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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-09-12 05:22:49 -04:00
parent 51a88b9566
commit c9b4263c7d
2 changed files with 44 additions and 1 deletions
+35
View File
@@ -9,6 +9,7 @@ import {
foregroundPreviewReadyPayload, foregroundPreviewReadyPayload,
handlePreviewKillAll, handlePreviewKillAll,
handlePreviewList, handlePreviewList,
openStudioBrowser,
previewLaunchMode, previewLaunchMode,
previewLaunchModeError, previewLaunchModeError,
previewPortError, previewPortError,
@@ -21,12 +22,19 @@ import {
studioSummaryUrls, studioSummaryUrls,
waitForStudioChildClose, waitForStudioChildClose,
} from "./preview.js"; } from "./preview.js";
import { openBrowser } from "../utils/openBrowser.js";
vi.mock("../utils/openBrowser.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/openBrowser.js")>()),
openBrowser: vi.fn(),
}));
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals();
process.exitCode = undefined; process.exitCode = undefined;
}); });
@@ -493,3 +501,30 @@ describe("waitForStudioChildClose", () => {
expect(signalTarget.off).toHaveBeenCalledTimes(2); 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",
]);
});
});
+9 -1
View File
@@ -1258,12 +1258,20 @@ function previewLifecycleSession(options: {
}; };
} }
function openStudioBrowser( export function openStudioBrowser(
url: string, url: string,
projectName: string, projectName: string,
projectDir: string, projectDir: string,
options?: BrowserLaunchOptions, options?: BrowserLaunchOptions,
): void { ): 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; if (options?.noOpen) return;
openBrowser(studioDeepLink(url, projectName, projectDir), { openBrowser(studioDeepLink(url, projectName, projectDir), {
browserPath: options?.browserPath, browserPath: options?.browserPath,