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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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、无菜单
|
||||
|
||||
Reference in New Issue
Block a user