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 };
|
||||
|
||||
Reference in New Issue
Block a user