From ab656e0dea99b9874174edbe19b0657b7124569d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 1 Oct 2026 00:15:12 +0800 Subject: [PATCH] perf(home): cold boot reads account, people list, sessions and entry summary in one round (BUG-1127) The sessions read guesses the remembered person and is dropped when the people list settles on another; a first sign-in no longer re-reads the people list it just fetched. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- frontend/src/app/(app)/page.tsx | 4 +- frontend/src/lib/current-subject.ts | 17 +- frontend/src/lib/home-bootstrap-run.ts | 5 + .../lib/rectification-entry-summary-read.ts | 26 +++ frontend/src/lib/session-list-context.tsx | 33 ++- .../tests/home-first-load-20260930.test.ts | 194 ++++++++++++++++++ 6 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/rectification-entry-summary-read.ts create mode 100644 frontend/tests/home-first-load-20260930.test.ts diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 02719a09..c19e4c8f 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -159,12 +159,12 @@ import { discardLegacyCloudMirrorKeys, fetchCloudChartLibrary, fetchCloudSynastryHistory, - fetchRectificationEntrySummary, payloadCode, payloadMessage, waitForUndoWindow, } from "@/lib/home-cloud-sync"; import { runHomeBootstrap, runHomeWarmRefresh } from "@/lib/home-bootstrap-run"; +import { takeRectificationEntrySummary } from "@/lib/rectification-entry-summary-read"; import { writeHomeWarmSnapshot } from "@/lib/home-warm-snapshot"; import { markHomeReadyAfterNewChat } from "@/lib/new-chat-timing"; import { useReplyRatingsSync } from "@/hooks/use-reply-ratings-sync"; @@ -509,7 +509,7 @@ export default function Home() { if (bootstrapPhase === "account" || !accountId || !sessionListSettled) return; void (async () => { try { - const summary = await fetchRectificationEntrySummary(); + const summary = await takeRectificationEntrySummary(); if (summary) setRectificationEntrySummary(summary); } catch { // The CTA falls back to the server-agnostic default labels. diff --git a/frontend/src/lib/current-subject.ts b/frontend/src/lib/current-subject.ts index 92ebf725..3ac0a2e2 100644 --- a/frontend/src/lib/current-subject.ts +++ b/frontend/src/lib/current-subject.ts @@ -219,16 +219,31 @@ function scheduleCatalogRecovery(account: Account): void { recoveryTimer = timer; } +type SettledRead = { ok: true; value: T } | { ok: false; error: unknown }; + +/** Starts a read now without an unhandled rejection; the caller rethrows on unwrap. */ +function settledRead(promise: Promise): Promise> { + return promise.then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error })); +} + +function unwrapRead(read: SettledRead): T { + if (!read.ok) throw read.error; + return read.value; +} + export function loadSubjectCatalog(force = false): Promise { if (!force && catalogRequest) return catalogRequest; const generation = ++catalogGeneration; clearCatalogRecovery(); + // The people list does not depend on the account payload, so it is read in the + // same round as the account instead of after it (BUG-1127). + const libraryRead = settledRead(fetchCloudChartLibrary()); catalogRequest = readBootAccount().then(async (result) => { if (generation !== catalogGeneration) return loadSubjectCatalog(); if (result.status === 401 || !result.account) throw new Error("account_unavailable"); const account = result.account; try { - const others = await fetchCloudChartLibrary(); + const others = unwrapRead(await libraryRead); if (generation !== catalogGeneration) return loadSubjectCatalog(); const self = readProfile(account.profile); const library = upsertSelfChart(others.filter((row) => row.role === "other"), self); diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index 3a27922f..c2cf35bd 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -60,6 +60,7 @@ import { type StoredPendingConsultation, } from "@/lib/home-types"; import type { PublicLanguageModelCatalog } from "@/lib/public-models"; +import { startRectificationEntrySummaryRead } from "@/lib/rectification-entry-summary-read"; import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard"; import type { SessionListBoot } from "@/lib/session-list-context"; import { isUnsavedEmptyConsultation, replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter"; @@ -105,6 +106,7 @@ export type HomeBootstrapIo = { clearStaleClientReload: typeof clearStaleClientReload; writeSessionUrl: typeof writeSessionUrl; clearLoginSessionReturn: typeof clearLoginSessionReturn; + startEntrySummaryRead: () => void; restoreConsultationRecovery: ( session: ChatSession, requestId: string, @@ -153,6 +155,7 @@ const defaultIo: Omit = { clearStaleClientReload, writeSessionUrl, clearLoginSessionReturn, + startEntrySummaryRead: () => startRectificationEntrySummaryRead(), }; function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo { @@ -319,6 +322,8 @@ export async function runAccountPhase( // Start independent reads before waiting for the provider so slow-network // latency is spent in parallel, not as a serial account -> model chain. + // The entry-card summary joins them (BUG-1127); Home takes it after reveal prep. + io.startEntrySummaryRead(); const modelCatalogPromise = io.fetchModelCatalog(signal) .then((catalog) => ({ catalog, error: null as unknown })) .catch((error: unknown) => ({ catalog: null, error })); diff --git a/frontend/src/lib/rectification-entry-summary-read.ts b/frontend/src/lib/rectification-entry-summary-read.ts new file mode 100644 index 00000000..89fa45db --- /dev/null +++ b/frontend/src/lib/rectification-entry-summary-read.ts @@ -0,0 +1,26 @@ +import { fetchRectificationEntrySummary } from "./home-cloud-sync"; +import type { RectificationEntrySummary } from "./rectification-entry"; + +/** + * The home entry card summary, started with the other cold-boot reads instead + * of after the session list (BUG-1127). The summary does not depend on the + * session list; Home used to wait for it only because its effect was gated on + * `sessionListSettled`. One read per cold boot: Home takes it once, and a + * later call (account switch, retry) reads fresh. Failures read as null, which + * keeps the card on its default labels, exactly as before. + */ +let pending: Promise | null = null; + +export function startRectificationEntrySummaryRead(read: () => Promise = fetchRectificationEntrySummary): void { + pending = read().catch(() => null); +} + +export function takeRectificationEntrySummary(read: () => Promise = fetchRectificationEntrySummary): Promise { + const started = pending; + pending = null; + return started ?? read(); +} + +export function resetRectificationEntrySummaryReadForTests(): void { + pending = null; +} diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index e71be92c..442653b4 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -98,7 +98,22 @@ function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession && sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session)); } -async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promise { +function fetchSessionPage(subjectId: string, signal: AbortSignal): Promise { + return fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" }); +} + +/** + * Cold boot reads the account, the people list and the remembered person's + * sessions in one round (BUG-1127). The sessions read is a guess at the + * person: it is used only when the person is still the same once the people + * list has settled the choice; otherwise it is dropped and the right person is + * read, so another person's list never paints (task red line 3). + */ +export async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promise { + const guessedSubject = readCurrentSubjectId(); + const guessedSessions = fetchSessionPage(guessedSubject, signal) + .then((response) => ({ response, error: null as unknown }), (error: unknown) => ({ response: null, error })); + void loadSubjectCatalog(forceCatalog).catch(() => undefined); const accountResult = await readBootAccount(); signal.throwIfAborted(); if (accountResult.status === 401) { @@ -109,16 +124,24 @@ async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promi } if (!accountResult.account) throw new Error("account_unavailable"); const accountPayload = accountResult.account; - if (readCurrentSubjectAccountId() !== accountPayload.user.id) { + const boundAccount = readCurrentSubjectAccountId(); + if (boundAccount !== accountPayload.user.id) { // Another account: nothing of the previous one may paint on `/` (BUG-1040). clearHomeWarmSnapshot(); - invalidateSubjectCatalog(); + // With no account bound yet (first sign-in on this device, or after a + // sign-out, which already invalidated), there is no stale people list; the + // one started above was read with the current session, so keep it instead + // of paying a second round for the same data (BUG-1127). + if (boundAccount) invalidateSubjectCatalog(); bindCurrentSubjectAccount(accountPayload.user.id, null); } - await loadSubjectCatalog(forceCatalog); + // Joins the read started above unless the account change just invalidated it. + await loadSubjectCatalog(); signal.throwIfAborted(); const subjectId = readCurrentSubjectId(); - const sessionResponse = await fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" }); + const guessed = subjectId === guessedSubject ? await guessedSessions : null; + signal.throwIfAborted(); + const sessionResponse = guessed?.response ?? await fetchSessionPage(subjectId, signal); if (sessionResponse.status === 401) { clearHomeWarmSnapshot(); return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true }; diff --git a/frontend/tests/home-first-load-20260930.test.ts b/frontend/tests/home-first-load-20260930.test.ts new file mode 100644 index 00000000..2b82a6f8 --- /dev/null +++ b/frontend/tests/home-first-load-20260930.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { bindCurrentSubjectAccount, currentSubjectStorageKey, LAST_SUBJECT_ACCOUNT_KEY, readCurrentSubjectId, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts"; +import { emptyProfile, type Account } from "../src/lib/home-types.ts"; +import { resetRectificationEntrySummaryReadForTests, startRectificationEntrySummaryRead, takeRectificationEntrySummary } from "../src/lib/rectification-entry-summary-read.ts"; +import { loadSessionList } from "../src/lib/session-list-context.tsx"; + +// TASK-home-first-load-20260930 T1 (BUG-1127): the cold boot reads the account, +// the people list and the remembered person's sessions in one round. Synthetic +// data only. + +const ACCOUNT_ID = "11111111-1111-4111-8111-111111111127"; +const OTHER_ID = "33333333-3333-4333-8333-333333333127"; +const account: Account = { + user: { id: ACCOUNT_ID, email: null }, + profile: { ...emptyProfile, name: "Synthetic" }, + avatar: null, credits: 1, isAdmin: false, adminUrl: null, + rectificationPriceCredits: 0, activeSubscription: null, + hasConfirmedBirthTime: false, hasUsableBirthTime: false, +}; +const otherProfile = { ...emptyProfile, name: "Synthetic other", chartRelationship: "friend" }; + +function sessionRow(id: string, subject: string) { + return { + id, title: `Synthetic ${subject}`, theme: "general", modelId: "test", messages: [], + createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, + chartProfileId: subject === "self" ? null : subject, chartProfileName: null, + chartProfileRole: subject === "self" ? "self" : "other", pinned: false, archivedAt: null, + }; +} + +type Pending = { url: string; resolve: (payload: unknown, status?: number) => void }; + +function installStorage(values: Record) { + const data = new Map(Object.entries(values)); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, String(value)); }, + removeItem: (key: string) => { data.delete(key); }, + clear: () => data.clear(), + key: (index: number) => Array.from(data.keys())[index] ?? null, + get length() { return data.size; }, + } satisfies Storage, + }); + return () => { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }; +} + +/** Every request waits until the test answers it, so "sent in the same round" is observable. */ +function installDeferredFetch() { + const pending: Pending[] = []; + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL) => new Promise((resolve) => { + pending.push({ url: String(input), resolve: (payload, status = 200) => resolve(new Response(JSON.stringify(payload), { status })) }); + })) as typeof fetch; + return { pending, restore: () => { globalThis.fetch = original; } }; +} + +async function flush() { + for (let i = 0; i < 20; i += 1) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function answer(pending: Pending[], match: (url: string) => boolean, payload: unknown, status = 200) { + const index = pending.findIndex((request) => match(request.url)); + assert.notEqual(index, -1, "request not sent"); + const [request] = pending.splice(index, 1); + request.resolve(payload, status); +} + +async function withBoot(storage: Record, run: (net: ReturnType) => Promise) { + resetSubjectCatalogForTests(); + const restoreStorage = installStorage(storage); + const net = installDeferredFetch(); + try { + // The module reads the remembered person when the tab starts; tests replay that. + const remembered = storage[LAST_SUBJECT_ACCOUNT_KEY]; + if (remembered) bindCurrentSubjectAccount(remembered, null); + await run(net); + } finally { + net.restore(); + restoreStorage(); + resetSubjectCatalogForTests(); + } +} + +test("cold boot sends account, people list and sessions in the same round (BUG-1127)", async () => withBoot({}, async ({ pending }) => { + const boot = loadSessionList(new AbortController().signal); + await flush(); + // Nothing has been answered yet, and all three reads are already out. + const sent = pending.map((request) => request.url.split("?")[0]).sort(); + assert.deepEqual(sent, ["/api/account", "/api/chart-profiles", "/api/sessions"]); + assert.match(pending.find((request) => request.url.startsWith("/api/sessions"))!.url, /subject=self/); + + answer(pending, (url) => url.startsWith("/api/sessions"), { sessions: [sessionRow("44444444-4444-4444-8444-444444444127", "self")], nextCursor: null }); + answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] }); + answer(pending, (url) => url === "/api/account", account); + const result = await boot; + assert.equal(result.subjectId, "self"); + assert.equal(result.account?.user.id, ACCOUNT_ID); + assert.deepEqual(result.sessions.map((session) => session.id), ["44444444-4444-4444-8444-444444444127"]); + assert.equal(pending.length, 0, "no second sessions read when the guess held"); +})); + +test("a remembered person that is no longer in the people list: the guessed list is dropped, self is read (red line 3)", async () => withBoot({ + [LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID, + [currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID, +}, async ({ pending }) => { + assert.equal(readCurrentSubjectId(), OTHER_ID); + const boot = loadSessionList(new AbortController().signal); + await flush(); + const guessed = pending.find((request) => request.url.startsWith("/api/sessions"))!; + assert.match(guessed.url, new RegExp(`subject=${OTHER_ID}`)); + + // The guessed person's list arrives first; it must never become the boot. + answer(pending, (url) => url === guessed.url, { sessions: [sessionRow("55555555-5555-4555-8555-555555555127", OTHER_ID)], nextCursor: null }); + answer(pending, (url) => url === "/api/account", account); + answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] }); + await flush(); + assert.equal(readCurrentSubjectId(), "self"); + answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null }); + const result = await boot; + assert.equal(result.subjectId, "self"); + assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]); +})); + +test("a remembered person still in the people list keeps the single-round read", async () => withBoot({ + [LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID, + [currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID, +}, async ({ pending }) => { + const boot = loadSessionList(new AbortController().signal); + await flush(); + answer(pending, (url) => url === "/api/account", account); + answer(pending, (url) => url === "/api/chart-profiles", { profiles: [{ id: OTHER_ID, role: "other", profile: otherProfile, updated_at: "2026-09-30T00:00:00Z" }] }); + answer(pending, (url) => url.startsWith("/api/sessions") && url.includes(`subject=${OTHER_ID}`), { sessions: [sessionRow("77777777-7777-4777-8777-777777777127", OTHER_ID)], nextCursor: null }); + const result = await boot; + assert.equal(result.subjectId, OTHER_ID); + assert.deepEqual(result.sessions.map((session) => session.id), ["77777777-7777-4777-8777-777777777127"]); + assert.equal(pending.length, 0); +})); + +test("another account on this device: the previous account's person and people list are not reused (BUG-1040)", async () => withBoot({ + [LAST_SUBJECT_ACCOUNT_KEY]: "99999999-9999-4999-8999-999999999127", + [currentSubjectStorageKey("99999999-9999-4999-8999-999999999127")]: OTHER_ID, +}, async ({ pending }) => { + const boot = loadSessionList(new AbortController().signal); + await flush(); + const guessed = pending.find((request) => request.url.startsWith("/api/sessions"))!; + assert.match(guessed.url, new RegExp(`subject=${OTHER_ID}`), "the guess follows the previous account's choice"); + answer(pending, (url) => url === "/api/account", account); + // The early people list was read before the account was known: it is dropped and read again. + answer(pending, (url) => url === "/api/chart-profiles", { profiles: [{ id: OTHER_ID, role: "other", profile: otherProfile, updated_at: "2026-09-30T00:00:00Z" }] }); + answer(pending, (url) => url === guessed.url, { sessions: [sessionRow("55555555-5555-4555-8555-555555555127", OTHER_ID)], nextCursor: null }); + await flush(); + answer(pending, (url) => url === "/api/chart-profiles", { profiles: [] }); + await flush(); + answer(pending, (url) => url.startsWith("/api/sessions") && url.includes("subject=self"), { sessions: [sessionRow("66666666-6666-4666-8666-666666666127", "self")], nextCursor: null }); + const result = await boot; + assert.equal(result.account?.user.id, ACCOUNT_ID); + assert.equal(result.subjectId, "self"); + assert.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]); +})); + +test("a signed-out cold boot still reports signed out when every read went out at once", async () => withBoot({}, async ({ pending }) => { + const boot = loadSessionList(new AbortController().signal); + await flush(); + answer(pending, (url) => url === "/api/account", {}, 401); + answer(pending, (url) => url === "/api/chart-profiles", {}, 401); + answer(pending, (url) => url.startsWith("/api/sessions"), {}, 401); + const result = await boot; + assert.equal(result.signedOut, true); + assert.deepEqual(result.sessions, []); +})); + +test("the entry-card summary started with the account reads is taken once, then read fresh (BUG-1127)", async () => { + resetRectificationEntrySummaryReadForTests(); + let reads = 0; + const summary = { status: "none" } as never; + startRectificationEntrySummaryRead(async () => { reads += 1; return summary; }); + assert.equal(reads, 1, "sent with the account round, not after the session list"); + assert.equal(await takeRectificationEntrySummary(async () => { reads += 1; return null; }), summary); + assert.equal(reads, 1, "the started read is reused"); + assert.equal(await takeRectificationEntrySummary(async () => { reads += 1; return null; }), null); + assert.equal(reads, 2, "a second take reads fresh"); + startRectificationEntrySummaryRead(async () => { throw new Error("down"); }); + assert.equal(await takeRectificationEntrySummary(), null, "a failed read keeps the default card labels"); + resetRectificationEntrySummaryReadForTests(); +});