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
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { prefetchHomeWarmSnapshot, resetHomeWarmPrefetchForTests, type HomeWarmPrefetchIo } from "../src/lib/home-warm-prefetch.ts";
import { clearHomeWarmSnapshot, isCompleteHomeWarmSnapshot, readHomeWarmSnapshot, writeHomeWarmSnapshot } from "../src/lib/home-warm-snapshot.ts";
// BUG-1104. Synthetic ids only.
const USER = "11111111-1111-4111-8111-111111111111";
const OTHER = "22222222-2222-4222-8222-222222222222";
const catalog = { defaultModelId: "m", models: [{ id: "m", label: "M", description: "x", creditCost: 1, isDefault: true }] };
function io(overrides: Partial<HomeWarmPrefetchIo> = {}) {
const calls: string[] = [];
const value: HomeWarmPrefetchIo = {
fetchModelCatalog: async () => { calls.push("models"); return catalog; },
fetchRectificationEntrySummary: async () => { calls.push("summary"); return null; },
...overrides,
};
return { value, calls };
}
test.beforeEach(() => {
clearHomeWarmSnapshot();
resetHomeWarmPrefetchForTests();
});
test("writes a complete snapshot through the shared writer; cursor stays with the list boot", async () => {
const { value } = io();
assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, value), true);
const snapshot = readHomeWarmSnapshot(USER);
assert.ok(isCompleteHomeWarmSnapshot(snapshot));
assert.equal(snapshot.sessionsCursor, null);
assert.equal(snapshot.listBoot, null, "the provider's own boot cursor wins on the warm start");
assert.equal(snapshot.entrySummarySettled, true);
});
test("an existing complete snapshot is left alone and nothing is fetched", async () => {
writeHomeWarmSnapshot({ accountId: USER, modelCatalog: catalog, entrySummary: null, entrySummarySettled: true, sessionsCursor: "c", listBoot: null });
const { value, calls } = io();
assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, value), false);
assert.deepEqual(calls, []);
assert.equal(readHomeWarmSnapshot(USER)?.sessionsCursor, "c");
});
test("a late answer after cancel (Home mounted, account switched, sign-out) writes nothing", async () => {
let current = true;
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => { release = resolve; });
const { value } = io({ fetchModelCatalog: async () => { await gate; return catalog; } });
const pending = prefetchHomeWarmSnapshot(USER, () => current, value);
current = false;
release();
assert.equal(await pending, false);
assert.equal(readHomeWarmSnapshot(USER), null);
});
test("a failed catalog leaves nothing; a failed summary settles like the cold start", async () => {
assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, io({ fetchModelCatalog: async () => { throw new Error("down"); } }).value), false);
assert.equal(readHomeWarmSnapshot(USER), null);
assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, io({ fetchRectificationEntrySummary: async () => { throw new Error("down"); } }).value), true);
assert.equal(readHomeWarmSnapshot(USER)?.entrySummary, null);
});
test("an empty catalog is not a usable snapshot", async () => {
const { value } = io({ fetchModelCatalog: async () => ({ defaultModelId: "", models: [] }) });
assert.equal(await prefetchHomeWarmSnapshot(USER, () => true, value), false);
assert.equal(readHomeWarmSnapshot(USER), null);
});
test("one flight per account; another account's snapshot is never painted", async () => {
const { value, calls } = io();
const [a, b] = await Promise.all([prefetchHomeWarmSnapshot(USER, () => true, value), prefetchHomeWarmSnapshot(USER, () => true, value)]);
assert.equal(a, true);
assert.equal(b, true, "the second caller shares the first flight");
assert.deepEqual(calls.filter((call) => call === "models"), ["models"]);
assert.equal(readHomeWarmSnapshot(OTHER), null);
});
@@ -48,6 +48,8 @@ const emptySummary = { has_resumable_case: false, has_terminal_case_with_time: f
type Mode = {
/** Paths whose response never arrives (to prove nothing waits for them). */
hang: Set<string>;
/** Paths answered with 500 (a prefetch that fails must leave nothing behind). */
fail: Set<string>;
models: unknown;
summary: unknown;
consultStatus: unknown;
@@ -57,7 +59,7 @@ type Mode = {
function installApi() {
const originalFetch = globalThis.fetch;
const requests: string[] = [];
const mode: Mode = { hang: new Set(), models: catalogPro, summary: terminalSummary, consultStatus: null, accountUser: USER };
const mode: Mode = { hang: new Set(), fail: new Set(), models: catalogPro, summary: terminalSummary, consultStatus: null, accountUser: USER };
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = new URL(String(input), "http://localhost");
@@ -65,6 +67,7 @@ function installApi() {
const path = url.pathname;
requests.push(`${method} ${path}${url.search}`);
if (mode.hang.has(path)) return new Promise<Response>(() => undefined);
if (mode.fail.has(path)) return json({ error: "unavailable" }, 500);
if (path === "/api/account") {
return json({
user: { id: mode.accountUser, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9,
@@ -637,3 +640,58 @@ test("another account in the same tab never paints the previous account's snapsh
api.restore();
}
});
// BUG-1104. A tab that opens on a secondary page (refresh, shared link) has
// never cold-started Home, so before the prefetch its first 「新建对话」 played
// the whole loading ring — the 2–3 s the product saw.
for (const path of ["/chart", "/reports"] as const) {
test(`a tab that opens on ${path}: 新建对话 is warm once the background prefetch has run`, async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
doc = await openDocument(tab, path, api.requests);
const { readHomeWarmSnapshot, isCompleteHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
await doc.settle(() => isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(USER)), "the background prefetch");
const prefetch = api.requests.filter((line) => !line.startsWith("GET /api/account") && !line.startsWith("GET /api/sessions") && !line.startsWith("GET /api/chart-profiles"));
assert.deepEqual(prefetch.sort(), ["GET /api/models", "GET /api/rectification/cases/entry-summary"], `read-only prefetch only: ${api.requests.join(", ")}`);
assert.ok(api.requests.every((line) => line.startsWith("GET ")), "nothing but GETs before Home mounts");
for (const waited of WAITED_PATHS) api.mode.hang.add(waited);
const before = api.requests.length;
await doc.followLink("新建对话");
const first = doc.firstCommits[0];
assert.ok(first, "home mounted");
assert.equal(first.loading, false, "no loading ring");
assert.equal(first.title, "新对话");
assert.equal(first.requests, before, "ready before any request was sent");
assert.equal(doc.view().rectificationCard, "再次校正", "the prefetched summary drives the card");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
}
test("a failed prefetch leaves nothing behind and 新建对话 takes the unchanged cold path", async () => {
const tab = createTab();
const api = installApi();
let doc: Doc | undefined;
try {
api.mode.fail.add("/api/models");
doc = await openDocument(tab, "/chart", api.requests);
await doc.settle(() => api.requests.includes("GET /api/models"), "the prefetch attempt");
for (let i = 0; i < 5; i += 1) await doc.h.idle();
const { readHomeWarmSnapshot } = await import("../src/lib/home-warm-snapshot.ts");
assert.equal(readHomeWarmSnapshot(USER), null, "no partial snapshot");
api.mode.fail.delete("/api/models");
await doc.followLink("新建对话");
assert.equal(doc.firstCommits[0]?.loading, true, "without a snapshot Home cold-starts as before");
await doc.settle(() => doc!.view().title === "新对话", "the cold reveal");
assert.equal(doc.view().loading, false);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
api.restore();
}
});
+5 -1
View File
@@ -243,7 +243,11 @@ test("the same component renders read-only when `/` is not the one mounting it",
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
// Only the new-chat action carries intent.
assert.match(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href=\{newChatHref\(\)\}[^>]*onClick=\{closeDrawer\}/);
// 原值:新建对话链接的 `onClick={closeDrawer}`
// 新值:`onClick={handleNewChatLink}`,该函数仍只做 closeDrawer,外加一个本地 performance mark
// 原因:BUG-1104 给「新建对话」到首页可用的耗时打点;意图仍只在 href 的 `/?new=1` 上,下一行断言不变
assert.match(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href=\{newChatHref\(\)\}[^>]*onClick=\{handleNewChatLink\}/);
assert.match(appSidebar, /function handleNewChatLink\(\) \{\s*markNewChatClick\(\);\s*closeDrawer\(\);\s*\}/);
assert.doesNotMatch(appSidebar, /<SidebarMenuLink className="new-chat"[^>]*href="\/"/);
// 原值: /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/——只读页脚是回 `/` 的链接,无 chevron、无菜单