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
This commit is contained in:
Jesse_Chen
2026-10-01 00:19:56 +08:00
co-authored by Claude Opus 5.5
parent ab656e0dea
commit 2fd1e2b05d
9 changed files with 310 additions and 17 deletions
+4
View File
@@ -5,6 +5,7 @@ import { Toaster } from "@/components/ui/sonner";
import { StaleBuildGuard } from "@/components/stale-build-guard";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
import { bootEarlyReadsScript } from "@/lib/boot-early-reads";
import { firstPaintFallbackBootScript } from "@/lib/first-paint-fallback";
import { themePreferenceBootScript } from "@/lib/theme-preference";
@@ -57,6 +58,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<script dangerouslySetInnerHTML={{ __html: themePreferenceBootScript }} />
{/* Classic script, not a module: it has to run when the client bundle never does. */}
<script dangerouslySetInnerHTML={{ __html: firstPaintFallbackBootScript() }} />
{/* Home cold-boot reads leave with the HTML instead of after the bundle
runs (BUG-1127). The script is a no-op on every path except "/". */}
<script dangerouslySetInnerHTML={{ __html: bootEarlyReadsScript() }} />
{enableReactDevTools && (
<>
<Script
+123
View File
@@ -0,0 +1,123 @@
/**
* Cold-boot reads sent by the HTML itself (TASK-home-first-load-20260930 T8,
* BUG-1127).
*
* The home loading screen used to wait for every first-screen chunk to download
* and run before asking for the account. The reads do not depend on the bundle,
* so an inline classic script in `<head>` sends them as soon as the HTML
* arrives, on `/` only, and parks the promises on one global. The boot code
* takes each one once (`takeBootRead`); after that, and on every other page,
* the normal requests run unchanged.
*
* Both sides are built from the constants below, so the early request and the
* normal one cannot drift apart (same path, query, `cache: "no-store"` and
* default same-origin credentials). Nothing here is written to any storage:
* the script only reads the person choice `current-subject.ts` already keeps.
*
* No React, no "use client": the root layout (a server component) imports the
* script string.
*/
import { SESSION_PAGE_SIZE } from "./session-cursor.ts";
export const BOOT_READS_GLOBAL = "__jyotishaBootReads";
export const BOOT_ACCOUNT_PATH = "/api/account";
export const BOOT_CHART_PROFILES_PATH = "/api/chart-profiles";
export const BOOT_MODELS_PATH = "/api/models";
export const BOOT_READ_INIT: RequestInit = { cache: "no-store" };
/** Where `current-subject.ts` keeps the last signed-in account and its chosen person. */
export const LAST_SUBJECT_ACCOUNT_KEY = "jyotisha_current_subject_account";
export const CURRENT_SUBJECT_KEY_PREFIX = "jyotisha_current_subject:";
/** An early read older than this is not trusted; the boot reads fresh instead. */
export const BOOT_READ_MAX_AGE_MS = 30_000;
export function bootSessionsPath(subjectId: string): string {
return `/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`;
}
type BootReads = {
startedAt: number;
account?: Promise<Response>;
chartProfiles?: Promise<Response>;
models?: Promise<Response>;
sessions?: Promise<Response>;
sessionsSubject?: string;
};
type BootReadKey = "account" | "chartProfiles" | "models";
function js(value: string): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
/** Classic ES5, no modules: it runs before and independently of the bundle. */
export function bootEarlyReadsScript(): string {
const init = js(BOOT_READ_INIT.cache as string);
return `(function () {
try {
if (window.location.pathname !== "/" || typeof window.fetch !== "function" || typeof window.Promise !== "function") return;
var subject = "self";
try {
var account = window.localStorage.getItem(${js(LAST_SUBJECT_ACCOUNT_KEY)}) || "";
if (account) subject = window.localStorage.getItem(${js(CURRENT_SUBJECT_KEY_PREFIX)} + account) || "self";
} catch (storageError) { subject = "self"; }
var read = function (path) {
var request = window.fetch(path, { cache: ${init} });
request["catch"](function () {});
return request;
};
window[${js(BOOT_READS_GLOBAL)}] = {
startedAt: Date.now(),
account: read(${js(BOOT_ACCOUNT_PATH)}),
chartProfiles: read(${js(BOOT_CHART_PROFILES_PATH)}),
models: read(${js(BOOT_MODELS_PATH)}),
sessions: read(${js(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=`)} + encodeURIComponent(subject)),
sessionsSubject: subject
};
} catch (error) {}
})();`;
}
function bootReads(): BootReads | null {
if (typeof window === "undefined") return null;
const reads = (window as unknown as Record<string, BootReads | undefined>)[BOOT_READS_GLOBAL];
if (!reads || typeof reads !== "object") return null;
if (typeof reads.startedAt !== "number" || Date.now() - reads.startedAt > BOOT_READ_MAX_AGE_MS) {
clearBootReads();
return null;
}
return reads;
}
export function clearBootReads(): void {
if (typeof window === "undefined") return;
delete (window as unknown as Record<string, unknown>)[BOOT_READS_GLOBAL];
}
/** The early read for `key`, once; later calls get null. */
export function takeBootRead(key: BootReadKey): Promise<Response> | null {
const reads = bootReads();
const read = reads?.[key];
if (!reads || !read) return null;
delete reads[key];
return read;
}
/**
* The early sessions read, once, and only if it asked for `subjectId`. A read
* for another person is dropped, never returned (task red line 3).
*/
export function takeBootSessionsRead(subjectId: string): Promise<Response> | null {
const reads = bootReads();
if (!reads?.sessions) return null;
const read = reads.sessions;
const asked = reads.sessionsSubject;
delete reads.sessions;
delete reads.sessionsSubject;
return asked === subjectId ? read : null;
}
/** An early read that failed on the network falls back to the normal request. */
export function withBootFallback(early: Promise<Response> | null, fresh: () => Promise<Response>): Promise<Response> {
return early ? early.catch(() => fresh()) : fresh();
}
+6 -4
View File
@@ -2,15 +2,17 @@
import { useEffect, useSyncExternalStore } from "react";
import { BOOT_ACCOUNT_PATH, BOOT_READ_INIT, CURRENT_SUBJECT_KEY_PREFIX, LAST_SUBJECT_ACCOUNT_KEY, takeBootRead, withBootFallback } from "@/lib/boot-early-reads";
import { fetchCloudChartLibrary } from "@/lib/home-cloud-sync";
import { readProfile, upsertSelfChart } from "@/lib/home-profile";
import type { Account, ChartLibraryRecord, Profile } from "@/lib/home-types";
const SELF_SUBJECT_ID = "self";
export const LAST_SUBJECT_ACCOUNT_KEY = "jyotisha_current_subject_account";
// The HTML's early sessions read (boot-early-reads.ts) reads these same keys.
export { LAST_SUBJECT_ACCOUNT_KEY };
export function currentSubjectStorageKey(id: string): string {
return `jyotisha_current_subject:${id}`;
return `${CURRENT_SUBJECT_KEY_PREFIX}${id}`;
}
function storedValue(key: string): string {
@@ -147,7 +149,7 @@ export function invalidateAccountFlight(): void {
export function readBootAccount(): Promise<{ status: number; account: Account | null }> {
if (accountFlight) return accountFlight;
const published = (async () => {
const response = await fetch("/api/account", { cache: "no-store" });
const response = await withBootFallback(takeBootRead("account"), () => fetch(BOOT_ACCOUNT_PATH, BOOT_READ_INIT));
if (response.status === 401) return { status: 401, account: null };
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error("account_unavailable");
@@ -237,7 +239,7 @@ export function loadSubjectCatalog(force = false): Promise<ChartLibraryRecord[]>
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());
const libraryRead = settledRead(fetchCloudChartLibrary(takeBootRead("chartProfiles")));
catalogRequest = readBootAccount().then(async (result) => {
if (generation !== catalogGeneration) return loadSubjectCatalog();
if (result.status === 401 || !result.account) throw new Error("account_unavailable");
+2 -1
View File
@@ -61,6 +61,7 @@ import {
} from "@/lib/home-types";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import { startRectificationEntrySummaryRead } from "@/lib/rectification-entry-summary-read";
import { takeBootRead } from "@/lib/boot-early-reads";
import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard";
import type { SessionListBoot } from "@/lib/session-list-context";
import { isUnsavedEmptyConsultation, replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter";
@@ -324,7 +325,7 @@ export async function runAccountPhase(
// 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)
const modelCatalogPromise = io.fetchModelCatalog(signal, takeBootRead("models"))
.then((catalog) => ({ catalog, error: null as unknown }))
.catch((error: unknown) => ({ catalog: null, error }));
const activeStatusPromise = io.fetchActiveConsultationStatus(signal).catch((caught: unknown) => {
+7 -4
View File
@@ -1,4 +1,5 @@
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
import { BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, withBootFallback } from "@/lib/boot-early-reads";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { clearHomeWarmSnapshot } from "@/lib/home-warm-snapshot";
import {
@@ -189,8 +190,9 @@ export function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): C
};
}
export async function fetchCloudChartLibrary() {
const response = await fetch("/api/chart-profiles", { cache: "no-store" });
/** `early`: the HTML-sent cold-boot read, taken by the caller (boot-early-reads.ts). */
export async function fetchCloudChartLibrary(early: Promise<Response> | null = null) {
const response = await withBootFallback(early, () => fetch(BOOT_CHART_PROFILES_PATH, BOOT_READ_INIT));
if (!response.ok) throw new Error("cloud_chart_library_unavailable");
const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null;
return (payload?.profiles || []).map(normalizeChartLibraryApiRecord);
@@ -450,8 +452,9 @@ export async function fetchRectificationEntrySummary(signal?: AbortSignal): Prom
return entrySummaryFromResponse(payload);
}
export async function fetchModelCatalog(signal?: AbortSignal) {
const response = await fetch("/api/models", { signal, cache: "no-store" });
/** `early`: the HTML-sent cold-boot read, taken by the caller (boot-early-reads.ts). */
export async function fetchModelCatalog(signal?: AbortSignal, early: Promise<Response> | null = null) {
const response = await withBootFallback(early, () => fetch(BOOT_MODELS_PATH, { ...BOOT_READ_INIT, signal }));
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取可用模型"));
return parsePublicModelCatalog(payload);
+8 -4
View File
@@ -22,7 +22,7 @@ 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 { SESSION_PAGE_SIZE } from "@/lib/session-cursor";
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";
@@ -99,7 +99,7 @@ function sessionsToSidebarRows(sessions: readonly ChatSession[]): SidebarSession
}
function fetchSessionPage(subjectId: string, signal: AbortSignal): Promise<Response> {
return fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`, { signal, cache: "no-store" });
return fetch(bootSessionsPath(subjectId), { ...BOOT_READ_INIT, signal });
}
/**
@@ -111,7 +111,8 @@ function fetchSessionPage(subjectId: string, signal: AbortSignal): Promise<Respo
*/
export async function loadSessionList(signal: AbortSignal, forceCatalog = false): Promise<SessionListBoot> {
const guessedSubject = readCurrentSubjectId();
const guessedSessions = fetchSessionPage(guessedSubject, signal)
// 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();
@@ -139,7 +140,10 @@ export async function loadSessionList(signal: AbortSignal, forceCatalog = false)
await loadSubjectCatalog();
signal.throwIfAborted();
const subjectId = readCurrentSubjectId();
const guessed = subjectId === guessedSubject ? await guessedSessions : null;
// 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) {
+11 -2
View File
@@ -469,12 +469,21 @@ test("the (app) layout mounts one sidebar for home and the four secondary routes
assert.match(layout, /controls=\{registration\?\.controls\}/);
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
const accountRead = readFileSync(new URL("../src/lib/current-subject.ts", import.meta.url), "utf8");
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(subjectId\)\}`/);
const bootReads = readFileSync(new URL("../src/lib/boot-early-reads.ts", import.meta.url), "utf8");
// 原值:provider 里内联 fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`)。
// 新值:provider 用 fetchSessionPage → fetch(bootSessionsPath(subjectId), …),路径由 boot-early-reads.ts 生成。
// 原因:首页 HTML 的早发请求(TASK-home-first-load T8)与正常请求必须同一路径,共用一个常量防漂移。
assert.match(provider, /fetch\(bootSessionsPath\(subjectId\), \{ \.\.\.BOOT_READ_INIT, signal \}\)/);
assert.match(bootReads, /`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(subjectId\)\}`/);
// 原值:会话列表文件里直接 fetch("/api/account")。
// 新值:同一句 fetch 在 readBootAccount,会话列表调用它,避免和人物目录各打一次。
// 原因:人物目录和会话列表要共享这一次账户结果。
assert.match(provider, /readBootAccount\(\)/);
assert.match(accountRead, /fetch\("\/api\/account"/);
// 原值:accountRead 里 fetch("/api/account"。
// 新值:fetch(BOOT_ACCOUNT_PATH, BOOT_READ_INIT),常量 BOOT_ACCOUNT_PATH = "/api/account" 在 boot-early-reads.ts。
// 原因:同上,早发请求与正常请求共用常量;仍只在 readBootAccount 一处发。
assert.match(accountRead, /fetch\(BOOT_ACCOUNT_PATH, BOOT_READ_INIT\)/);
assert.match(bootReads, /BOOT_ACCOUNT_PATH = "\/api\/account"/);
});
// 「那一刻的天空」 header entry (TASK-birth-sky-cover-20260928 T3).
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import * as acorn from "acorn";
import { bindCurrentSubjectAccount, currentSubjectStorageKey, LAST_SUBJECT_ACCOUNT_KEY, readCurrentSubjectId, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, BOOT_READ_MAX_AGE_MS, BOOT_READS_GLOBAL, bootEarlyReadsScript, bootSessionsPath, clearBootReads, takeBootRead, takeBootSessionsRead, withBootFallback } from "../src/lib/boot-early-reads.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";
@@ -192,3 +197,136 @@ test("the entry-card summary started with the account reads is taken once, then
assert.equal(await takeRectificationEntrySummary(), null, "a failed read keeps the default card labels");
resetRectificationEntrySummaryReadForTests();
});
// ---------------------------------------------------------------------------
// T8 (BUG-1127): reads sent by the HTML before the bundle runs.
// ---------------------------------------------------------------------------
type BootWindow = { location: { pathname: string }; fetch: (path: string, init: RequestInit) => Promise<Response>; Promise: PromiseConstructor; localStorage: Pick<Storage, "getItem">; [key: string]: unknown };
function runBootScript(pathname: string, storage: Record<string, string> = {}) {
const sent: { path: string; init: RequestInit }[] = [];
const window: BootWindow = {
location: { pathname },
fetch: (path, init) => { sent.push({ path, init }); return Promise.resolve(new Response("{}")); },
Promise,
localStorage: { getItem: (key: string) => storage[key] ?? null },
};
vm.runInNewContext(bootEarlyReadsScript(), { window, Date, encodeURIComponent });
return { sent, window };
}
test("the early-read script is classic ES5 and only acts on / (T8)", () => {
assert.doesNotThrow(() => acorn.parse(bootEarlyReadsScript(), { ecmaVersion: 5, sourceType: "script" }));
for (const path of ["/chart", "/reports", "/login", "/people", "/admin"]) {
assert.deepEqual(runBootScript(path).sent, [], `${path} sends nothing`);
}
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
assert.match(layout, /__html: bootEarlyReadsScript\(\)/);
});
test("the early reads are the normal requests, byte for byte (T8)", () => {
const { sent, window } = runBootScript("/", {
[LAST_SUBJECT_ACCOUNT_KEY]: ACCOUNT_ID,
[currentSubjectStorageKey(ACCOUNT_ID)]: OTHER_ID,
});
assert.deepEqual(sent.map((request) => request.path).sort(), [BOOT_ACCOUNT_PATH, BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, bootSessionsPath(OTHER_ID)].sort());
for (const request of sent) assert.deepEqual({ ...request.init }, { ...BOOT_READ_INIT });
const reads = window[BOOT_READS_GLOBAL] as { sessionsSubject: string };
assert.equal(reads.sessionsSubject, OTHER_ID);
// No remembered account: the sessions read is for 本人.
assert.ok(runBootScript("/").sent.some((request) => request.path === bootSessionsPath("self")));
});
function withEarlyReads(reads: Record<string, unknown>, run: () => Promise<void>) {
const host = globalThis as unknown as { window?: unknown };
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
host.window = globalThis;
(globalThis as Record<string, unknown>)[BOOT_READS_GLOBAL] = { startedAt: Date.now(), ...reads };
return run().finally(() => {
clearBootReads();
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
else Reflect.deleteProperty(globalThis, "window");
});
}
const json = (payload: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(payload), { status }));
test("the boot takes each early read once, and the rest of the tab reads normally (T8)", async () => withEarlyReads({ account: json(account), models: json({}) }, async () => {
assert.ok(takeBootRead("account"));
assert.equal(takeBootRead("account"), null, "consumed once");
assert.ok(takeBootRead("models"));
assert.equal(takeBootRead("chartProfiles"), null, "never sent here");
}));
test("an early sessions read for another person is dropped, not returned (T8)", async () => withEarlyReads({ sessions: json({ sessions: [] }), sessionsSubject: OTHER_ID }, async () => {
assert.equal(takeBootSessionsRead("self"), null);
assert.equal(takeBootSessionsRead(OTHER_ID), null, "dropped for good after the mismatch");
}));
test("an early read older than the budget is not trusted (T8)", async () => withEarlyReads({ account: json(account) }, async () => {
(globalThis as unknown as Record<string, { startedAt: number }>)[BOOT_READS_GLOBAL].startedAt = Date.now() - BOOT_READ_MAX_AGE_MS - 1;
assert.equal(takeBootRead("account"), null);
}));
test("a failed early read falls back to the normal request (T8)", async () => {
let fresh = 0;
const response = await withBootFallback(Promise.reject(new TypeError("network")), async () => { fresh += 1; return new Response("{}"); });
assert.equal(fresh, 1);
assert.equal(response.status, 200);
assert.equal(await withBootFallback(null, async () => { fresh += 1; return new Response("{}", { status: 202 }); }).then((r) => r.status), 202);
});
test("cold boot on / uses the HTML's reads and sends nothing itself (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
account: json(account),
chartProfiles: json({ profiles: [] }),
sessions: json({ sessions: [sessionRow("88888888-8888-4888-8888-888888888127", "self")], nextCursor: null }),
sessionsSubject: "self",
}, async () => {
const result = await loadSessionList(new AbortController().signal);
assert.equal(pending.length, 0, "no request left the bundle");
assert.equal(result.account?.user.id, ACCOUNT_ID);
assert.deepEqual(result.sessions.map((session) => session.id), ["88888888-8888-4888-8888-888888888127"]);
})));
test("an early 401 still signs out, exactly like the normal read (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
account: json({}, 401),
chartProfiles: json({}, 401),
sessions: json({}, 401),
sessionsSubject: "self",
}, async () => {
const result = await loadSessionList(new AbortController().signal);
assert.equal(result.signedOut, true);
assert.equal(pending.length, 0);
})));
test("an early read that failed on the network: the boot reads again and still lands (T8)", async () => withBoot({}, async ({ pending }) => withEarlyReads({
account: Promise.reject(new TypeError("network")),
chartProfiles: json({ profiles: [] }),
sessions: json({ sessions: [], nextCursor: null }),
sessionsSubject: "self",
}, async () => {
const boot = loadSessionList(new AbortController().signal);
await flush();
answer(pending, (url) => url === "/api/account", account);
const result = await boot;
assert.equal(result.account?.user.id, ACCOUNT_ID);
})));
test("another account than the one this device remembered: the early sessions list is discarded (T8, red line 8)", async () => withBoot({
[LAST_SUBJECT_ACCOUNT_KEY]: "99999999-9999-4999-8999-999999999127",
}, async ({ pending }) => withEarlyReads({
account: json(account),
chartProfiles: json({ profiles: [] }),
sessions: json({ sessions: [sessionRow("55555555-5555-4555-8555-555555555127", "self")], nextCursor: null }),
sessionsSubject: "self",
}, async () => {
const boot = loadSessionList(new AbortController().signal);
await flush();
// The account changed, so the people list is read again and so is the sessions list.
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.deepEqual(result.sessions.map((session) => session.id), ["66666666-6666-4666-8666-666666666127"]);
})));
+11 -2
View File
@@ -4,6 +4,7 @@ import test from "node:test";
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
const accountRead = readFileSync(new URL("../src/lib/current-subject.ts", import.meta.url), "utf8");
const bootReads = readFileSync(new URL("../src/lib/boot-early-reads.ts", import.meta.url), "utf8");
const layout = readFileSync(new URL("../src/app/(app)/layout.tsx", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const sessionHook = readFileSync(new URL("../src/hooks/use-session-management.ts", import.meta.url), "utf8");
@@ -19,12 +20,20 @@ test("the app layout owns one SessionListProvider and one SidebarProvider", () =
});
test("the provider fetches the session list once and home does not fetch it again", () => {
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(subjectId\)\}`/);
// 原值:provider 里内联 fetch(`/api/sessions?limit=${SESSION_PAGE_SIZE}&subject=${encodeURIComponent(subjectId)}`)。
// 新值:provider 用 fetchSessionPage → fetch(bootSessionsPath(subjectId), …),路径由 boot-early-reads.ts 生成。
// 原因:首页 HTML 的早发请求(TASK-home-first-load T8)与正常请求必须同一路径,共用一个常量防漂移。
assert.match(provider, /fetch\(bootSessionsPath\(subjectId\), \{ \.\.\.BOOT_READ_INIT, signal \}\)/);
assert.match(bootReads, /`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(subjectId\)\}`/);
// 原值:provider 自己 fetch("/api/account")。
// 新值:provider 调用 readBootAccount,fetch 在 current-subject.ts,同一次加载只打一次。
// 原因:人物目录和会话列表要共享账户结果。
assert.match(provider, /readBootAccount\(\)/);
assert.match(accountRead, /fetch\("\/api\/account"/);
// 原值:accountRead 里 fetch("/api/account"。
// 新值:fetch(BOOT_ACCOUNT_PATH, BOOT_READ_INIT),常量 BOOT_ACCOUNT_PATH = "/api/account" 在 boot-early-reads.ts。
// 原因:同上,早发请求与正常请求共用常量;仍只在 readBootAccount 一处发。
assert.match(accountRead, /fetch\(BOOT_ACCOUNT_PATH, BOOT_READ_INIT\)/);
assert.match(bootReads, /BOOT_ACCOUNT_PATH = "\/api\/account"/);
// 原值: page.tsx 里 `await sessionListReady;`
// 新值: page.tsx 把 sessionListReady 交给 runHomeBootstrap,函数里 `await deps.sessionListReady`。
// 原因: 启动流程搬出首页后,会话列表仍只由 provider 拉一次。