Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions packages/void/src/prerender-render.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { extractPageDataFromHtml } from '@void/isr/core';
Comment thread
Brooooooklyn marked this conversation as resolved.
import { dirname, isAbsolute, join, relative } from 'pathslash';

const PAGE_DATA_SCRIPT_ID = '__VOID_PAGE_DATA__';
Comment thread
Brooooooklyn marked this conversation as resolved.

export type PrerenderResult = {
rendered: Array<string>;
failed: Array<{ path: string; error: string }>;
Expand Down Expand Up @@ -157,14 +160,23 @@ export async function renderPrerenderPaths(
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, html);
if (fetchPageData) {
// 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) {
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) {
Expand Down
70 changes: 59 additions & 11 deletions packages/void/test/unit/prerender-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = [];
const pageHtml = (pageData: unknown) =>
`<script id="__VOID_PAGE_DATA__" type="application/json">${serializePageData(pageData)}</script>`;
const fixture = () => {
const root = mkdtempSync(join(tmpdir(), 'void-render-pool-'));
roots.push(root);
Expand All @@ -18,36 +21,76 @@ 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 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: '</script><b>a&b</b>\u2028' } };
let active = 0;
let peak = 0;
const htmlDone = new Set<string>();
const dataRequests: Array<string> = [];
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 });
return new Response(pageHtml(path === escapedPage.path ? escapedPage : { 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);
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<string> = [];
const result = await renderPrerenderPaths(
['/deferred', '/island'],
root,
async (path) =>
new Response(
path === '/deferred'
? pageHtml({ component: 'deferred', props: {}, deferredKeys: { stats: 'stats' } })
: '<h1>island</h1>',
),
async (path) => {
dataRequests.push(path);
if (path !== '/deferred') {
return new Response('<h1>island</h1>');
}
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 },
deferredKeys: { stats: 'stats' },
});
expect(existsSync(join(root, '_void/pages/island.json'))).toBe(false);
});

it.each([
['/', '/index'],
['/same', '/same'],
Expand Down Expand Up @@ -122,15 +165,19 @@ describe('prerender pool', () => {
const root = fixture();
const clientDir = join(root, 'client');
const fetched: Array<string> = [];
const dataRequests: Array<string> = [];
const outputs: Array<[string, string, string | undefined]> = [];
const result = await renderPrerenderPaths(
['/blog/../../escape', '/ok', '/../client/data-escape'],
clientDir,
async (path) => {
fetched.push(path);
return new Response('<h1>page</h1>');
return new Response(pageHtml({ path }));
},
async (path) => {
dataRequests.push(path);
return Response.json({ path }, { headers: { 'X-VoidPages': 'true' } });
},
async (path) => Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }),
(path, outputFile, error) => outputs.push([path, outputFile, error]),
);
const error = 'prerender path resolves outside the output directory';
Expand All @@ -140,12 +187,13 @@ 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);
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('<h1>page</h1>');
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',
});
Expand Down
Loading