diff --git a/packages/void/src/prerender-render.ts b/packages/void/src/prerender-render.ts index 7b9fb2215..7e2e3b248 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,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) { diff --git a/packages/void/test/unit/prerender-render.test.ts b/packages/void/test/unit/prerender-render.test.ts index a2768233e..5d9ee0a92 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,12 +21,14 @@ 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: 'a&b\u2028' } }; let active = 0; let peak = 0; - const htmlDone = new Set(); + const dataRequests: Array = []; const result = await renderPrerenderPaths( paths, root, @@ -31,23 +36,61 @@ describe('prerender pool', () => { 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 = []; + const result = await renderPrerenderPaths( + ['/deferred', '/island'], + root, + async (path) => + new Response( + path === '/deferred' + ? pageHtml({ component: 'deferred', props: {}, deferredKeys: { stats: 'stats' } }) + : '

island

', + ), + 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 }, + deferredKeys: { stats: 'stats' }, + }); + expect(existsSync(join(root, '_void/pages/island.json'))).toBe(false); + }); + it.each([ ['/', '/index'], ['/same', '/same'], @@ -122,15 +165,19 @@ 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'], clientDir, async (path) => { fetched.push(path); - return new Response('

page

'); + 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'; @@ -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('

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', });