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 };