Coming back to / from /chart, /ephemeris, /reports or /people by client navigation remounted Home from hydrated=false and replayed the whole bootstrap: model catalog, consult status, entry summary and daily card, 3-5 round trips plus up to 4 s of reveal budget, every time. The reveal now counts per document load, not per mount: - lib/home-warm-snapshot.ts: memory-only, per-account snapshot of the model catalog, rectification entry summary, session cursor and today's card (keyed by person + birth fingerprint + day). Account, profile and the session list already survive in SessionListProvider. Cleared on account change, sign-out, provider 401 and redirectToLogin. - lib/home-warm-start.ts: Home's useState initializers take one warm decision per mount. Complete snapshot + settled list + complete profile + a landing computable in memory -> start ready, landing resolved with the cold-path functions (?new=1, ?c=, login-return stash and its person scope from BUG-1038). Anything missing or needing a lookup -> the unchanged cold path (no half-reveal, BUG-1021). - runHomeWarmRefresh commits the landing, then refreshes catalog, account and background-consultation recovery in the background (recovery shares resolveReservedConsultation with the cold path). Summary and daily card refresh through their existing effects. - Next renders the new page before it writes the address bar, so AppLink / navigateAppPath note the target href (lib/client-navigation-target.ts); unknown target -> cold path. Home() useState 33 / useRef 37 unchanged; page.tsx 1329 -> 1373 lines. Tests: home-warm-return-lifecycle (13, real Home + sidebar + provider, navigating in Next's render-then-write-URL order; 11 fail with warm start disabled) and home-warm-snapshot (12). Full suite 3953 / 61 failing, failure names identical to the 3928 / 61 baseline. Build: / stays Static, rootMainFiles gzip 130933 B unchanged. Local Chrome with every /api/* held 1.5 s: /chart -> 新建对话 interactive in 18-23 ms with no loading ring (baseline 5530 ms with ring). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
import { noteClientNavigationTarget } from "./client-navigation-target.ts";
|
|
|
|
export type AppNavigationHost = {
|
|
assign: (href: string) => void;
|
|
};
|
|
|
|
export type AppNavigationClick = {
|
|
button: number;
|
|
metaKey: boolean;
|
|
ctrlKey: boolean;
|
|
shiftKey: boolean;
|
|
altKey: boolean;
|
|
defaultPrevented: boolean;
|
|
preventDefault: () => void;
|
|
};
|
|
|
|
const UNKNOWN = "unknown";
|
|
|
|
let clientCommitOverride: string | null = null;
|
|
let knownServerCommit: string | null = null;
|
|
let host: AppNavigationHost = {
|
|
assign: (href) => {
|
|
window.location.assign(href);
|
|
},
|
|
};
|
|
|
|
export function clientBuildCommit(): string {
|
|
if (clientCommitOverride !== null) return clientCommitOverride;
|
|
const value = process.env.NEXT_PUBLIC_GIT_COMMIT;
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
export function knownServerBuildCommit(): string | null {
|
|
return knownServerCommit;
|
|
}
|
|
|
|
export function rememberServerBuildCommit(commit: string | null): void {
|
|
if (typeof commit !== "string") {
|
|
knownServerCommit = null;
|
|
return;
|
|
}
|
|
const trimmed = commit.trim();
|
|
knownServerCommit = trimmed ? trimmed : null;
|
|
}
|
|
|
|
export function isUsableBuildCommit(value: string | null | undefined): value is string {
|
|
return typeof value === "string" && value.length > 0 && value !== UNKNOWN;
|
|
}
|
|
|
|
export function isStaleClientBuild(
|
|
client = clientBuildCommit(),
|
|
server = knownServerCommit,
|
|
): boolean {
|
|
if (!isUsableBuildCommit(client) || !isUsableBuildCommit(server)) return false;
|
|
return client !== server;
|
|
}
|
|
|
|
export function readHealthGitCommit(payload: unknown): string | null {
|
|
if (!payload || typeof payload !== "object" || !("deployment" in payload)) return null;
|
|
const deployment = (payload as { deployment?: unknown }).deployment;
|
|
if (!deployment || typeof deployment !== "object" || !("gitCommit" in deployment)) return null;
|
|
const commit = (deployment as { gitCommit?: unknown }).gitCommit;
|
|
return typeof commit === "string" && commit.trim() ? commit.trim() : null;
|
|
}
|
|
|
|
export async function syncBuildCommitFromHealth(
|
|
fetchImpl: typeof fetch = fetch,
|
|
): Promise<void> {
|
|
try {
|
|
const response = await fetchImpl("/api/health", { cache: "no-store" });
|
|
if (!response.ok) return;
|
|
const payload: unknown = await response.json().catch(() => null);
|
|
const commit = readHealthGitCommit(payload);
|
|
if (commit) rememberServerBuildCommit(commit);
|
|
} catch {
|
|
// Health is unavailable: keep the last known commit and do not force a reload.
|
|
}
|
|
}
|
|
|
|
export function navigateAppPath(
|
|
href: string,
|
|
clientNavigate?: (href: string) => void,
|
|
): void {
|
|
if (isStaleClientBuild()) {
|
|
host.assign(href);
|
|
return;
|
|
}
|
|
if (clientNavigate) {
|
|
noteClientNavigationTarget(href);
|
|
clientNavigate(href);
|
|
return;
|
|
}
|
|
host.assign(href);
|
|
}
|
|
|
|
function isModifiedClick(event: AppNavigationClick): boolean {
|
|
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
}
|
|
|
|
export function interceptAppNavigation(event: AppNavigationClick, href: string): void {
|
|
if (event.defaultPrevented || isModifiedClick(event)) return;
|
|
if (!href.startsWith("/") || href.startsWith("//")) return;
|
|
if (!isStaleClientBuild()) return;
|
|
event.preventDefault();
|
|
host.assign(href);
|
|
}
|
|
|
|
export function hrefFromLinkTarget(href: unknown): string {
|
|
if (typeof href === "string") return href;
|
|
if (!href || typeof href !== "object") return "";
|
|
const record = href as { pathname?: unknown; search?: unknown; hash?: unknown; query?: unknown };
|
|
const pathname = typeof record.pathname === "string" ? record.pathname : "";
|
|
let search = typeof record.search === "string" ? record.search : "";
|
|
if (!search && record.query && typeof record.query === "object") {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(record.query as Record<string, unknown>)) {
|
|
if (typeof value === "string") params.set(key, value);
|
|
}
|
|
const serialized = params.toString();
|
|
search = serialized ? `?${serialized}` : "";
|
|
}
|
|
const hash = typeof record.hash === "string" ? record.hash : "";
|
|
return `${pathname}${search}${hash}`;
|
|
}
|
|
|
|
export function setAppNavigationHostForTests(next: AppNavigationHost | null): void {
|
|
host = next ?? {
|
|
assign: (href) => {
|
|
window.location.assign(href);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function setClientBuildCommitForTests(commit: string | null): void {
|
|
clientCommitOverride = commit;
|
|
}
|
|
|
|
export function resetAppNavigationForTests(): void {
|
|
clientCommitOverride = null;
|
|
knownServerCommit = null;
|
|
host = {
|
|
assign: (href) => {
|
|
window.location.assign(href);
|
|
},
|
|
};
|
|
}
|