This commit is contained in:
2026-05-09 19:56:03 +08:00
parent 052dbc248b
commit 7c8aa1e124
12 changed files with 483 additions and 59 deletions

View File

@@ -174,6 +174,11 @@ async function fetchJson(url, options, timeoutMs) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
} }
return JSON.parse(text); return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
}
throw error;
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
@@ -194,6 +199,11 @@ async function downloadUrl(url, timeoutMs) {
response.headers.get('content-type') || 'image/jpeg', 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 { } finally {
clearTimeout(timer); clearTimeout(timer);
} }

View File

@@ -151,6 +151,14 @@
- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。 - 验证:`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` - 关联:`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"` - 验证:`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` - 关联:`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 健康检查误超时 ## Rust 冷编译导致 api-server 健康检查误超时
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run` - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`

View File

@@ -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`

View File

@@ -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 差异。 - [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` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [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` 自动降级策略和手动排障命令。 - [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 运行态启动、拼图开局/排行榜/下一关和回归测试。 - [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 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。 - [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 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。 - [HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md](./HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md):记录 Hyper3D Rodin Gen-2 文生 3D 模型、图生 3D 模型、状态查询和下载列表的后端代理、环境变量、请求约束与验收边界。

View File

@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(), authEntry: vi.fn(),
changePassword: vi.fn(), changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(), ensureStoredAccessToken: vi.fn(),
getStoredAccessToken: vi.fn(),
refreshStoredAccessToken: vi.fn(), refreshStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(), getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(), getCurrentAuthUser: vi.fn(),
@@ -29,6 +30,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({ vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed', AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken, ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
getStoredAccessToken: authMocks.getStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken, refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
})); }));
@@ -96,6 +98,7 @@ beforeEach(() => {
window.history.replaceState(null, '', '/'); window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token'); authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({
user: null, user: null,
@@ -231,10 +234,37 @@ test('auth gate waits for refresh cookie rotation before exposing restored user
expect(await screen.findByText('应用内容')).toBeTruthy(); expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1); expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledWith({
clearOnFailure: true,
});
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled(); expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); 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 () => { test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({ authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: [], availableLoginMethods: [],

View File

@@ -10,6 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings'; import { useGameSettings } from '../../hooks/useGameSettings';
import { import {
AUTH_STATE_EVENT, AUTH_STATE_EVENT,
getStoredAccessToken,
refreshStoredAccessToken, refreshStoredAccessToken,
} from '../../services/apiClient'; } from '../../services/apiClient';
import { import {
@@ -18,6 +19,7 @@ import {
authEntry, authEntry,
type AuthLoginMethod, type AuthLoginMethod,
type AuthRiskBlockSummary, type AuthRiskBlockSummary,
type AuthSessionSnapshot,
type AuthSessionSummary, type AuthSessionSummary,
type AuthUser, type AuthUser,
bindWechatPhone, bindWechatPhone,
@@ -81,6 +83,18 @@ function normalizeAvailableLoginMethods(
: FALLBACK_LOGIN_METHODS; : FALLBACK_LOGIN_METHODS;
} }
type AuthHydrateSessionResult =
| {
kind: 'authenticated';
session: AuthSessionSnapshot & {
user: AuthUser;
};
}
| {
kind: 'guest';
session: AuthSessionSnapshot | null;
};
export function AuthGate({ children }: AuthGateProps) { export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking'); const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
@@ -163,6 +177,56 @@ export function AuthGate({ children }: AuthGateProps) {
setError(''); 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 () => { const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState(); clearLocalAuthenticatedState();
try { try {
@@ -316,26 +380,21 @@ export function AuthGate({ children }: AuthGateProps) {
} }
try { try {
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。 const restoredSession = await restoreAuthSession();
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
await refreshStoredAccessToken();
if (!isCurrentHydrate()) { if (!isCurrentHydrate()) {
return; return;
} }
const nextSession = await getCurrentAuthUser(); if (restoredSession.kind === 'guest') {
if (!isCurrentHydrate()) {
return;
}
if (!nextSession.user) {
setAvailableLoginMethods( setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods), normalizeAvailableLoginMethods(
restoredSession.session?.availableLoginMethods,
),
); );
await resolveGuestFallback(); await resolveGuestFallback();
return; return;
} }
const nextSession = restoredSession.session;
setUser(nextSession.user); setUser(nextSession.user);
setAvailableLoginMethods( setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods), normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
@@ -368,7 +427,7 @@ export function AuthGate({ children }: AuthGateProps) {
isActive = false; isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
}; };
}, [activateReadyUser]); }, [restoreAuthSession]);
useEffect(() => { useEffect(() => {
if (!readyUser) { if (!readyUser) {

View File

@@ -1558,6 +1558,7 @@ export function PlatformEntryFlowShellImpl({
useState<string | null>(null); useState<string | null>(null);
const [isStartingRecommendEntry, setIsStartingRecommendEntry] = const [isStartingRecommendEntry, setIsStartingRecommendEntry] =
useState(false); useState(false);
const recommendRuntimeStartRequestRef = useRef(0);
const [, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>( const [, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
null, null,
); );
@@ -5651,38 +5652,36 @@ export function PlatformEntryFlowShellImpl({
], ],
); );
const openRecommendGalleryDetail = useCallback( const openPublicGalleryDetail = useCallback(
(entry: PlatformPublicGalleryCard) => { (entry: PlatformPublicGalleryCard) => {
runProtectedAction(() => { if (isBigFishGalleryEntry(entry)) {
if (isBigFishGalleryEntry(entry)) { openPublicWorkDetail(entry);
openPublicWorkDetail(entry); return;
return; }
}
if (isPuzzleGalleryEntry(entry)) { if (isPuzzleGalleryEntry(entry)) {
void openPuzzlePublicWorkDetail(entry.profileId, { void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab, tab: platformBootstrap.platformTab,
}); });
return; return;
} }
if (isMatch3DGalleryEntry(entry)) { if (isMatch3DGalleryEntry(entry)) {
openPublicWorkDetail(entry); openPublicWorkDetail(entry);
return; return;
} }
if (isSquareHoleGalleryEntry(entry)) { if (isSquareHoleGalleryEntry(entry)) {
openPublicWorkDetail(entry); openPublicWorkDetail(entry);
return; return;
} }
if (isVisualNovelGalleryEntry(entry)) { if (isVisualNovelGalleryEntry(entry)) {
void openVisualNovelPublicWorkDetail(entry.profileId); void openVisualNovelPublicWorkDetail(entry.profileId);
return; return;
} }
void openRpgPublicWorkDetail(entry); void openRpgPublicWorkDetail(entry);
});
}, },
[ [
openPuzzlePublicWorkDetail, openPuzzlePublicWorkDetail,
@@ -5690,9 +5689,17 @@ export function PlatformEntryFlowShellImpl({
openRpgPublicWorkDetail, openRpgPublicWorkDetail,
openVisualNovelPublicWorkDetail, openVisualNovelPublicWorkDetail,
platformBootstrap.platformTab, platformBootstrap.platformTab,
runProtectedAction,
], ],
); );
const openRecommendGalleryDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
runProtectedAction(() => {
openPublicGalleryDetail(entry);
});
},
[openPublicGalleryDetail, runProtectedAction],
);
const openPuzzleDetail = useCallback( const openPuzzleDetail = useCallback(
async ( async (
profileId: string, profileId: string,
@@ -6112,8 +6119,15 @@ export function PlatformEntryFlowShellImpl({
async (entry: PlatformPublicGalleryCard) => { async (entry: PlatformPublicGalleryCard) => {
const entryKey = getPlatformPublicGalleryEntryKey(entry); const entryKey = getPlatformPublicGalleryEntryKey(entry);
const runtimeKind = getPlatformRecommendRuntimeKind(entry); const runtimeKind = getPlatformRecommendRuntimeKind(entry);
const startRequestId = recommendRuntimeStartRequestRef.current + 1;
recommendRuntimeStartRequestRef.current = startRequestId;
const isCurrentStartRequest = () =>
recommendRuntimeStartRequestRef.current === startRequestId;
if (entryKey !== activeRecommendEntryKey) { if (entryKey !== activeRecommendEntryKey) {
await saveAndExitRecommendPuzzleRuntime(); await saveAndExitRecommendPuzzleRuntime();
if (!isCurrentStartRequest()) {
return;
}
} }
setActiveRecommendEntryKey(entryKey); setActiveRecommendEntryKey(entryKey);
setActiveRecommendRuntimeKind(runtimeKind); setActiveRecommendRuntimeKind(runtimeKind);
@@ -6182,9 +6196,28 @@ export function PlatformEntryFlowShellImpl({
started = true; 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 { } finally {
setIsStartingRecommendEntry(false); if (isCurrentStartRequest()) {
setIsStartingRecommendEntry(false);
}
} }
}, },
[ [
@@ -7549,7 +7582,8 @@ export function PlatformEntryFlowShellImpl({
}} }}
onOpenCreateWorld={openCreationTypePicker} onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={openRecommendGalleryDetail} onOpenGalleryDetail={openPublicGalleryDetail}
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
recommendRuntimeContent={recommendRuntimeContent} recommendRuntimeContent={recommendRuntimeContent}
activeRecommendEntryKey={activeRecommendEntryKey} activeRecommendEntryKey={activeRecommendEntryKey}
isStartingRecommendEntry={ isStartingRecommendEntry={

View File

@@ -2810,6 +2810,7 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
render(<TestWrapper withAuth />); render(<TestWrapper withAuth />);
await openDiscoverHub(user);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); 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 () => { test('published big fish works stay hidden from platform home and game category channel', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = { const publishedBigFishWork: BigFishWorkSummary = {

View File

@@ -515,6 +515,7 @@ function renderLoggedOutHomeView(
| 'featuredEntries' | 'featuredEntries'
| 'latestEntries' | 'latestEntries'
| 'onOpenGalleryDetail' | 'onOpenGalleryDetail'
| 'onOpenRecommendGalleryDetail'
| 'onSearchPublicCode' | 'onSearchPublicCode'
| 'recommendRuntimeContent' | 'recommendRuntimeContent'
| 'activeRecommendEntryKey' | 'activeRecommendEntryKey'
@@ -568,6 +569,7 @@ function renderLoggedOutHomeView(
onOpenCreateWorld={vi.fn()} onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()} onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()} onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={ recommendRuntimeContent={
overrides.recommendRuntimeContent ?? ( overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime"></div> <div data-testid="recommend-runtime"></div>
@@ -592,6 +594,7 @@ function renderStatefulLoggedOutHomeView(
| 'featuredEntries' | 'featuredEntries'
| 'latestEntries' | 'latestEntries'
| 'onOpenGalleryDetail' | 'onOpenGalleryDetail'
| 'onOpenRecommendGalleryDetail'
| 'onSearchPublicCode' | 'onSearchPublicCode'
| 'recommendRuntimeContent' | 'recommendRuntimeContent'
| 'activeRecommendEntryKey' | 'activeRecommendEntryKey'
@@ -650,6 +653,7 @@ function renderStatefulLoggedOutHomeView(
onOpenCreateWorld={vi.fn()} onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()} onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()} onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenRecommendGalleryDetail={overrides.onOpenRecommendGalleryDetail}
recommendRuntimeContent={ recommendRuntimeContent={
overrides.recommendRuntimeContent ?? ( overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime" /> <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 })); await user.click(screen.getByRole('button', { name: / /u }));
expect(openLoginModal).toHaveBeenCalledTimes(2); 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(); 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(); 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(); mockDesktopLayout();
const todayPublishedAt = new Date().toISOString(); const todayPublishedAt = new Date().toISOString();
const todayEntry = { const todayEntry = {
@@ -1448,9 +1538,64 @@ test('desktop home syncs mobile home modules without square or latest labels', (
updatedAt: todayPublishedAt, updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard; } satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), { render(
latestEntries: [puzzlePublicEntry, todayEntry], <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.getByText('今日游戏')).toBeTruthy();
expect(screen.getAllByText('推荐').length).toBeGreaterThan(0); expect(screen.getAllByText('推荐').length).toBeGreaterThan(0);

View File

@@ -118,6 +118,7 @@ export interface RpgEntryHomeViewProps {
onOpenCreateWorld: () => void; onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void; onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void; onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode; recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null; activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean; isStartingRecommendEntry?: boolean;
@@ -2898,6 +2899,7 @@ export function RpgEntryHomeView({
onResumeSave, onResumeSave,
onOpenCreateTypePicker, onOpenCreateTypePicker,
onOpenGalleryDetail, onOpenGalleryDetail,
onOpenRecommendGalleryDetail,
recommendRuntimeContent, recommendRuntimeContent,
activeRecommendEntryKey = null, activeRecommendEntryKey = null,
isStartingRecommendEntry = false, isStartingRecommendEntry = false,
@@ -3007,6 +3009,8 @@ export function RpgEntryHomeView({
const [isSavingAvatar, setIsSavingAvatar] = useState(false); const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user); const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout(); const isDesktopLayout = usePlatformDesktopLayout();
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const featuredShelf = useMemo( const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6), () => featuredEntries.slice(0, 6),
[featuredEntries], [featuredEntries],
@@ -3771,12 +3775,17 @@ export function RpgEntryHomeView({
} }
if (!isAuthenticated) { if (!isAuthenticated) {
authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry)); authUi?.openLoginModal();
return; return;
} }
onOpenGalleryDetail(activeRecommendEntry); openRecommendGalleryDetail(activeRecommendEntry);
}, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]); }, [
activeRecommendEntry,
authUi,
isAuthenticated,
openRecommendGalleryDetail,
]);
const selectNextRecommendEntry = useCallback(() => { const selectNextRecommendEntry = useCallback(() => {
onSelectNextRecommendEntry?.(); onSelectNextRecommendEntry?.();
}, [onSelectNextRecommendEntry]); }, [onSelectNextRecommendEntry]);
@@ -3786,7 +3795,7 @@ export function RpgEntryHomeView({
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null; const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => { const openLeadPublicEntry = () => {
if (leadPublicEntry) { if (leadPublicEntry) {
onOpenGalleryDetail(leadPublicEntry); openRecommendGalleryDetail(leadPublicEntry);
return; return;
} }
@@ -3870,7 +3879,7 @@ export function RpgEntryHomeView({
type="button" type="button"
onClick={() => onClick={() =>
activeRecommendEntry activeRecommendEntry
? onOpenGalleryDetail(activeRecommendEntry) ? openRecommendGalleryDetail(activeRecommendEntry)
: undefined : undefined
} }
className="platform-recommend-runtime-state platform-recommend-runtime-state--button" className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
@@ -4463,7 +4472,7 @@ export function RpgEntryHomeView({
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`} key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
entry={entry} entry={entry}
rank={index + 1} rank={index + 1}
onClick={() => onOpenGalleryDetail(entry)} onClick={() => openRecommendGalleryDetail(entry)}
/> />
))} ))}
</div> </div>
@@ -4486,7 +4495,7 @@ export function RpgEntryHomeView({
<WorldCard <WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`} key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
entry={entry} entry={entry}
onClick={() => onOpenGalleryDetail(entry)} onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0" className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/> />
@@ -4552,7 +4561,7 @@ export function RpgEntryHomeView({
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`} key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button" type="button"
onClick={() => onClick={() =>
onOpenGalleryDetail({ openRecommendGalleryDetail({
ownerUserId: entry.ownerUserId, ownerUserId: entry.ownerUserId,
profileId: entry.profileId, profileId: entry.profileId,
publicWorkCode: null, publicWorkCode: null,
@@ -4621,7 +4630,7 @@ export function RpgEntryHomeView({
<WorldCard <WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`} key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
entry={entry} entry={entry}
onClick={() => onOpenGalleryDetail(entry)} onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0" className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)} authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/> />
@@ -4638,7 +4647,10 @@ export function RpgEntryHomeView({
); );
const tabContentById = { const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent, home:
!isAuthenticated || !isDesktopLayout
? mobileRecommendContent
: desktopHomeContent,
category: categoryContent, category: categoryContent,
create: createContent, create: createContent,
saves: savesContent, saves: savesContent,

View File

@@ -8,6 +8,7 @@ import {
fetchWithApiAuth, fetchWithApiAuth,
getStoredAccessToken, getStoredAccessToken,
isTimeoutError, isTimeoutError,
refreshStoredAccessToken,
requestJson, requestJson,
setStoredAccessToken, setStoredAccessToken,
} from './apiClient'; } from './apiClient';
@@ -312,6 +313,27 @@ describe('apiClient', () => {
expect(getStoredAccessToken()).toBe('still-valid-token'); 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 () => { it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
setStoredAccessToken('expired-token', { emit: false }); setStoredAccessToken('expired-token', { emit: false });
fetchMock fetchMock

View File

@@ -548,11 +548,17 @@ export async function ensureStoredAccessToken() {
return refreshAccessToken(); return refreshAccessToken();
} }
export async function refreshStoredAccessToken() { export async function refreshStoredAccessToken(
options: {
clearOnFailure?: boolean;
} = {},
) {
try { try {
return await refreshAccessToken(); return await refreshAccessToken();
} catch (error) { } catch (error) {
clearStoredAccessToken({ emit: false }); if (options.clearOnFailure !== false) {
clearStoredAccessToken({ emit: false });
}
throw error; throw error;
} }
} }