fix(home): prefetch the warm snapshot from secondary pages so the first 新建对话 skips the loading ring (BUG-1104)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 20:28:59 +08:00
co-authored by Claude Opus 5.5
parent b19d0bbf36
commit fee9ddc8a1
12 changed files with 329 additions and 8 deletions
+12
View File
@@ -1,3 +1,4 @@
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
@@ -438,6 +439,17 @@ export async function fetchAccount(signal?: AbortSignal): Promise<Account> {
return payload as Account;
}
/**
* The rectification entry card's summary. Null on a non-OK answer; throws on
* a network error. Shared by Home's cold start and the warm prefetch (BUG-1104).
*/
export async function fetchRectificationEntrySummary(signal?: AbortSignal): Promise<RectificationEntrySummary | null> {
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store", signal });
if (!response.ok) return null;
const payload = await response.json().catch(() => null);
return entrySummaryFromResponse(payload);
}
export async function fetchModelCatalog(signal?: AbortSignal) {
const response = await fetch("/api/models", { signal, cache: "no-store" });
const payload = await response.json().catch(() => null);
+73
View File
@@ -0,0 +1,73 @@
/**
* Warm the Home snapshot from a secondary page (BUG-1104).
*
* BUG-1040's warm return only works after Home has cold-started once in this
* document. A tab that opens on /chart, /ephemeris, /reports or /people (a
* refresh, a shared link, the post-login landing of a deep link) has no
* snapshot, so its first 「新建对话」 plays the whole loading ring.
*
* While such a page sits idle, fetch exactly what `isCompleteHomeWarmSnapshot`
* requires — the model catalog and the rectification entry summary — with the
* same client helpers the cold start uses, and write it through the same
* `writeHomeWarmSnapshot`. Read-only GETs only: no fallback PATCH, no
* consultation recovery. Those still run in Home's warm refresh after landing.
* The cursor is left to the provider's own boot (`listBoot: null`).
*
* Pure module state, no React.
*/
import { fetchModelCatalog, fetchRectificationEntrySummary } from "./home-cloud-sync";
import { isCompleteHomeWarmSnapshot, readHomeWarmSnapshot, writeHomeWarmSnapshot } from "./home-warm-snapshot";
import type { PublicLanguageModelCatalog } from "./public-models";
import type { RectificationEntrySummary } from "./rectification-entry";
export type HomeWarmPrefetchIo = Readonly<{
fetchModelCatalog: (signal?: AbortSignal) => Promise<PublicLanguageModelCatalog>;
fetchRectificationEntrySummary: (signal?: AbortSignal) => Promise<RectificationEntrySummary | null>;
}>;
const defaultIo: HomeWarmPrefetchIo = { fetchModelCatalog, fetchRectificationEntrySummary };
let inflight: { accountId: string; promise: Promise<boolean> } | null = null;
/**
* Fill the snapshot for `accountId` unless it is already complete. Resolves to
* true when this call wrote a complete snapshot. Any failure leaves nothing
* behind: the next Home simply takes the unchanged cold path.
* `isCurrent` is asked again before writing, so an account switch, sign-out
* or a Home that mounted meanwhile never receives a late write.
*/
export function prefetchHomeWarmSnapshot(
accountId: string,
isCurrent: () => boolean,
io: HomeWarmPrefetchIo = defaultIo,
signal?: AbortSignal,
): Promise<boolean> {
if (!accountId || isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(accountId))) return Promise.resolve(false);
if (inflight && inflight.accountId === accountId) return inflight.promise;
const promise = (async () => {
try {
const [modelCatalog, summary] = await Promise.all([
io.fetchModelCatalog(signal),
// Same settle rule as the cold start: a failed summary reads as the default card.
io.fetchRectificationEntrySummary(signal).catch(() => null),
]);
if (signal?.aborted || !isCurrent()) return false;
if (isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(accountId))) return false;
const next = { accountId, modelCatalog, entrySummary: summary, entrySummarySettled: true, sessionsCursor: null, listBoot: null };
if (!isCompleteHomeWarmSnapshot(next)) return false;
writeHomeWarmSnapshot(next);
return true;
} catch {
return false;
} finally {
inflight = null;
}
})();
inflight = { accountId, promise };
return promise;
}
export function resetHomeWarmPrefetchForTests(): void {
inflight = null;
}
+2
View File
@@ -44,6 +44,7 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog } from "@/lib/pu
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
import type { SessionListBoot, SessionListContextValue } from "@/lib/session-list-context";
import { replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter";
import { noteHomeStartPath } from "@/lib/new-chat-timing";
import { createStartGreeting } from "@/lib/starter-greeting";
export type WarmLanding = Readonly<{
@@ -243,6 +244,7 @@ let landingCommitted: HomeWarmStart | null = null;
export function takeHomeWarmStart(read: () => HomeWarmStartInput): HomeWarmStart | null {
if (memo) return memo.result;
const result = typeof window === "undefined" ? null : resolveHomeWarmStart(read());
noteHomeStartPath(result ? "warm" : "cold");
memo = { result };
mounted = result;
queueMicrotask(() => {
+45
View File
@@ -0,0 +1,45 @@
/**
* 「新建对话」 timing (BUG-1104). Browser Performance marks only: nothing is
* sent anywhere and nothing personal is recorded. In DevTools → Performance
* (or `performance.getEntriesByType("measure")`) a slow case shows as
* `jyotisha:new-chat (warm|cold)` with its duration from click to a ready Home.
*/
const CLICK = "jyotisha:new-chat:click";
let pending = false;
let path: "warm" | "cold" | null = null;
function perf(): Performance | null {
return typeof performance !== "undefined" && typeof performance.mark === "function" ? performance : null;
}
/** The sidebar's 「新建对话」 was clicked (either the link off `/` or the button on it). */
export function markNewChatClick(): void {
const p = perf();
if (!p) return;
try {
p.clearMarks(CLICK);
p.mark(CLICK);
pending = true;
path = null;
} catch {
// Timing is best-effort.
}
}
/** Which way the Home that mounts next decided to start. */
export function noteHomeStartPath(kind: "warm" | "cold"): void {
if (pending) path ??= kind;
}
/** Home is revealed and usable; closes the measure opened by the click, once. */
export function markHomeReadyAfterNewChat(): void {
const p = perf();
if (!p || !pending) return;
pending = false;
try {
p.measure(`jyotisha:new-chat (${path ?? "unknown"})`, CLICK);
} catch {
// The click mark was cleared by another tool; nothing to measure.
}
}
+25
View File
@@ -19,6 +19,8 @@ import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCa
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { readSessions } from "@/lib/home-cloud-sync";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import { prefetchHomeWarmSnapshot } from "@/lib/home-warm-prefetch";
import { isProfileComplete, readProfile } from "@/lib/home-profile";
import type { Account, ChatSession } from "@/lib/home-types";
import { SESSION_PAGE_SIZE } from "@/lib/session-cursor";
import { isListedSidebarSession } from "@/lib/session-list-filter";
@@ -211,6 +213,29 @@ export function SessionListProvider({ children }: { children: ReactNode }) {
};
}, []);
// BUG-1104: a tab that opened on a secondary page has no warm snapshot yet,
// so its first 「新建对话」 would cold-start Home. Once the list is settled and
// no Home is mounted, fill the snapshot in the background (read-only GETs).
// Home mounting, sign-out, an account switch or a list reload cancels it.
const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile))
? account.user.id
: null;
useEffect(() => {
if (!prefetchAccountId || window.location.pathname === "/") return;
const controller = new AbortController();
const run = () => {
void prefetchHomeWarmSnapshot(prefetchAccountId, () => !controller.signal.aborted, undefined, controller.signal);
};
// Safari has no requestIdleCallback; a short timeout keeps it off the first paint.
const idle = "requestIdleCallback" in window;
const handle = idle ? window.requestIdleCallback(run, { timeout: 2000 }) : window.setTimeout(run, 300);
return () => {
controller.abort();
if (idle) window.cancelIdleCallback(handle);
else window.clearTimeout(handle);
};
}, [prefetchAccountId]);
const reload = useCallback(() => reloadRef.current(), []);
const registerShellControls = useCallback((next: ShellRegistration | null) => {