From 50b991e5041d9cbb3b2ad216e36387cf263b36aa Mon Sep 17 00:00:00 2001 From: Yangshun Date: Tue, 20 Oct 2026 16:16:25 +0800 Subject: [PATCH 1/2] perf(prerender): write page data from the rendered HTML Static prerendering fetched every page twice: once for the HTML and again with X-VoidPages for `_void/pages/*.json`. The HTML already embeds the same page object in `__VOID_PAGE_DATA__`, so read the JSON from it instead. Pages with deferred props embed them unresolved, so they keep the second request, which resolves them inline. Output is unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/prerender-render.ts | 21 +++++-- .../void/test/unit/prerender-render.test.ts | 60 ++++++++++++------- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/void/src/prerender-render.ts b/packages/void/src/prerender-render.ts index 7b9fb2215..b8d2730b8 100644 --- a/packages/void/src/prerender-render.ts +++ b/packages/void/src/prerender-render.ts @@ -1,6 +1,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'; +import { extractPageDataFromHtml } from '@void/isr/core'; import { dirname, isAbsolute, join, relative } from 'pathslash'; +const PAGE_DATA_SCRIPT_ID = '__VOID_PAGE_DATA__'; + export type PrerenderResult = { rendered: Array; failed: Array<{ path: string; error: string }>; @@ -157,14 +160,24 @@ export async function renderPrerenderPaths( } mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, html); - if (fetchPageData) { + // Page HTML embeds the same page object the X-VoidPages request + // returns, so one render produces both files. Pages with deferred + // props embed them unresolved and still need the prefetch request, + // which resolves them inline. + const embeddedPageData = extractPageDataFromHtml(html); + let pageData: unknown; + if (embeddedPageData !== null) { + pageData = JSON.parse(embeddedPageData); + } else if (fetchPageData && html.includes(PAGE_DATA_SCRIPT_ID)) { const pageDataResponse = await fetchPageData(path); if (pageDataResponse.headers.get('X-VoidPages')) { - const pageData = await pageDataResponse.json(); - mkdirSync(dirname(pageDataPath), { recursive: true }); - writeFileSync(pageDataPath, JSON.stringify(pageData)); + pageData = await pageDataResponse.json(); } } + if (pageData !== undefined) { + mkdirSync(dirname(pageDataPath), { recursive: true }); + writeFileSync(pageDataPath, JSON.stringify(pageData)); + } result.rendered.push(path); onResult?.(path, outputFile); } catch (err) { diff --git a/packages/void/test/unit/prerender-render.test.ts b/packages/void/test/unit/prerender-render.test.ts index a2768233e..f223e21b7 100644 --- a/packages/void/test/unit/prerender-render.test.ts +++ b/packages/void/test/unit/prerender-render.test.ts @@ -2,10 +2,13 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { serializePageData } from '../../src/pages/serialize.ts'; import { renderPrerenderPaths } from '../../src/prerender-render.ts'; import { createAssetFallback } from '../../src/runtime/routing-worker.ts'; const roots: Array = []; +const pageHtml = (pageData: unknown) => + ``; const fixture = () => { const root = mkdtempSync(join(tmpdir(), 'void-render-pool-')); roots.push(root); @@ -18,28 +21,17 @@ afterEach(() => { }); describe('prerender pool', () => { - it('bounds overlapping renders, keeps result order, and writes each page data after its HTML', async () => { + it('bounds overlapping renders, keeps result order, and writes each page data', async () => { const root = fixture(); const paths = Array.from({ length: 12 }, (_, i) => `/page-${i}`); let active = 0; let peak = 0; - const htmlDone = new Set(); - const result = await renderPrerenderPaths( - paths, - root, - async (path) => { - peak = Math.max(peak, ++active); - await new Promise((resolve) => setTimeout(resolve, path === '/page-0' ? 12 : 2)); - active--; - htmlDone.add(path); - return new Response(path, { status: path === '/page-3' ? 503 : 200 }); - }, - async (path) => { - expect(htmlDone.has(path)).toBe(true); - expect(readFileSync(join(root, `${path.slice(1)}.html`), 'utf8')).toBe(path); - return Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }); - }, - ); + const result = await renderPrerenderPaths(paths, root, async (path) => { + peak = Math.max(peak, ++active); + await new Promise((resolve) => setTimeout(resolve, path === '/page-0' ? 12 : 2)); + active--; + return new Response(pageHtml({ path }), { status: path === '/page-3' ? 503 : 200 }); + }); expect(peak).toBe(4); expect(result.rendered).toEqual(paths.filter((path) => path !== '/page-3')); expect(result.failed).toEqual([{ path: '/page-3', error: 'HTTP 503' }]); @@ -48,6 +40,32 @@ describe('prerender pool', () => { }); }); + it('writes resolved page data for deferred pages and none for HTML without page data', async () => { + const root = fixture(); + const result = await renderPrerenderPaths( + ['/deferred', '/island'], + root, + async (path) => + new Response( + path === '/deferred' + ? pageHtml({ component: 'deferred', props: {}, deferredKeys: { stats: 'stats' } }) + : '

