Skip to content

Commit 6f78ab6

Browse files
yangshunclaudeBrooooooklyn
authored
perf(prerender): reuse each page's HTML render for its navigation data (#501)
## Problem Static prerendering made two requests per page: one for the HTML, and a second with `X-VoidPages` to write `_void/pages/*.json`. The loader and middleware ran twice for every page. ## Fix Fixes #500 The HTML already embeds the page object in `__VOID_PAGE_DATA__`, so the prerenderer now writes the JSON from that script and skips the second request. - Uses `extractPageDataFromHtml` from `@void/isr/core`, which ISR already uses to build JSON from page HTML. - Pages with deferred props still make the second request. Their HTML has those props missing, because the values are streamed after the page data script. The prefetch request returns them filled in. - In practice, defer() is mainly useful for server-rendered pages. A fully static site rarely needs it, so on most static sites the two implementations behave the same. IMO, this is an acceptable fix. The alternative is to - Island pages and non-page routes have no `__VOID_PAGE_DATA__` script, so they still get no JSON file. - The JSON is parsed and stringified again, so the files are byte-identical to before. The embedded copy escapes `<`, `>` and `&`. ## Results I ran two benchmarks, once with a 1000-page Void app (no actual network requests), another with a remote PayloadCMS database. ### 1000-page Void app Measured with my own 1000-page Void app benchmark, static output. Old and new code ran alternately in one process on the same build: | | Before | After | |---|---|---| | Requests | 2,002 | 1,001 | | Prerender phase, Node | 422–477 ms | 380–396 ms (−10% to −19%) | | Prerender phase, Cloudflare | 848–1,660 ms | 630–1,149 ms (−26% to −31%) | On Node, the second request was an in-process call, so a full build barely changes: 4.99 s before vs 4.64 s after, median of 3, within noise. The gain is larger on Cloudflare, where each request goes through the local Workers runtime. The built output (1,001 HTML and 1,001 JSON files) is byte-identical to before on both Node and Cloudflare. ### 100-page blog backed by remote Payload CMS Tested in a more realistic scenario of a 100-post blog backed by a remote Payload CMS database, where each pre-rendered page fetches from Payload CMS: | | Before | After | |---|---:|---:| | Build time | 265.00s | 133.31s | | Payload operations | 1,439 | 722 | ## Tests - Updated two existing tests that served page data only through the second request. - Added one test for deferred pages, which still need the second request, and for HTML without page data, which gets no JSON file. - `static-pages` and `node-static` integration tests pass. ## Alternative considered Another approach handles deferred props in the same single request. During prerendering, the page handler copies each deferred value into the page object as it streams, then appends the completed object to the end of the HTML as a build-only script. The prerenderer removes that script and writes it as the JSON file. This avoids the second request even for deferred pages, but it changes `handlePageGet`, which runs on every page request in every app, and the appended script has to pass through the app's HTML middleware unchanged. I prefer to keep the change in build-time code instead. Deferred props are uncommon in fully static pages, so those pages keeping the second request costs little. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: LongYinan <github@lyn.one>
1 parent f53f2b7 commit 6f78ab6

2 files changed

Lines changed: 75 additions & 15 deletions

File tree

‎packages/void/src/prerender-render.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { mkdirSync, writeFileSync } from 'node:fs';
2+
import { extractPageDataFromHtml } from '@void/isr/core';
23
import { dirname, isAbsolute, join, relative } from 'pathslash';
34

