fix(web): make home bootstrap slow-network safe\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>

This commit is contained in:
jesse-ux
2026-09-24 18:22:44 +08:00
parent 2f53fa0171
commit 0e4dd0f475
7 changed files with 103 additions and 53 deletions
+31 -23
View File
@@ -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 (
<main className="app-loading app-loading-error">
<div className="app-loading-content" role="alert">
<strong>暂时无法进入 Jyotisha</strong>
<span>{accountError}</span>
<div className="app-loading-actions">
<button className="button-primary" type="button" onClick={() => window.location.reload()}>重试</button>
<button className="button-primary" type="button" onClick={() => {
setAccountError("");
setHydrated(false);
setBootstrapPhase("account");
prepareStartedAt.current = null;
reloadSessionList();
}}>重试</button>
<Link className="button-secondary" href="/login">返回登录</Link>
</div>
</div>
+3 -3
View File
@@ -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 = "这个页面没能加载完";
+1 -1
View File
@@ -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.
+1
View File
@@ -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;
+51 -23
View File
@@ -53,6 +53,7 @@ export type SessionListContextValue = {
settled: boolean;
ready: Promise<void>;
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<ShellRegistration | null>(null);
const bootRef = useRef<SessionListBoot | null>(null);
const [readyPack] = useState(createReadyGate);
const controllerRef = useRef<AbortController | null>(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<SessionListContextValue>(() => ({
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 (