island

', + ), + async () => + Response.json( + { component: 'deferred', props: { stats: 1 }, deferredKeys: { stats: 'stats' } }, + { headers: { 'X-VoidPages': 'true' } }, + ), + ); + expect(result).toEqual({ rendered: ['/deferred', '/island'], failed: [] }); + expect(JSON.parse(readFileSync(join(root, '_void/pages/deferred.json'), 'utf8'))).toEqual({ + component: 'deferred', + props: { stats: 1 }, + deferredKeys: { stats: 'stats' }, + }); + expect(existsSync(join(root, '_void/pages/island.json'))).toBe(false); + }); + it.each([ ['/', '/index'], ['/same', '/same'], @@ -128,9 +146,9 @@ describe('prerender pool', () => { clientDir, async (path) => { fetched.push(path); - return new Response('

page

'); + return new Response(pageHtml({ path })); }, - async (path) => Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }), + undefined, (path, outputFile, error) => outputs.push([path, outputFile, error]), ); const error = 'prerender path resolves outside the output directory'; @@ -145,7 +163,7 @@ describe('prerender pool', () => { expect(existsSync(join(clientDir, '_void/escape.json'))).toBe(false); expect(existsSync(join(clientDir, 'data-escape.html'))).toBe(false); expect(existsSync(join(clientDir, '_void/client/data-escape.json'))).toBe(false); - expect(readFileSync(join(clientDir, 'ok.html'), 'utf8')).toBe('

page

