fix(web): make home bootstrap slow-network safe\n\nCo-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user