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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-01 00:15:12 +08:00
co-authored by Claude Opus 5.5
parent 80ea7b0bc1
commit ab656e0dea
6 changed files with 271 additions and 8 deletions
+2 -2
View File
@@ -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.
+16 -1
View File
@@ -219,16 +219,31 @@ function scheduleCatalogRecovery(account: Account): void {
recoveryTimer = timer;
}
type SettledRead<T> = { ok: true; value: T } | { ok: false; error: unknown };
/** Starts a read now without an unhandled rejection; the caller rethrows on unwrap. */
function settledRead<T>(promise: Promise<T>): Promise<SettledRead<T>> {
return promise.then((value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }));
}
function unwrapRead<T>(read: SettledRead<T>): T {
if (!read.ok) throw read.error;
return read.value;
}
export function loadSubjectCatalog(force = false): Promise<ChartLibraryRecord[]> {
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);
+5
View File
@@ -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<HomeBootstrapIo, "restoreConsultationRecovery"> = {
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 }));
@@ -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<RectificationEntrySummary | null> | null = null;
export function startRectificationEntrySummaryRead(read: () => Promise<RectificationEntrySummary | null> = fetchRectificationEntrySummary): void {
pending = read().catch(() => null);
}
export function takeRectificationEntrySummary(read: () => Promise<RectificationEntrySummary | null> = fetchRectificationEntrySummary): Promise<RectificationEntrySummary | null> {
const started = pending;
pending = null;
return started ?? read();
}
export function resetRectificationEntrySummaryReadForTests(): void {
pending = null;
}
+28 -5
View File
@@ -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<SessionListBoot> {
function fetchSessionPage(subjectId: string, signal: AbortSignal): Promise<Response> {
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<SessionListBoot> {
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 };
@@ -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<string, string>) {
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<Response>((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<string, string>, run: (net: ReturnType<typeof installDeferredFetch>) => Promise<void>) {
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();
});