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:
co-authored by
Claude Opus 5.5
parent
ab656e0dea
commit
2fd1e2b05d
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user