diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 20134405..67b7ca7b 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -228,6 +228,7 @@ import { sessionIdsToPrefetch, shouldAutoOpenRectificationSession, starterHomeLandingNeedsConsultation, + HOME_BOOTSTRAP_SLOW_MS, type BootstrapPhase, } from "@/lib/home-bootstrap"; import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; @@ -252,7 +253,9 @@ export default function Home() { account, setAccount, ready: sessionListReady, + settled: sessionListSettled, boot: sessionListBoot, + reload: reloadSessionList, registerShellControls, } = sessionList; const [accountMenuOpen, setAccountMenuOpen] = useState(false); @@ -548,7 +551,7 @@ export default function Home() { ]); useEffect(() => { - if (bootstrapPhase === "account" || !accountId) return; + if (bootstrapPhase === "account" || !accountId || !sessionListSettled) return; void (async () => { try { const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" }); @@ -848,7 +851,7 @@ export default function Home() { controller.abort(); setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。"); setHydrated(true); - }, 8000); + }, HOME_BOOTSTRAP_SLOW_MS); async function loadCloudData() { let redirectedToLogin = false; @@ -954,6 +957,15 @@ export default function Home() { return; } + // Start independent reads before waiting for the provider so slow-network + // latency is spent in parallel, not as a serial account -> model chain. + const modelCatalogPromise = fetchModelCatalog(controller.signal) + .then((catalog) => ({ catalog, error: null as unknown })) + .catch((error: unknown) => ({ catalog: null, error })); + const activeStatusPromise = fetchActiveConsultationStatus(controller.signal).catch((caught: unknown) => { + if (caught instanceof Error && caught.name === "AbortError") throw caught; + return null; + }); await sessionListReady; if (controller.signal.aborted) return; const listBoot = sessionListBoot(); @@ -962,16 +974,11 @@ export default function Home() { return; } const nextAccount = listBoot.account; - const modelCatalogResult = await fetchModelCatalog(controller.signal) - .then((catalog) => ({ catalog, unavailable: false })) - .catch((caught: unknown) => { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - return { catalog: null, unavailable: true }; - }); + const modelCatalogResult = await modelCatalogPromise; + if (modelCatalogResult.error) throw modelCatalogResult.error; const nextModelCatalog = modelCatalogResult.catalog; const nextProfile = readProfile(nextAccount.profile); const parsedSessions = readSessions(listBoot.rawRows, nextModelCatalog); - setSessionsCursor(listBoot.cursor); let nextSessions = parsedSessions.sessions.length > 0 ? parsedSessions.sessions : listBoot.sessions; if (nextSessions.length === 0) { nextSessions = [createSession( @@ -982,7 +989,7 @@ export default function Home() { } nextSessions = applyLegacySessionControls(nextAccount.user.id, nextSessions); - let reservedConsultation: ConsultationStatus | null = null; + let reservedConsultation: ConsultationStatus | null = await activeStatusPromise; const storedPending = dropRectificationStoredPending( readStoredPendingConsultation(sessionStorage.getItem(pendingConsultationStorageKey), nextSessions.map((session) => session.id)), nextSessions, @@ -1001,6 +1008,7 @@ export default function Home() { reservedConsultation = status; } else { sessionStorage.removeItem(pendingConsultationStorageKey); + reservedConsultation = null; } } catch (caught) { if (caught instanceof Error && caught.name === "AbortError") throw caught; @@ -1011,13 +1019,6 @@ export default function Home() { status: "reserved", }; } - } else { - try { - reservedConsultation = await fetchActiveConsultationStatus(controller.signal); - consultationStatusMissingCount.current = 0; - } catch (caught) { - if (caught instanceof Error && caught.name === "AbortError") throw caught; - } } if (controller.signal.aborted) return; @@ -1056,6 +1057,7 @@ export default function Home() { } if (controller.signal.aborted) return; clearStaleClientReload(sessionStorage); + setSessionsCursor(listBoot.cursor); setAccount(nextAccount); setModelCatalog(nextModelCatalog); setProfile(nextProfile); @@ -1081,9 +1083,7 @@ export default function Home() { setComposerNotice(""); } } - if (modelCatalogResult.unavailable) { - setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); - } else if (parsedSessions.fallbackSessionIds.length > 0) { + if (parsedSessions.fallbackSessionIds.length > 0) { setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); } else if (lookedUp.notice) { setComposerNotice(lookedUp.notice); @@ -1127,7 +1127,7 @@ export default function Home() { window.clearTimeout(bootstrapTimeout); controller.abort(); }; - }, []); + }, [sessionListReady]); useEffect(() => { if (!hydrated || uiPreview.current) return; @@ -1534,14 +1534,22 @@ export default function Home() { ); } - if (!account) { + // Bootstrap failures stay fatal even if the provider finishes its own read + // after the bootstrap controller was aborted; never reveal without a catalog. + if (!account || !modelCatalog) { return (
暂时无法进入 Jyotisha {accountError}
- + 返回登录
diff --git a/frontend/src/lib/first-paint-fallback.ts b/frontend/src/lib/first-paint-fallback.ts index 5823c85a..14deec39 100644 --- a/frontend/src/lib/first-paint-fallback.ts +++ b/frontend/src/lib/first-paint-fallback.ts @@ -3,8 +3,8 @@ * * It has to run when the client bundle never runs, so the generated string is * ES5: no modules, no React, no reload until the button is clicked. - * The timer is longer than the 8000ms account budget plus the 4000ms prepare - * reveal, so a normal hydrate does not flash this copy. + * The timer is longer than the 20s in-bundle bootstrap slow threshold, so a + * normal hydrate does not flash this copy. */ import { @@ -12,7 +12,7 @@ import { sanitizeBootstrapBuildId, } from "./home-bootstrap-failure.ts"; -export const FIRST_PAINT_FALLBACK_TIMEOUT_MS = 13_000; +export const FIRST_PAINT_FALLBACK_TIMEOUT_MS = 25_000; export const FIRST_PAINT_FALLBACK_TITLE = "这个页面没能加载完"; diff --git a/frontend/src/lib/home-bootstrap-failure.ts b/frontend/src/lib/home-bootstrap-failure.ts index 909e9da9..17470e90 100644 --- a/frontend/src/lib/home-bootstrap-failure.ts +++ b/frontend/src/lib/home-bootstrap-failure.ts @@ -1,7 +1,7 @@ /** * Home bootstrap failures are not one "the API is slow" bucket. * - * `api-error` is the in-bundle account/session path (HTTP status or the 8s + * `api-error` is the in-bundle account/session path (HTTP status or the 20s * cloud timeout). The other categories are what the inline dead-screen script * can see before that bundle runs. Observation records only category, build * id, and duration — never a payload, exception string, or birth data. diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index b3cb3656..16baff63 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -67,6 +67,7 @@ export function starterHomeLandingNeedsConsultation( return !landing || landing.sessionType === "birth_time_rectification"; } +export const HOME_BOOTSTRAP_SLOW_MS = 20_000; export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000; export const SESSION_PREFETCH_COUNT = 5; diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index a4617af1..f236886e 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -53,6 +53,7 @@ export type SessionListContextValue = { settled: boolean; ready: Promise; boot: () => SessionListBoot | null; + reload: () => void; registerShellControls: (registration: ShellRegistration | null) => void; }; @@ -125,29 +126,53 @@ export function SessionListProvider({ children }: { children: ReactNode }) { const [settled, setSettled] = useState(false); const [registration, setRegistration] = useState(null); const bootRef = useRef(null); - const [readyPack] = useState(createReadyGate); + const controllerRef = useRef(null); + const reloadRef = useRef<() => void>(() => undefined); + const readyPackRef = useRef(createReadyGate()); + const [readyVersion, forceReadyVersion] = useState(0); useEffect(() => { - const controller = new AbortController(); - void loadSessionList(controller.signal) - .then((boot) => { - if (controller.signal.aborted) return; - bootRef.current = boot; - setSessions(boot.sessions); - setSessionsCursor(boot.cursor); - setAccount(boot.account); - setSignedOut(boot.signedOut); - setSettled(true); - readyPack.resolve(); - }) - .catch(() => { - if (controller.signal.aborted) return; - bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false }; - setSettled(true); - readyPack.resolve(); - }); - return () => controller.abort(); - }, [readyPack]); + let disposed = false; + const load = (readyPack = readyPackRef.current) => { + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + setSettled(false); + setSignedOut(false); + bootRef.current = null; + void loadSessionList(controller.signal) + .then((boot) => { + if (disposed || controller.signal.aborted || controllerRef.current !== controller) return; + bootRef.current = boot; + setSessions(boot.sessions); + setSessionsCursor(boot.cursor); + setAccount(boot.account); + setSignedOut(boot.signedOut); + setSettled(true); + readyPack.resolve(); + }) + .catch(() => { + if (disposed || controller.signal.aborted || controllerRef.current !== controller) return; + bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false }; + setSettled(true); + readyPack.resolve(); + }); + }; + reloadRef.current = () => { + const nextReadyPack = createReadyGate(); + readyPackRef.current = nextReadyPack; + forceReadyVersion((version) => version + 1); + load(nextReadyPack); + }; + load(); + return () => { + disposed = true; + reloadRef.current = () => undefined; + controllerRef.current?.abort(); + }; + }, []); + + const reload = useCallback(() => reloadRef.current(), []); const registerShellControls = useCallback((next: ShellRegistration | null) => { setRegistration(next); @@ -155,6 +180,7 @@ export function SessionListProvider({ children }: { children: ReactNode }) { const boot = useCallback(() => bootRef.current, []); + void readyVersion; const value = useMemo(() => ({ sessions, setSessions, @@ -164,18 +190,20 @@ export function SessionListProvider({ children }: { children: ReactNode }) { setAccount, signedOut, settled, - ready: readyPack.promise, + ready: readyPackRef.current.promise, boot, + reload, registerShellControls, }), [ account, boot, - readyPack.promise, + reload, registerShellControls, sessions, sessionsCursor, settled, signedOut, + readyVersion, ]); return ( diff --git a/frontend/tests/first-paint-fallback-contract.test.ts b/frontend/tests/first-paint-fallback-contract.test.ts index 9aa8dc88..1be7ebb4 100644 --- a/frontend/tests/first-paint-fallback-contract.test.ts +++ b/frontend/tests/first-paint-fallback-contract.test.ts @@ -174,9 +174,12 @@ test("fallback script is classic ES5 and waits past the bootstrap budgets", () = const bootstrap = read("src/lib/home-bootstrap.ts"); const layout = read("src/app/layout.tsx"); const hook = read("src/hooks/use-home-shell-registration.ts"); - assert.match(page, /\}, 8000\);/); - assert.match(bootstrap, /BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000/); - assert.ok(FIRST_PAINT_FALLBACK_TIMEOUT_MS > 8000 + 4000); + // 原值: 首屏兜底只需晚于 8000ms 账户超时 + 4000ms prepare。 + // 新值: 兜底必须晚于 20000ms 首页慢网错误屏并保留余量。 + // 原因: bundle 正常运行时不能被未启动兜底脚本抢先揭幕。 + assert.doesNotMatch(page, /\}, 8000\);/); + assert.match(bootstrap, /HOME_BOOTSTRAP_SLOW_MS = 20_000/); + assert.ok(FIRST_PAINT_FALLBACK_TIMEOUT_MS > 20_000); const script = firstPaintFallbackBootScript("abcdef0"); assert.match(script, new RegExp(String(FIRST_PAINT_FALLBACK_TIMEOUT_MS))); diff --git a/frontend/tests/home-bootstrap-reveal.test.ts b/frontend/tests/home-bootstrap-reveal.test.ts index e72d940c..00635c4f 100644 --- a/frontend/tests/home-bootstrap-reveal.test.ts +++ b/frontend/tests/home-bootstrap-reveal.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { BOOTSTRAP_PREPARE_TIMEOUT_MS, + HOME_BOOTSTRAP_SLOW_MS, SESSION_PREFETCH_COUNT, bootstrapLoadingCopy, bootstrapPrepareSettled, @@ -118,6 +119,15 @@ test("prepare phase settles only when every applicable item has an answer", () = assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true); }); +test("home bootstrap keeps one slow-network budget before the fatal screen", () => { + // 原值: 首页账户阶段使用 8000ms 内联总超时。 + // 新值: 账户、模型与状态等待共享 20000ms,超时后只显示错误屏。 + // 原因: 慢网串行链需要一次等待一次揭幕,不能半揭幕或提前放弃。 + assert.equal(HOME_BOOTSTRAP_SLOW_MS, 20_000); + assert.match(page, /HOME_BOOTSTRAP_SLOW_MS/); + assert.doesNotMatch(page, /\}, 8000\);/); +}); + test("reveal delay is zero once settled and counts down from the phase start otherwise", () => { assert.equal(BOOTSTRAP_PREPARE_TIMEOUT_MS, 4000); assert.equal(bootstrapRevealDelayMs(10_000, 9_000, true), 0);