'); + expect(readFileSync(join(clientDir, 'ok.html'), 'utf8')).toBe(pageHtml({ path: '/ok' })); expect(JSON.parse(readFileSync(join(clientDir, '_void/pages/ok.json'), 'utf8'))).toEqual({ path: '/ok', }); From 846205ab0cba6f45986cd583d647642765b37de8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 20 Oct 2026 17:15:21 +0800 Subject: [PATCH 2/2] test(prerender): prove one request per page and unchanged JSON bytes The tests now fail if prerendering sends the X-VoidPages request again for a page whose HTML embeds its data: they record every page-data request and expect none, except for a deferred page, which still needs it after its HTML is written. One page uses characters the embedded script escapes, and its JSON file must match JSON.stringify of the page object. The comment says why the embedded text is parsed and stringified again. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/prerender-render.ts | 7 +-- .../void/test/unit/prerender-render.test.ts | 56 ++++++++++++++----- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/void/src/prerender-render.ts b/packages/void/src/prerender-render.ts index b8d2730b8..7e2e3b248 100644 --- a/packages/void/src/prerender-render.ts +++ b/packages/void/src/prerender-render.ts @@ -160,10 +160,9 @@ export async function renderPrerenderPaths( } mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, html); - // Page HTML embeds the same page object the X-VoidPages request - // returns, so one render produces both files. Pages with deferred - // props embed them unresolved and still need the prefetch request, - // which resolves them inline. + // Reuse the page object the HTML embeds for hydration, re-stringified to drop + // the \u003c-style escapes. Deferred props embed unresolved, so those pages + // still make the X-VoidPages request. HTML without the script gets no JSON. const embeddedPageData = extractPageDataFromHtml(html); let pageData: unknown; if (embeddedPageData !== null) { diff --git a/packages/void/test/unit/prerender-render.test.ts b/packages/void/test/unit/prerender-render.test.ts index f223e21b7..5d9ee0a92 100644 --- a/packages/void/test/unit/prerender-render.test.ts +++ b/packages/void/test/unit/prerender-render.test.ts @@ -21,27 +21,45 @@ afterEach(() => { }); describe('prerender pool', () => { - it('bounds overlapping renders, keeps result order, and writes each page data', async () => { + it('bounds overlapping renders, keeps result order, and writes page data from one render', async () => { const root = fixture(); const paths = Array.from({ length: 12 }, (_, i) => `/page-${i}`); + // Characters the embedded script escapes; the JSON file must keep the plain text. + const escapedPage = { path: '/page-11', props: { title: 'a&b\u2028' } }; let active = 0; let peak = 0; - const result = await renderPrerenderPaths(paths, root, async (path) => { - peak = Math.max(peak, ++active); - await new Promise((resolve) => setTimeout(resolve, path === '/page-0' ? 12 : 2)); - active--; - return new Response(pageHtml({ path }), { status: path === '/page-3' ? 503 : 200 }); - }); + const dataRequests: Array = []; + const result = await renderPrerenderPaths( + paths, + root, + async (path) => { + peak = Math.max(peak, ++active); + await new Promise((resolve) => setTimeout(resolve, path === '/page-0' ? 12 : 2)); + active--; + return new Response(pageHtml(path === escapedPage.path ? escapedPage : { path }), { + status: path === '/page-3' ? 503 : 200, + }); + }, + async (path) => { + dataRequests.push(path); + return Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }); + }, + ); expect(peak).toBe(4); expect(result.rendered).toEqual(paths.filter((path) => path !== '/page-3')); expect(result.failed).toEqual([{ path: '/page-3', error: 'HTTP 503' }]); - expect(JSON.parse(readFileSync(join(root, '_void/pages/page-11.json'), 'utf8'))).toEqual({ - path: '/page-11', + expect(dataRequests).toEqual([]); + expect(readFileSync(join(root, '_void/pages/page-11.json'), 'utf8')).toBe( + JSON.stringify(escapedPage), + ); + expect(JSON.parse(readFileSync(join(root, '_void/pages/page-10.json'), 'utf8'))).toEqual({ + path: '/page-10', }); }); it('writes resolved page data for deferred pages and none for HTML without page data', async () => { const root = fixture(); + const dataRequests: Array = []; const result = await renderPrerenderPaths( ['/deferred', '/island'], root, @@ -51,13 +69,20 @@ describe('prerender pool', () => { ? pageHtml({ component: 'deferred', props: {}, deferredKeys: { stats: 'stats' } }) : '

island

', ), - async () => - Response.json( + async (path) => { + dataRequests.push(path); + if (path !== '/deferred') { + return new Response('

island

'); + } + expect(existsSync(join(root, 'deferred.html'))).toBe(true); + return Response.json( { component: 'deferred', props: { stats: 1 }, deferredKeys: { stats: 'stats' } }, { headers: { 'X-VoidPages': 'true' } }, - ), + ); + }, ); expect(result).toEqual({ rendered: ['/deferred', '/island'], failed: [] }); + expect(dataRequests).toEqual(['/deferred']); expect(JSON.parse(readFileSync(join(root, '_void/pages/deferred.json'), 'utf8'))).toEqual({ component: 'deferred', props: { stats: 1 }, @@ -140,6 +165,7 @@ describe('prerender pool', () => { const root = fixture(); const clientDir = join(root, 'client'); const fetched: Array = []; + const dataRequests: Array = []; const outputs: Array<[string, string, string | undefined]> = []; const result = await renderPrerenderPaths( ['/blog/../../escape', '/ok', '/../client/data-escape'], @@ -148,7 +174,10 @@ describe('prerender pool', () => { fetched.push(path); return new Response(pageHtml({ path })); }, - undefined, + async (path) => { + dataRequests.push(path); + return Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }); + }, (path, outputFile, error) => outputs.push([path, outputFile, error]), ); const error = 'prerender path resolves outside the output directory'; @@ -158,6 +187,7 @@ describe('prerender pool', () => { { path: '/../client/data-escape', error }, ]); expect(fetched).toEqual(['/ok']); + expect(dataRequests).toEqual([]); expect(outputs).toContainEqual(['/blog/../../escape', 'blog/../../escape.html', error]); expect(existsSync(join(root, 'escape.html'))).toBe(false); expect(existsSync(join(clientDir, '_void/escape.json'))).toBe(false);