1
This commit is contained in:
@@ -174,6 +174,11 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -194,6 +199,11 @@ async function downloadUrl(url, timeoutMs) {
|
||||
response.headers.get('content-type') || 'image/jpeg',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,14 @@
|
||||
- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。
|
||||
- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。
|
||||
|
||||
## 刷新网页后登录态失效
|
||||
|
||||
- 现象:刷新网页后,用户明明有本地 access token,却回到未登录状态。
|
||||
- 原因:`AuthGate` hydrate 曾先强制调用 `refreshStoredAccessToken()`;当 refresh cookie 临时失效、代理错配或后端返回 `401` 时,该方法会先清空本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
|
||||
- 处理:`refreshStoredAccessToken()` 增加 `clearOnFailure` 选项;`AuthGate` 在已有本地 access token 时先用 `/api/auth/me` 确认用户,确认成功后再后台 refresh 续期与写每日登录埋点,后台 refresh 失败不清 token。
|
||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login"`。
|
||||
- 关联:`src/services/apiClient.ts`、`src/components/auth/AuthGate.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
## 登录后推荐页加载出作品又回到未登录
|
||||
|
||||
- 现象:前端登录成功后进入推荐页,推荐页自动加载出一个作品,随后瞬间回到未登录;停留在其他页面或推荐页没加载出作品时不复现。
|
||||
@@ -162,6 +170,22 @@
|
||||
- 验证:`npm run test -- src/services/apiClient.test.ts src/services/assetReadUrlService.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle runtime uses frontend move merge logic and backend leaderboard"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "formal puzzle similar work keeps current run level progression"`。
|
||||
- 关联:`src/services/apiClient.ts`、`src/services/assetReadUrlService.ts`、`src/services/puzzle-runtime/puzzleRuntimeClient.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md`。
|
||||
|
||||
## 推荐页作品卡一直显示加载中
|
||||
|
||||
- 现象:推荐页有公开作品,但主视口一直停在“加载中...”,没有进入作品,也没有显示可操作错误。
|
||||
- 原因:推荐页自动启动嵌入运行态时先设置 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,但失败或并发切换时外层缺少稳定错误态和请求版本保护,旧启动请求可能晚到覆盖新状态。
|
||||
- 处理:`selectRecommendRuntimeEntry` 使用启动请求版本号丢弃旧请求;启动失败统一设置 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"` 并关闭 `isStartingRecommendEntry`。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
## 推荐页未登录入口误打开公开详情
|
||||
|
||||
- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁。
|
||||
- 原因:`RpgEntryHomeView` 曾只有 `onOpenGalleryDetail` 一个回调,同时服务发现页公开详情和推荐页作品入口;一旦为发现页保留公开浏览能力,推荐页也会跟着打开详情。
|
||||
- 处理:公开详情与推荐页入口分离为 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`。发现页、搜索和排行榜保留公开详情;推荐 Tab、推荐封面、推荐运行态错误重试和桌面推荐模块统一走登录门禁。未登录推荐页只显示封面,点击封面只弹登录窗,不携带登录后自动打开详情的回调。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged out recommend"`。
|
||||
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/technical/AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md`。
|
||||
|
||||
## Rust 冷编译导致 api-server 健康检查误超时
|
||||
|
||||
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 登录恢复与推荐页加载态收口修复
|
||||
|
||||
日期:`2026-05-09`
|
||||
|
||||
## 背景
|
||||
|
||||
推荐页作品卡偶发一直停留在“加载中...”,同时刷新网页后可能从已登录态回到未登录态。两类问题都发生在平台入口首屏和登录态恢复链路中,表现上像推荐页问题,实际涉及 `AuthGate`、请求层 token 处理和推荐页嵌入运行态的启动状态机。
|
||||
|
||||
## 根因
|
||||
|
||||
刷新网页掉线的关键根因是 `AuthGate` hydrate 先强制调用 `refreshStoredAccessToken()`。如果 refresh cookie 临时不可用、代理错配或后端短暂返回 `401`,该方法会清掉本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
|
||||
|
||||
推荐页作品卡卡住加载中的根因是推荐页自动启动运行态时,`activeRecommendEntryKey` 和 `activeRecommendRuntimeKind` 先被设置,失败时只把 kind 置空或由玩法内部写错误;外层没有稳定的 `activeRecommendRuntimeError` 收口。并发切换作品时,旧启动请求也可能晚到覆盖新启动状态。
|
||||
|
||||
## 修复
|
||||
|
||||
1. `refreshStoredAccessToken()` 增加 `clearOnFailure` 选项,默认保持原全局恢复语义;显式传 `false` 时 refresh 失败不会清空现有 access token。
|
||||
2. `AuthGate` 已有本地 access token 时,先用 `/api/auth/me` 确认当前用户;确认成功后再后台调用 refresh 续期与写每日登录埋点。后台 refresh 失败只静默忽略,不再把已确认账号改成未登录。
|
||||
3. 本地没有 access token 时,`AuthGate` 仍通过 refresh cookie 补票;该路径失败会清 token 并落到未登录,保持原有安全语义。
|
||||
4. 推荐页 `selectRecommendRuntimeEntry` 增加启动请求版本号。旧请求晚到后直接丢弃,不能覆盖当前作品。
|
||||
5. 推荐页运行态启动失败时统一写入 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"`,并关闭 `isStartingRecommendEntry`,避免作品卡永久显示加载态。
|
||||
6. 推荐页入口继续保持登录门禁。未登录用户点击推荐 Tab 只切到推荐封面并弹出登录弹窗;未登录状态下点击推荐封面再次弹出登录弹窗,不打开详情、不启动运行态。
|
||||
7. `RpgEntryHomeView` 将公开作品详情入口与推荐页入口拆成 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`:发现页、搜索结果和排行榜仍可按公开浏览能力打开详情;推荐页封面、推荐运行态错误重试、桌面推荐模块统一走推荐门禁入口。
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login|home recommendation"`
|
||||
2. 后续完整收口仍建议执行:
|
||||
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/components/auth/AuthGate.test.tsx src/services/apiClient.test.ts`
|
||||
- `npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
|
||||
- `npm run typecheck`
|
||||
- `npm run check:encoding`
|
||||
|
||||
## 关联文件
|
||||
|
||||
1. `src/services/apiClient.ts`
|
||||
2. `src/components/auth/AuthGate.tsx`
|
||||
3. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
4. `src/components/rpg-entry/RpgEntryHomeView.tsx`
|
||||
5. `src/components/auth/AuthGate.test.tsx`
|
||||
6. `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
|
||||
7. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
|
||||
@@ -7,6 +7,7 @@
|
||||
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
|
||||
- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
||||
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。
|
||||
- [AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md](./AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md):记录刷新网页后登录态失效和推荐页作品卡卡在加载中的联合修复,覆盖 `AuthGate` 本地 token 优先恢复、refresh 失败不清 token、推荐页启动请求版本保护和错误态收口。
|
||||
- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态、平台 bootstrap 私有投影刷新和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖 `authImpact: local` 请求策略、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。
|
||||
- [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
|
||||
- [HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md](./HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md):记录 Hyper3D Rodin Gen-2 文生 3D 模型、图生 3D 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。
|
||||
|
||||
@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
ensureStoredAccessToken: vi.fn(),
|
||||
getStoredAccessToken: vi.fn(),
|
||||
refreshStoredAccessToken: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
@@ -29,6 +30,7 @@ const authMocks = vi.hoisted(() => ({
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
|
||||
}));
|
||||
|
||||
@@ -96,6 +98,7 @@ beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getStoredAccessToken.mockReturnValue('');
|
||||
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: null,
|
||||
@@ -231,10 +234,37 @@ test('auth gate waits for refresh cookie rotation before exposing restored user
|
||||
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
|
||||
clearOnFailure: true,
|
||||
});
|
||||
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate keeps a valid local token login when refresh rotation fails after reload', async () => {
|
||||
authMocks.getStoredAccessToken.mockReturnValue('jwt-existing-token');
|
||||
authMocks.refreshStoredAccessToken.mockRejectedValue(
|
||||
new Error('refresh cookie 失效'),
|
||||
);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
expect(screen.getByText('私有数据:可读取')).toBeTruthy();
|
||||
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
|
||||
clearOnFailure: false,
|
||||
});
|
||||
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: [],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useGameSettings } from '../../hooks/useGameSettings';
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
refreshStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
authEntry,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSnapshot,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
@@ -81,6 +83,18 @@ function normalizeAvailableLoginMethods(
|
||||
: FALLBACK_LOGIN_METHODS;
|
||||
}
|
||||
|
||||
type AuthHydrateSessionResult =
|
||||
| {
|
||||
kind: 'authenticated';
|
||||
session: AuthSessionSnapshot & {
|
||||
user: AuthUser;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: 'guest';
|
||||
session: AuthSessionSnapshot | null;
|
||||
};
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
@@ -163,6 +177,56 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const restoreAuthSession = useCallback(async () => {
|
||||
const hadLocalAccessToken = Boolean(getStoredAccessToken());
|
||||
|
||||
if (hadLocalAccessToken) {
|
||||
try {
|
||||
const session = await getCurrentAuthUser();
|
||||
if (session.user) {
|
||||
const confirmedUser = session.user;
|
||||
// 中文注释:已有 access token 能确认当前账号时,refresh 只作为续期和每日登录埋点补强。
|
||||
// refresh cookie 临时失效或代理抖动不能反向抹掉这次已确认的登录态。
|
||||
void refreshStoredAccessToken({ clearOnFailure: false }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
session: {
|
||||
...session,
|
||||
user: confirmedUser,
|
||||
},
|
||||
} satisfies AuthHydrateSessionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'guest',
|
||||
session,
|
||||
} satisfies AuthHydrateSessionResult;
|
||||
} catch {
|
||||
// 本地 token 可能已过期或被吊销,再尝试通过 refresh cookie 补票。
|
||||
}
|
||||
}
|
||||
|
||||
await refreshStoredAccessToken({ clearOnFailure: true });
|
||||
const session = await getCurrentAuthUser();
|
||||
if (session.user) {
|
||||
const confirmedUser = session.user;
|
||||
return {
|
||||
kind: 'authenticated',
|
||||
session: {
|
||||
...session,
|
||||
user: confirmedUser,
|
||||
},
|
||||
} satisfies AuthHydrateSessionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'guest',
|
||||
session,
|
||||
} satisfies AuthHydrateSessionResult;
|
||||
}, []);
|
||||
|
||||
const logoutCurrentSession = useCallback(async () => {
|
||||
clearLocalAuthenticatedState();
|
||||
try {
|
||||
@@ -316,26 +380,21 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。
|
||||
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
|
||||
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
|
||||
await refreshStoredAccessToken();
|
||||
const restoredSession = await restoreAuthSession();
|
||||
if (!isCurrentHydrate()) {
|
||||
return;
|
||||
}
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isCurrentHydrate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
if (restoredSession.kind === 'guest') {
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||
normalizeAvailableLoginMethods(
|
||||
restoredSession.session?.availableLoginMethods,
|
||||
),
|
||||
);
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSession = restoredSession.session;
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(
|
||||
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
|
||||
@@ -368,7 +427,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
};
|
||||
}, [activateReadyUser]);
|
||||
}, [restoreAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyUser) {
|
||||
|
||||
@@ -1558,6 +1558,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<string | null>(null);
|
||||
const [isStartingRecommendEntry, setIsStartingRecommendEntry] =
|
||||
useState(false);
|
||||
const recommendRuntimeStartRequestRef = useRef(0);
|
||||
const [, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
|
||||
null,
|
||||
);
|
||||
@@ -5651,38 +5652,36 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const openRecommendGalleryDetail = useCallback(
|
||||
const openPublicGalleryDetail = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
runProtectedAction(() => {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void openPuzzlePublicWorkDetail(entry.profileId, {
|
||||
tab: platformBootstrap.platformTab,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void openPuzzlePublicWorkDetail(entry.profileId, {
|
||||
tab: platformBootstrap.platformTab,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
void openVisualNovelPublicWorkDetail(entry.profileId);
|
||||
return;
|
||||
}
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
void openVisualNovelPublicWorkDetail(entry.profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
void openRpgPublicWorkDetail(entry);
|
||||
});
|
||||
void openRpgPublicWorkDetail(entry);
|
||||
},
|
||||
[
|
||||
openPuzzlePublicWorkDetail,
|
||||
@@ -5690,9 +5689,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
openRpgPublicWorkDetail,
|
||||
openVisualNovelPublicWorkDetail,
|
||||
platformBootstrap.platformTab,
|
||||
runProtectedAction,
|
||||
],
|
||||
);
|
||||
|
||||
const openRecommendGalleryDetail = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
runProtectedAction(() => {
|
||||
openPublicGalleryDetail(entry);
|
||||
});
|
||||
},
|
||||
[openPublicGalleryDetail, runProtectedAction],
|
||||
);
|
||||
const openPuzzleDetail = useCallback(
|
||||
async (
|
||||
profileId: string,
|
||||
@@ -6112,8 +6119,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
async (entry: PlatformPublicGalleryCard) => {
|
||||
const entryKey = getPlatformPublicGalleryEntryKey(entry);
|
||||
const runtimeKind = getPlatformRecommendRuntimeKind(entry);
|
||||
const startRequestId = recommendRuntimeStartRequestRef.current + 1;
|
||||
recommendRuntimeStartRequestRef.current = startRequestId;
|
||||
const isCurrentStartRequest = () =>
|
||||
recommendRuntimeStartRequestRef.current === startRequestId;
|
||||
if (entryKey !== activeRecommendEntryKey) {
|
||||
await saveAndExitRecommendPuzzleRuntime();
|
||||
if (!isCurrentStartRequest()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setActiveRecommendEntryKey(entryKey);
|
||||
setActiveRecommendRuntimeKind(runtimeKind);
|
||||
@@ -6182,9 +6196,28 @@ export function PlatformEntryFlowShellImpl({
|
||||
started = true;
|
||||
}
|
||||
|
||||
setActiveRecommendRuntimeKind(started ? runtimeKind : null);
|
||||
if (!isCurrentStartRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
setActiveRecommendRuntimeKind(runtimeKind);
|
||||
setActiveRecommendRuntimeError(null);
|
||||
} else {
|
||||
setActiveRecommendRuntimeKind(null);
|
||||
setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrentStartRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveRecommendRuntimeKind(null);
|
||||
setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。');
|
||||
} finally {
|
||||
setIsStartingRecommendEntry(false);
|
||||
if (isCurrentStartRequest()) {
|
||||
setIsStartingRecommendEntry(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -7549,7 +7582,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={openRecommendGalleryDetail}
|
||||
onOpenGalleryDetail={openPublicGalleryDetail}
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isStartingRecommendEntry={
|
||||
|
||||
@@ -2810,6 +2810,7 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDiscoverHub(user);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -3066,6 +3067,44 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '星桥机关',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [publishedPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockRejectedValueOnce(
|
||||
new Error('启动拼图玩法失败'),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('作品暂时无法进入,请稍后再试。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('加载中...')).toBeNull();
|
||||
});
|
||||
|
||||
test('published big fish works stay hidden from platform home and game category channel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedBigFishWork: BigFishWorkSummary = {
|
||||
|
||||
@@ -515,6 +515,7 @@ function renderLoggedOutHomeView(
|
||||
| 'featuredEntries'
|
||||
| 'latestEntries'
|
||||
| 'onOpenGalleryDetail'
|
||||
| 'onOpenRecommendGalleryDetail'
|
||||
| 'onSearchPublicCode'
|
||||
| 'recommendRuntimeContent'
|
||||
| 'activeRecommendEntryKey'
|
||||
@@ -568,6 +569,7 @@ function renderLoggedOutHomeView(
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
|
||||
recommendRuntimeContent={
|
||||
overrides.recommendRuntimeContent ?? (
|
||||
<div data-testid="recommend-runtime">运行内容</div>
|
||||
@@ -592,6 +594,7 @@ function renderStatefulLoggedOutHomeView(
|
||||
| 'featuredEntries'
|
||||
| 'latestEntries'
|
||||
| 'onOpenGalleryDetail'
|
||||
| 'onOpenRecommendGalleryDetail'
|
||||
| 'onSearchPublicCode'
|
||||
| 'recommendRuntimeContent'
|
||||
| 'activeRecommendEntryKey'
|
||||
@@ -650,6 +653,7 @@ function renderStatefulLoggedOutHomeView(
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
|
||||
recommendRuntimeContent={
|
||||
overrides.recommendRuntimeContent ?? (
|
||||
<div data-testid="recommend-runtime" />
|
||||
@@ -1171,7 +1175,93 @@ test('logged out recommend cover opens login modal again', async () => {
|
||||
await user.click(screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(2);
|
||||
expect(openLoginModal).toHaveBeenLastCalledWith(expect.any(Function));
|
||||
expect(openLoginModal).toHaveBeenLastCalledWith();
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('logged out desktop recommend page renders cover only', () => {
|
||||
mockDesktopLayout();
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
});
|
||||
|
||||
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
|
||||
expect(screen.queryByText('今日游戏')).toBeNull();
|
||||
expect(screen.queryByText('作品分类')).toBeNull();
|
||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
||||
});
|
||||
|
||||
test('logged in recommend page uses gated recommend detail callback', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const onOpenRecommendGalleryDetail = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[puzzlePublicEntry]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={onOpenGalleryDetail}
|
||||
onOpenRecommendGalleryDetail={onOpenRecommendGalleryDetail}
|
||||
recommendRuntimeError="作品暂时无法进入,请稍后再试。"
|
||||
activeRecommendEntryKey="puzzle:user-2:puzzle-profile-public-1"
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('作品暂时无法进入,请稍后再试。'));
|
||||
|
||||
expect(onOpenRecommendGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1435,7 +1525,7 @@ test('mobile today channel only shows newly published works from today', async (
|
||||
expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop home syncs mobile home modules without square or latest labels', () => {
|
||||
test('desktop logged in home syncs mobile home modules without square or latest labels', () => {
|
||||
mockDesktopLayout();
|
||||
const todayPublishedAt = new Date().toISOString();
|
||||
const todayEntry = {
|
||||
@@ -1448,9 +1538,64 @@ test('desktop home syncs mobile home modules without square or latest labels', (
|
||||
updatedAt: todayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, todayEntry],
|
||||
});
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[puzzlePublicEntry, todayEntry]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('今日游戏')).toBeTruthy();
|
||||
expect(screen.getAllByText('推荐').length).toBeGreaterThan(0);
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
|
||||
recommendRuntimeContent?: ReactNode;
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
@@ -2898,6 +2899,7 @@ export function RpgEntryHomeView({
|
||||
onResumeSave,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenRecommendGalleryDetail,
|
||||
recommendRuntimeContent,
|
||||
activeRecommendEntryKey = null,
|
||||
isStartingRecommendEntry = false,
|
||||
@@ -3007,6 +3009,8 @@ export function RpgEntryHomeView({
|
||||
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const openRecommendGalleryDetail =
|
||||
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
@@ -3771,12 +3775,17 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry));
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenGalleryDetail(activeRecommendEntry);
|
||||
}, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]);
|
||||
openRecommendGalleryDetail(activeRecommendEntry);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
authUi,
|
||||
isAuthenticated,
|
||||
openRecommendGalleryDetail,
|
||||
]);
|
||||
const selectNextRecommendEntry = useCallback(() => {
|
||||
onSelectNextRecommendEntry?.();
|
||||
}, [onSelectNextRecommendEntry]);
|
||||
@@ -3786,7 +3795,7 @@ export function RpgEntryHomeView({
|
||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
onOpenGalleryDetail(leadPublicEntry);
|
||||
openRecommendGalleryDetail(leadPublicEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3870,7 +3879,7 @@ export function RpgEntryHomeView({
|
||||
type="button"
|
||||
onClick={() =>
|
||||
activeRecommendEntry
|
||||
? onOpenGalleryDetail(activeRecommendEntry)
|
||||
? openRecommendGalleryDetail(activeRecommendEntry)
|
||||
: undefined
|
||||
}
|
||||
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
|
||||
@@ -4463,7 +4472,7 @@ export function RpgEntryHomeView({
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
|
||||
entry={entry}
|
||||
rank={index + 1}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -4486,7 +4495,7 @@ export function RpgEntryHomeView({
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
@@ -4552,7 +4561,7 @@ export function RpgEntryHomeView({
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenGalleryDetail({
|
||||
openRecommendGalleryDetail({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
publicWorkCode: null,
|
||||
@@ -4621,7 +4630,7 @@ export function RpgEntryHomeView({
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
@@ -4638,7 +4647,10 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
|
||||
const tabContentById = {
|
||||
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
|
||||
home:
|
||||
!isAuthenticated || !isDesktopLayout
|
||||
? mobileRecommendContent
|
||||
: desktopHomeContent,
|
||||
category: categoryContent,
|
||||
create: createContent,
|
||||
saves: savesContent,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchWithApiAuth,
|
||||
getStoredAccessToken,
|
||||
isTimeoutError,
|
||||
refreshStoredAccessToken,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
@@ -312,6 +313,27 @@ describe('apiClient', () => {
|
||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||
});
|
||||
|
||||
it('keeps local token when explicit refresh opts out of clearing on failure', async () => {
|
||||
setStoredAccessToken('usable-local-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
await expect(
|
||||
refreshStoredAccessToken({ clearOnFailure: false }),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('usable-local-token');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
|
||||
@@ -548,11 +548,17 @@ export async function ensureStoredAccessToken() {
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
export async function refreshStoredAccessToken() {
|
||||
export async function refreshStoredAccessToken(
|
||||
options: {
|
||||
clearOnFailure?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
try {
|
||||
return await refreshAccessToken();
|
||||
} catch (error) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
if (options.clearOnFailure !== false) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user