From 4cd6716475f7da9b5f656179bcebc76065b6da63 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 20 Oct 2026 20:16:25 +0800 Subject: [PATCH 1/2] perf(runtime): load the client asset manifest once per cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `createManifestLoader()` caches the parsed `/.vite/manifest.json` only after the fetch and parse finish. Requests that arrive together on a cold process or isolate all miss the cache, so each one fetches the manifest from `ASSETS` and parses it again. Nothing sets `globalThis.__VITE_MANIFEST__`, so production always takes this path. The manifest grows with the app. In the 5,000-page benchmark app it is 2,056,949 B. With 8 concurrent cold calls, the loader made 8 fetches and 8 `response.json()` parses totalling ~28 ms, and returned 8 distinct manifest objects. The parses run on the same thread, so they also delay the other requests. Later calls hit the cache. ## Fix Concurrent cold calls now share one in-flight load. The shared promise is cleared when it settles, so failures behave as before: - A non-ok response returns `null` and is not cached. The next call fetches again. - A rejected fetch rejects every caller that shared the attempt. The next call fetches again. ## Impact Measured with a probe that calls the built loader 8 times at once on a cold instance. A stub `ASSETS.fetch` serves the 5,000-page benchmark manifest verbatim after 5 ms. Node 24.14, 7 fresh processes per variant, median (range): | | Before | After | |---|---:|---:| | `ASSETS.fetch` calls | 8 | 1 | | Total `response.json()` time | 27.9 ms (25.9–30.3) | 3.6 ms (3.5–3.7) | | Wall time for the 8 calls | 44.2 ms (42.9–47.4) | 11.5 ms (11.2–11.7) | | Distinct manifest objects | 8 | 1 | Only the first burst of requests on each cold process or isolate changes. Warm requests already hit the cache. ## Verification - `vp test run packages/void/test/unit/routing-runtime.test.ts`: 40 passed. The new concurrency test fails on the old loader (8 fetches). - `vp run check`, after `vp run build:all`: 33/33 passed. - `vp run test --base origin/main`: 508 files selected, 507 passed and 1 skipped; 10,744 tests passed and 31 skipped. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/runtime/routing-assets.ts | 24 +++++++++----- .../void/test/unit/routing-runtime.test.ts | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/void/src/runtime/routing-assets.ts b/packages/void/src/runtime/routing-assets.ts index edf598c0e..8df6e8dcb 100644 --- a/packages/void/src/runtime/routing-assets.ts +++ b/packages/void/src/runtime/routing-assets.ts @@ -54,6 +54,7 @@ export type ManifestLoader = ( ) => Promise; export function createManifestLoader(): ManifestLoader { let cached: ClientManifest | null = null; + let pending: Promise | null = null; return async (request, env) => { if (cached) { return cached; @@ -68,14 +69,21 @@ export function createManifestLoader(): ManifestLoader { if (!assets || typeof assets.fetch !== 'function') { return null; } - const response = await assets.fetch( - new Request(new URL('/.vite/manifest.json', request.url).toString()), - ); - if (!response.ok) { - return null; - } - cached = (await response.json()) as ClientManifest; - return cached; + // Concurrent cold requests share one fetch and parse. A missing or failed + // manifest is not cached, so a later request retries. + pending ??= (async () => { + const response = await assets.fetch( + new Request(new URL('/.vite/manifest.json', request.url).toString()), + ); + if (!response.ok) { + return null; + } + cached = (await response.json()) as ClientManifest; + return cached; + })().finally(() => { + pending = null; + }); + return pending; }; } export type ClientAssetOptions = { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 018f0c224..fa791b46e 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -638,6 +638,39 @@ describe('typed client asset tags', () => { expect(islandTags.preloads).not.toContain('/md.js'); expect(binding).toHaveBeenCalledTimes(1); }); + it('shares one manifest fetch between concurrent cold loads', async () => { + const binding = vi.fn(async () => Response.json({ page: { file: 'page.js' } })); + const loader = createManifestLoader(); + const env = { ASSETS: { fetch: binding } }; + const request = new Request('https://app.test/page'); + const manifests = await Promise.all(Array.from({ length: 8 }, () => loader(request, env))); + expect(binding).toHaveBeenCalledTimes(1); + expect(new Set(manifests).size).toBe(1); + expect(manifests[0]).toEqual({ page: { file: 'page.js' } }); + expect(await loader(request, env)).toBe(manifests[0]); + expect(binding).toHaveBeenCalledTimes(1); + }); + it('retries the manifest after a missing response or a rejected fetch', async () => { + const manifest = { page: { file: 'page.js' } }; + const binding = vi + .fn<() => Promise>() + .mockResolvedValueOnce(new Response('missing', { status: 404 })) + .mockRejectedValueOnce(new Error('assets unavailable')) + .mockImplementation(async () => Response.json(manifest)); + const loader = createManifestLoader(); + const env = { ASSETS: { fetch: binding } }; + const request = new Request('https://app.test/page'); + expect(await loader(request, env)).toBeNull(); + expect(binding).toHaveBeenCalledTimes(1); + const rejected = [loader(request, env), loader(request, env)]; + await expect(rejected[0]).rejects.toThrow('assets unavailable'); + await expect(rejected[1]).rejects.toThrow('assets unavailable'); + expect(binding).toHaveBeenCalledTimes(2); + expect(await loader(request, env)).toEqual(manifest); + expect(binding).toHaveBeenCalledTimes(3); + await loader(request, env); + expect(binding).toHaveBeenCalledTimes(3); + }); it('loads development CSS with active roots and omits client tags when production has no manifest', async () => { const devCss = vi.fn( async ( From 899de2d976b350a8600cb8e4f64d762707c10367 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 21 Oct 2026 15:59:18 +0800 Subject: [PATCH 2/2] style(runtime): separate manifest loader tests --- packages/void/test/unit/routing-runtime.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index fa791b46e..c7453ef70 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -638,6 +638,7 @@ describe('typed client asset tags', () => { expect(islandTags.preloads).not.toContain('/md.js'); expect(binding).toHaveBeenCalledTimes(1); }); + it('shares one manifest fetch between concurrent cold loads', async () => { const binding = vi.fn(async () => Response.json({ page: { file: 'page.js' } })); const loader = createManifestLoader(); @@ -650,6 +651,7 @@ describe('typed client asset tags', () => { expect(await loader(request, env)).toBe(manifests[0]); expect(binding).toHaveBeenCalledTimes(1); }); + it('retries the manifest after a missing response or a rejected fetch', async () => { const manifest = { page: { file: 'page.js' } }; const binding = vi @@ -671,6 +673,7 @@ describe('typed client asset tags', () => { await loader(request, env); expect(binding).toHaveBeenCalledTimes(3); }); + it('loads development CSS with active roots and omits client tags when production has no manifest', async () => { const devCss = vi.fn( async (