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:
co-authored by
Claude Opus 5.5
parent
b19d0bbf36
commit
fee9ddc8a1
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user