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