From c9b4263c7d58f99ae1acae5ce4e99d18a515e1ad Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 12 Sep 2026 05:22:49 -0400 Subject: [PATCH] perf(cli): warm the preview route before the browser opens The browser's first request for a project's preview paid the server's cold compiler import and first bundle in the request path, after the studio shell's own first-layout stall, so both costs landed on the user's time-to-first-frame in series. The CLI now issues one fire-and-forget request for the preview route from openStudioBrowser, the single funnel for every launch path, before the --no-open early return so pasted URLs benefit too. Measured on the demo project: first client request 231ms before, 101ms after; the ETag 304 path afterwards is under 1ms. The fetch carries a 10s abort so an unsettled connection cannot keep an otherwise-finished CLI process alive, matching the package's existing convention. The shader query params the player appends are not part of the route's cache key, so the plain route URL warms the same entry. --- packages/cli/src/commands/preview.test.ts | 35 +++++++++++++++++++++++ packages/cli/src/commands/preview.ts | 10 ++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/preview.test.ts b/packages/cli/src/commands/preview.test.ts index 4d6c7528fd..e90dade70f 100644 --- a/packages/cli/src/commands/preview.test.ts +++ b/packages/cli/src/commands/preview.test.ts @@ -9,6 +9,7 @@ import { foregroundPreviewReadyPayload, handlePreviewKillAll, handlePreviewList, + openStudioBrowser, previewLaunchMode, previewLaunchModeError, previewPortError, @@ -21,12 +22,19 @@ import { studioSummaryUrls, waitForStudioChildClose, } from "./preview.js"; +import { openBrowser } from "../utils/openBrowser.js"; + +vi.mock("../utils/openBrowser.js", async (importOriginal) => ({ + ...(await importOriginal()), + openBrowser: vi.fn(), +})); const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); + vi.unstubAllGlobals(); process.exitCode = undefined; }); @@ -493,3 +501,30 @@ describe("waitForStudioChildClose", () => { expect(signalTarget.off).toHaveBeenCalledTimes(2); }); }); + +describe("openStudioBrowser", () => { + it("warms the preview route before the browser launch, and also under --no-open", () => { + const order: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((url: string) => { + order.push(`fetch ${url}`); + return Promise.resolve({ body: null } as Response); + }), + ); + vi.mocked(openBrowser) + .mockReset() + .mockImplementation(() => { + order.push("openBrowser"); + }); + + openStudioBrowser("http://127.0.0.1:4567", "Launch #1", "/tmp/launch"); + openStudioBrowser("http://127.0.0.1:4567", "demo", "/tmp/demo", { noOpen: true }); + + expect(order).toEqual([ + "fetch http://127.0.0.1:4567/api/projects/Launch%20%231/preview", + "openBrowser", + "fetch http://127.0.0.1:4567/api/projects/demo/preview", + ]); + }); +}); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 5203d7eead..ddd092786e 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -1258,12 +1258,20 @@ function previewLifecycleSession(options: { }; } -function openStudioBrowser( +export function openStudioBrowser( url: string, projectName: string, projectDir: string, options?: BrowserLaunchOptions, ): void { + // Pay the route's cold compiler import here so the browser's first request + // doesn't. The shader query params the player appends aren't in the route's + // ETag; the timeout stops an unsettled fetch holding the CLI's event loop. + void fetch(`${url}/api/projects/${encodeURIComponent(projectName)}/preview`, { + signal: AbortSignal.timeout(10_000), + }) + .then((res) => res.body?.cancel()) + .catch(() => {}); if (options?.noOpen) return; openBrowser(studioDeepLink(url, projectName, projectDir), { browserPath: options?.browserPath,