diff --git a/frontend/scripts/measure-home-first-load.mjs b/frontend/scripts/measure-home-first-load.mjs index 55601653..31e12511 100644 --- a/frontend/scripts/measure-home-first-load.mjs +++ b/frontend/scripts/measure-home-first-load.mjs @@ -14,6 +14,11 @@ * * /exec-daemon/node scripts/measure-home-first-load.mjs --base http://127.0.0.1:3920 --delay 600 [--runs 3] [--port 9340] [--throttle slow] * + * `--css-delay 1500` holds every stylesheet response that long (TASK-home-first-load-fix + * -20261001): an inline script after the stylesheets waits for them, which a + * local server hides because CSS arrives instantly. + * `--ua iphone` uses an iPhone Safari user agent (headless Chrome engine, not WebKit). + * * `--throttle slow` emulates 1.6 Mbps down / 150 ms latency so JS and font * downloads cost something against a local server. "First-screen JS" is the * scripts the HTML itself references (parser-initiated), not chunks loaded later. @@ -30,6 +35,9 @@ const BASE = (args.base ?? "http://127.0.0.1:3000").replace(/\/$/, ""); const DELAY = Number(args.delay ?? 600); const RUNS = Number(args.runs ?? 1); const PORT = Number(args.port ?? 9340); +const CSS_DELAY = Number(args["css-delay"] ?? 0); +const IPHONE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"; +const UA = args.ua === "iphone" ? IPHONE_UA : null; const THROTTLE = args.throttle === "slow" ? { offline: false, latency: 150, downloadThroughput: 1_600_000 / 8, uploadThroughput: 750_000 / 8 } : null; const ACCOUNT = JSON.stringify({ @@ -72,6 +80,7 @@ async function measureOnce() { const urls = new Map(); const jsDone = []; const parserJs = new Set(); + const cssReleased = []; const apiStarts = []; ws.onmessage = (message) => { const m = JSON.parse(message.data); @@ -86,6 +95,12 @@ async function measureOnce() { events.push([at, `api ${m.params.request.method} ${url.replace(/^https?:\/\/[^/]+/, "").slice(0, 60)} → ${status} (answered ${now()}ms)`]); void send("Fetch.fulfillRequest", { requestId: m.params.requestId, responseCode: status, responseHeaders: [{ name: "content-type", value: "application/json" }], body: Buffer.from(body).toString("base64") }); }, DELAY); + } else if (CSS_DELAY > 0 && /\.css(\?|$)/.test(url)) { + setTimeout(() => { + events.push([now(), `stylesheet released ${url.split("/").pop().split("?")[0]}`]); + cssReleased.push(now()); + void send("Fetch.continueRequest", { requestId: m.params.requestId }); + }, CSS_DELAY); } else { void send("Fetch.continueRequest", { requestId: m.params.requestId }); } @@ -103,6 +118,10 @@ async function measureOnce() { await send("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); await send("Network.enable"); if (THROTTLE) await send("Network.emulateNetworkConditions", THROTTLE); + if (UA) { + await send("Network.setUserAgentOverride", { userAgent: UA, platform: "iPhone" }); + await send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 3, mobile: true }); + } await send("Page.enable"); t0 = performance.now(); await send("Page.navigate", { url: `${BASE}/` }); @@ -129,8 +148,10 @@ async function measureOnce() { if (revealed !== null && at > revealed) break; if (!waves.length || at - waves[waves.length - 1] > DELAY / 2) waves.push(at); } + const bootReads = await send("Runtime.evaluate", { expression: `(() => { const r = window.__jyotishaBootReads; return r === undefined ? "absent" : JSON.stringify(Object.keys(r)); })()`, returnByValue: true }); + const firstCss = cssReleased.length ? Math.min(...cssReleased) : null; chrome.kill(); - return { events, summary: { revealed, lastJs, firstApi, firstApiVsLastJs: firstApi !== null && lastJs !== null ? firstApi - lastJs : null, apiRounds: waves.length, serifFontRequestsBeforeLoad: fontsBeforeLoad } }; + return { events, summary: { firstStylesheetReleased: firstCss, firstApiBeforeCss: firstCss !== null && firstApi !== null ? firstApi < firstCss : null, bootReadsAfterReveal: bootReads?.result?.value ?? null, revealed, lastJs, firstApi, firstApiVsLastJs: firstApi !== null && lastJs !== null ? firstApi - lastJs : null, apiRounds: waves.length, serifFontRequestsBeforeLoad: fontsBeforeLoad } }; } const summaries = []; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 0aaeb1fd..049f4492 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,7 +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 { bootEarlyReadsScriptSrc } from "@/lib/boot-early-reads"; import { firstPaintFallbackBootScript } from "@/lib/first-paint-fallback"; import { themePreferenceBootScript } from "@/lib/theme-preference"; @@ -59,8 +59,10 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac {/* Classic script, not a module: it has to run when the client bundle never does. */}