Files
Jyotisha/frontend/src/lib/session-list-context.tsx
T
Jesse_ChenandClaude Opus 5.5 2fd1e2b05d perf(home): the HTML sends the cold-boot reads before the bundle runs (BUG-1127)
An inline classic script on / starts account, people list, sessions and
models; the boot takes each once from one global, falls back to the normal
request on failure, drops another person's or another account's list, and
trusts nothing older than 30s. Paths and init come from one shared module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 00:19:56 +08:00

346 lines
14 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
import type { AppSidebarControls, SidebarAccount } from "@/components/app-sidebar";
import type { SidebarSession } from "@/components/sidebar-session-row";
import { bindCurrentSubjectAccount, invalidateAccountFlight, invalidateSubjectCatalog, loadSubjectCatalog, readBootAccount, readCurrentSubjectAccountId, readCurrentSubjectId, subscribeCurrentSubject, useCurrentSubject } from "@/lib/current-subject";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { readSessions } from "@/lib/home-cloud-sync";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import { prefetchHomeWarmDaily, prefetchHomeWarmSnapshot } from "@/lib/home-warm-prefetch";
import { isProfileComplete, readProfile } from "@/lib/home-profile";
import type { Account, ChatSession } from "@/lib/home-types";
import { BOOT_READ_INIT, bootSessionsPath, takeBootSessionsRead, withBootFallback } from "@/lib/boot-early-reads";
import { isListedSidebarSession } from "@/lib/session-list-filter";
import { toSidebarSessionRow } from "@/lib/session-sidebar-row";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
export type SessionListBoot = {
readonly sessions: ChatSession[];
readonly rawRows: unknown;
readonly cursor: string | null;
readonly account: Account | null;
readonly signedOut: boolean;
readonly subjectId?: string;
readonly error?: string;
};
export type ShellRegistration = {
readonly controls: AppSidebarControls;
readonly escapeBlocked: boolean;
readonly activeSessionId: string | null;
readonly openingSessionId: string | null;
readonly insetClassName: string;
readonly insetInert: boolean;
readonly sidebarSessions?: readonly SidebarSession[];
readonly sidebarAccount?: SidebarAccount;
};
export type SessionListContextValue = {
sessions: ChatSession[];
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
sessionsCursor: string | null;
setSessionsCursor: Dispatch<SetStateAction<string | null>>;
account: Account | null;
setAccount: Dispatch<SetStateAction<Account | null>>;
signedOut: boolean;
settled: boolean;
ready: Promise<void>;
waitUntilReady: () => Promise<void>;
boot: () => SessionListBoot | null;
reload: () => void;
registerShellControls: (registration: ShellRegistration | null) => void;
};
const SessionListContext = createContext<SessionListContextValue | null>(null);
// Home produces these controls; only the shell subscribes to their updates.
// Publishing them through the data context re-renders Home, whose registration
// effect has fresh callbacks/rows, feeding another update back to the provider.
const ShellRegistrationContext = createContext<ShellRegistration | null>(null);
function createReadyGate(): { promise: Promise<void>; resolve: () => void } {
let settle = () => {};
const promise = new Promise<void>((resolve) => {
settle = resolve;
});
return { promise, resolve: () => settle() };
}
function toSidebarAccount(account: Account | null): SidebarAccount | null {
if (!account) return null;
const profileName = account.profile && typeof account.profile === "object" && "name" in account.profile
&& typeof account.profile.name === "string" ? account.profile.name.trim() : "";
const name = profileName;
const email = account.user.email || "";
return {
name: name || email || "账户",
email,
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
credits: account.credits,
avatar: account.avatar,
};
}
function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession[] {
return sessions.filter((session) => isListedSidebarSession(session)
&& sessionMatchesSubject(session, readCurrentSubjectId())).map((session) => toSidebarSessionRow(session));
}
function fetchSessionPage(subjectId: string, signal: AbortSignal): Promise<Response> {
return fetch(bootSessionsPath(subjectId), { ...BOOT_READ_INIT, signal });
}
/**
* 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();
// On `/` the HTML already sent this read (T8); otherwise it starts here.
const guessedSessions = withBootFallback(takeBootSessionsRead(guessedSubject), () => 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) {
clearHomeWarmSnapshot();
invalidateSubjectCatalog();
bindCurrentSubjectAccount("", null);
return { sessions: [], rawRows: [], cursor: null, account: null, signedOut: true };
}
if (!accountResult.account) throw new Error("account_unavailable");
const accountPayload = accountResult.account;
const boundAccount = readCurrentSubjectAccountId();
if (boundAccount !== accountPayload.user.id) {
// Another account: nothing of the previous one may paint on `/` (BUG-1040).
clearHomeWarmSnapshot();
// 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);
}
// Joins the read started above unless the account change just invalidated it.
await loadSubjectCatalog();
signal.throwIfAborted();
const subjectId = readCurrentSubjectId();
// The guess was made for the account this device remembered; another account's
// list is never reused, even for the same person id (task red line 8).
const guessUsable = subjectId === guessedSubject && (!boundAccount || boundAccount === accountPayload.user.id);
const guessed = guessUsable ? 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 };
}
const sessionPayload = await sessionResponse.json().catch(() => null) as {
sessions?: unknown;
nextCursor?: unknown;
} | null;
if (!sessionResponse.ok) throw new Error("sessions_unavailable");
const rawRows = Array.isArray(sessionPayload?.sessions) ? sessionPayload.sessions : [];
const parsed = readSessions(rawRows, null);
const cursor = typeof sessionPayload?.nextCursor === "string" ? sessionPayload.nextCursor : null;
return {
sessions: parsed.sessions,
rawRows,
cursor,
account: accountPayload,
signedOut: false,
subjectId,
};
}
export function SessionListProvider({ children }: { children: ReactNode }) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsCursor, setSessionsCursor] = useState<string | null>(null);
const [account, setAccount] = useState<Account | null>(null);
const [signedOut, setSignedOut] = useState(false);
const [settled, setSettled] = useState(false);
const [registration, setRegistration] = useState<ShellRegistration | null>(null);
const bootRef = useRef<SessionListBoot | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const reloadRef = useRef<() => void>(() => undefined);
const [readyPack] = useState(createReadyGate);
const currentReady = useRef(readyPack);
useCurrentSubject();
useEffect(() => {
let disposed = false;
let displayedAccount = "";
const load = (nextReadyPack: ReturnType<typeof createReadyGate>, forceCatalog = false) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setSettled(false);
setSignedOut(false);
bootRef.current = null;
void loadSessionList(controller.signal, forceCatalog)
.then((boot) => {
if (disposed || controller.signal.aborted || controllerRef.current !== controller) return;
const requestedSubject = boot.subjectId ?? readCurrentSubjectId();
if (requestedSubject !== readCurrentSubjectId()) return;
bootRef.current = boot;
const sameAccount = Boolean(boot.account) && displayedAccount === boot.account?.user.id;
setSessions((current) => sameAccount ? mergeSubjectSessionRows(current, boot.sessions, {
subjectId: requestedSubject,
complete: boot.cursor == null,
}) : boot.sessions);
displayedAccount = boot.account?.user.id ?? "";
setSessionsCursor(boot.cursor);
setAccount(boot.account);
setSignedOut(boot.signedOut);
setSettled(true);
nextReadyPack.resolve();
})
.catch(() => {
if (disposed || controller.signal.aborted || controllerRef.current !== controller) return;
bootRef.current = { sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" };
setSettled(true);
nextReadyPack.resolve();
});
};
reloadRef.current = () => {
const previous = currentReady.current;
const next = createReadyGate();
currentReady.current = next;
previous.resolve();
invalidateAccountFlight();
load(next, true);
};
load(readyPack);
const unsubscribe = subscribeCurrentSubject(() => {
if (disposed) return;
reloadRef.current();
});
return () => {
disposed = true;
unsubscribe();
reloadRef.current = () => undefined;
controllerRef.current?.abort();
};
}, []);
// BUG-1104: a tab that opened on a secondary page has no warm snapshot yet,
// so its first 「新建对话」 would cold-start Home. Once the list is settled and
// no Home is mounted, fill the snapshot in the background (read-only GETs).
// Home mounting, sign-out, an account switch or a list reload cancels it.
const prefetchAccountId = settled && !signedOut && !registration && account && isProfileComplete(readProfile(account.profile))
? account.user.id
: null;
useEffect(() => {
if (!prefetchAccountId || window.location.pathname === "/") return;
const controller = new AbortController();
const run = () => {
const isCurrent = () => !controller.signal.aborted;
void prefetchHomeWarmSnapshot(prefetchAccountId, isCurrent, undefined, controller.signal);
// Only the account holder's own line: other people's profiles live in the
// subject catalog, and a missing line there just keeps the old behaviour.
if (readCurrentSubjectId() === "self" && account) {
void prefetchHomeWarmDaily(prefetchAccountId, "self", readProfile(account.profile), isCurrent, undefined, controller.signal);
}
};
// Safari has no requestIdleCallback; a short timeout keeps it off the first paint.
const idle = "requestIdleCallback" in window;
const handle = idle ? window.requestIdleCallback(run, { timeout: 2000 }) : window.setTimeout(run, 300);
return () => {
controller.abort();
if (idle) window.cancelIdleCallback(handle);
else window.clearTimeout(handle);
};
}, [account, prefetchAccountId]);
const reload = useCallback(() => reloadRef.current(), []);
const registerShellControls = useCallback((next: ShellRegistration | null) => {
setRegistration(next);
}, []);
const boot = useCallback(() => bootRef.current, []);
const waitUntilReady = useCallback(async () => {
for (;;) {
const gate = currentReady.current;
await gate.promise;
if (gate === currentReady.current) return;
}
}, []);
const ready = readyPack.promise;
const value = useMemo<SessionListContextValue>(() => ({
sessions,
setSessions,
sessionsCursor,
setSessionsCursor,
account,
setAccount,
signedOut,
settled,
ready,
waitUntilReady,
boot,
reload,
registerShellControls,
}), [
account,
boot,
reload,
registerShellControls,
sessions,
sessionsCursor,
settled,
signedOut,
ready,
waitUntilReady,
]);
return (
<SessionListContext.Provider value={value}>
<ShellRegistrationContext.Provider value={registration}>
{children}
</ShellRegistrationContext.Provider>
</SessionListContext.Provider>
);
}
export function useSessionList(): SessionListContextValue {
const value = useContext(SessionListContext);
if (!value) {
throw new Error("useSessionList must be used inside SessionListProvider");
}
return value;
}
export function useShellRegistration(): ShellRegistration | null {
return useContext(ShellRegistrationContext);
}
export function sessionListSidebarModel(
list: SessionListContextValue,
registered: ShellRegistration | null,
): {
sessions: readonly SidebarSession[];
account: SidebarAccount | null;
} {
return {
sessions: registered?.sidebarSessions ?? sessionsToSidebarRows(list.sessions),
account: registered?.sidebarAccount ?? toSidebarAccount(list.account),
};
}