5+
const PAGE_DATA_SCRIPT_ID = '__VOID_PAGE_DATA__';
6+
47
export type PrerenderResult = {
58
rendered: Array<string>;
69
failed: Array<{ path: string; error: string }>;
@@ -157,14 +160,23 @@ export async function renderPrerenderPaths(
157160
}
158161
mkdirSync(dirname(outputPath), { recursive: true });
159162
writeFileSync(outputPath, html);
160-
if (fetchPageData) {
163+
// Reuse the page object the HTML embeds for hydration, re-stringified to drop
164+
// the \u003c-style escapes. Deferred props embed unresolved, so those pages
165+
// still make the X-VoidPages request. HTML without the script gets no JSON.
166+
const embeddedPageData = extractPageDataFromHtml(html);
167+
let pageData: unknown;
168+
if (embeddedPageData !== null) {
169+
pageData = JSON.parse(embeddedPageData);
170+
} else if (fetchPageData && html.includes(PAGE_DATA_SCRIPT_ID)) {
161171
const pageDataResponse = await fetchPageData(path);
162172
if (pageDataResponse.headers.get('X-VoidPages')) {
163-
const pageData = await pageDataResponse.json();
164-
mkdirSync(dirname(pageDataPath), { recursive: true });
165-
writeFileSync(pageDataPath, JSON.stringify(pageData));
173+
pageData = await pageDataResponse.json();
166174
}
167175
}
176+
if (pageData !== undefined) {
177+
mkdirSync(dirname(pageDataPath), { recursive: true });
178+
writeFileSync(pageDataPath, JSON.stringify(pageData));
179+
}
168180
result.rendered.push(path);
169181
onResult?.(path, outputFile);
170182
} catch (err) {

‎packages/void/test/unit/prerender-render.test.ts‎

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs
22
import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { afterEach, describe, expect, it } from 'vite-plus/test';
5+
import { serializePageData } from '../../src/pages/serialize.ts';
56
import { renderPrerenderPaths } from '../../src/prerender-render.ts';
67
import { createAssetFallback } from '../../src/runtime/routing-worker.ts';
78

89
const roots: Array<string> = [];
10+
const pageHtml = (pageData: unknown) =>
11+
`<script id="__VOID_PAGE_DATA__" type="application/json">${serializePageData(pageData)}</script>`;
912
const fixture = () => {
1013
const root = mkdtempSync(join(tmpdir(), 'void-render-pool-'));
1114
roots.push(root);
@@ -18,36 +21,76 @@ afterEach(() => {
1821
});
1922

2023
describe('prerender pool', () => {
21-
it('bounds overlapping renders, keeps result order, and writes each page data after its HTML', async () => {
24+
it('bounds overlapping renders, keeps result order, and writes page data from one render', async () => {
2225
const root = fixture();
2326
const paths = Array.from({ length: 12 }, (_, i) => `/page-${i}`);
27+
// Characters the embedded script escapes; the JSON file must keep the plain text.
28+
const escapedPage = { path: '/page-11', props: { title: '</script><b>a&b</b>\u2028' } };
2429
let active = 0;
2530
let peak = 0;
26-
const htmlDone = new Set<string>();
31+
const dataRequests: Array<string> = [];
2732
const result = await renderPrerenderPaths(
2833
paths,
2934
root,
3035
async (path) => {
3136
peak = Math.max(peak, ++active);
3237
await new Promise((resolve) => setTimeout(resolve, path === '/page-0' ? 12 : 2));
3338
active--;
34-
htmlDone.add(path);
35-
return new Response(path, { status: path === '/page-3' ? 503 : 200 });
39+
return new Response(pageHtml(path === escapedPage.path ? escapedPage : { path }), {
40+
status: path === '/page-3' ? 503 : 200,
41+
});
3642
},
3743
async (path) => {
38-
expect(htmlDone.has(path)).toBe(true);
39-
expect(readFileSync(join(root, `${path.slice(1)}.html`), 'utf8')).toBe(path);
44+
dataRequests.push(path);
4045
return Response.json({ path }, { headers: { 'X-VoidPages': 'true' } });
4146
},
4247
);
4348
expect(peak).toBe(4);
4449
expect(result.rendered).toEqual(paths.filter((path) => path !== '/page-3'));
4550
expect(result.failed).toEqual([{ path: '/page-3', error: 'HTTP 503' }]);
46-
expect(JSON.parse(readFileSync(join(root, '_void/pages/page-11.json'), 'utf8'))).toEqual({
47-
path: '/page-11',
51+
expect(dataRequests).toEqual([]);
52+
expect(readFileSync(join(root, '_void/pages/page-11.json'), 'utf8')).toBe(
53+
JSON.stringify(escapedPage),
54+
);
55+
expect(JSON.parse(readFileSync(join(root, '_void/pages/page-10.json'), 'utf8'))).toEqual({
56+
path: '/page-10',
4857
});
4958
});
5059

60+
it('writes resolved page data for deferred pages and none for HTML without page data', async () => {
61+
const root = fixture();
62+
const dataRequests: Array<string> = [];
63+
const result = await renderPrerenderPaths(
64+
['/deferred', '/island'],
65+
root,
66+
async (path) =>
67+
new Response(
68+
path === '/deferred'
69+
? pageHtml({ component: 'deferred', props: {}, deferredKeys: { stats: 'stats' } })
70+
: '<h1>island</h1>',
71+
),
72+
async (path) => {
73+
dataRequests.push(path);
74+
if (path !== '/deferred') {
75+
return new Response('<h1>island</h1>');
76+
}
77+
expect(existsSync(join(root, 'deferred.html'))).toBe(true);
78+
return Response.json(
79+
{ component: 'deferred', props: { stats: 1 }, deferredKeys: { stats: 'stats' } },
80+
{ headers: { 'X-VoidPages': 'true' } },
81+
);
82+
},
83+
);
84+
expect(result).toEqual({ rendered: ['/deferred', '/island'], failed: [] });
85+
expect(dataRequests).toEqual(['/deferred']);
86+
expect(JSON.parse(readFileSync(join(root, '_void/pages/deferred.json'), 'utf8'))).toEqual({
87+
component: 'deferred',
88+
props: { stats: 1 },
89+
deferredKeys: { stats: 'stats' },
90+
});
91+
expect(existsSync(join(root, '_void/pages/island.json'))).toBe(false);
92+
});
93+
5194
it.each([
5295
['/', '/index'],
5396
['/same', '/same'],
@@ -122,15 +165,19 @@ describe('prerender pool', () => {
122165
const root = fixture();
123166
const clientDir = join(root, 'client');
124167
const fetched: Array<string> = [];
168+
const dataRequests: Array<string> = [];
125169
const outputs: Array<[string, string, string | undefined]> = [];
126170
const result = await renderPrerenderPaths(
127171
['/blog/../../escape', '/ok', '/../client/data-escape'],
128172
clientDir,
129173
async (path) => {
130174
fetched.push(path);
131-
return new Response('<h1>page</h1>');
175+
return new Response(pageHtml({ path }));
176+
},
177+
async (path) => {
178+
dataRequests.push(path);
179+
return Response.json({ path }, { headers: { 'X-VoidPages': 'true' } });
132180
},
133-
async (path) => Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }),
134181
(path, outputFile, error) => outputs.push([path, outputFile, error]),
135182
);
136183
const error = 'prerender path resolves outside the output directory';
@@ -140,12 +187,13 @@ describe('prerender pool', () => {
140187
{ path: '/../client/data-escape', error },
141188
]);
142189
expect(fetched).toEqual(['/ok']);
190+
expect(dataRequests).toEqual([]);
143191
expect(outputs).toContainEqual(['/blog/../../escape', 'blog/../../escape.html', error]);
144192
expect(existsSync(join(root, 'escape.html'))).toBe(false);
145193
expect(existsSync(join(clientDir, '_void/escape.json'))).toBe(false);
146194
expect(existsSync(join(clientDir, 'data-escape.html'))).toBe(false);
147195
expect(existsSync(join(clientDir, '_void/client/data-escape.json'))).toBe(false);
148-
expect(readFileSync(join(clientDir, 'ok.html'), 'utf8')).toBe('<h1>page</h1>');
196+
expect(readFileSync(join(clientDir, 'ok.html'), 'utf8')).toBe(pageHtml({ path: '/ok' }));
149197
expect(JSON.parse(readFileSync(join(clientDir, '_void/pages/ok.json'), 'utf8'))).toEqual({
150198
path: '/ok',
151199
});

0 commit comments

Comments
 (0)