From 1b5ba4384f06a5df0d0c0092692d61fe13e5f882 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Wed, 21 Oct 2026 20:29:40 +0800 Subject: [PATCH 1/2] perf(pages): emit a compact route manifest ## Problem `generateComponentManifest` produces the route metadata that every Pages client entry and SSR renderer embeds. Page bodies load lazily, but the metadata loads eagerly, and for every page it emitted: - a `components` entry, `"id": () => import("...")`; - a `layoutTree` entry repeating the page id and its layout chain, even when every page shares the same chain; - a `routes` entry repeating the id with a RegExp, even for static paths, which `matchRoute` tested one by one. In the many-pages benchmark app (React, one root layout, static pages), the client entry grew to 498,210 B (106,537 B gzipped) at 1,000 pages and 1,610,210 B (250,066 B gzipped) at 5,000. At 5,000 pages, the route RegExps were 435 KB and the layout chains 225 KB of that entry. ## Fix The generated module keeps the same bindings (`components`, `layoutTree`, `routeMeta`, `matchRoute`), so no adapter changes: - Each page is one row: its id, its loader, the index of its layout chain, and its pattern only when that differs from `"/" + id`. - Identical layout chains are emitted once and shared. - Static routes ahead of every dynamic route are matched with one Map lookup. Routes from the first dynamic one onwards keep their RegExps and order, so the first matching route is unchanged. ## Impact Client entry of the many-pages benchmark app at 5,000 pages: raw 1,610,210 B -> 970,417 B (-40%) gzip 250,047 B -> 207,782 B (-17%) brotli 142,147 B -> 124,254 B (-13%) Most of what remains is the lazy import for each page and its preload list, which Vite generates. Loading, hydrating and client navigation times did not change measurably over localhost on an M5 (about 130 ms and 60 ms either way); the saving is in bytes to download and parse. ## Verification - `pages-manifest.test.ts` compares `matchRoute` with testing each page pattern in order, over static, dynamic, catch-all and wildcard pages, duplicates, a static route after a dynamic one, ids whose path differs, query strings and encoded paths. It fails if static routes after a dynamic one are looked up exactly. It also checks `layoutTree` against `resolveLayoutIds` for every page, the component list and island flags. - The adapters' `plugin-pages.test.ts` now look for the new page rows. - `vp run --filter ./packages/void typecheck`. - `vp run test`: 508 affected files, including the Pages integration suites for all four adapters. Two platform API tests timed out under load and pass on a rerun. - Built the 5,000-page benchmark app and checked in Chrome that it hydrates and navigates on the client to the last page without a document reload or errors. Co-Authored-By: Claude Opus 5.5 --- packages/react/test/unit/plugin-pages.test.ts | 4 +- packages/solid/test/unit/plugin-pages.test.ts | 4 +- .../svelte/test/unit/plugin-pages.test.ts | 4 +- packages/void/src/pages/manifest.ts | 90 ++++++---- .../void/test/unit/pages-manifest.test.ts | 160 +++++++++++++++++- packages/vue/test/unit/plugin-pages.test.ts | 4 +- 6 files changed, 227 insertions(+), 39 deletions(-) diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index 5526d7709..246d3cc9a 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -128,7 +128,7 @@ describe('pagesPlugin dev config', () => { const load = getHookHandler(plugin.load); const renderer = await load('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"test": () => import'); + expect(renderer).toContain('["test", () => import'); }); it('logs a page scan error from the watcher and keeps the last scan', async () => { @@ -153,7 +153,7 @@ describe('pagesPlugin dev config', () => { expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); expect(invalidateModule).not.toHaveBeenCalled(); const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"index": () => import'); + expect(renderer).toContain('["index", () => import'); expect(renderer).not.toContain('"about"'); writePage( diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index db8407a32..b31feba83 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -75,7 +75,7 @@ describe('pagesPlugin dev HMR', () => { const load = getHookHandler(plugin.load); const renderer = await load('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"test": () => import'); + expect(renderer).toContain('["test", () => import'); }); it('logs a page scan error from the watcher and keeps the last scan', async () => { @@ -100,7 +100,7 @@ describe('pagesPlugin dev HMR', () => { expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); expect(invalidateModule).not.toHaveBeenCalled(); const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"index": () => import'); + expect(renderer).toContain('["index", () => import'); expect(renderer).not.toContain('"about"'); writePage( diff --git a/packages/svelte/test/unit/plugin-pages.test.ts b/packages/svelte/test/unit/plugin-pages.test.ts index faabb40ca..25b949d66 100644 --- a/packages/svelte/test/unit/plugin-pages.test.ts +++ b/packages/svelte/test/unit/plugin-pages.test.ts @@ -75,7 +75,7 @@ describe('pagesPlugin dev HMR', () => { const load = getHookHandler(plugin.load); const renderer = await load('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"test": () => import'); + expect(renderer).toContain('["test", () => import'); }); it('logs a page scan error from the watcher and keeps the last scan', async () => { @@ -100,7 +100,7 @@ describe('pagesPlugin dev HMR', () => { expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); expect(invalidateModule).not.toHaveBeenCalled(); const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"index": () => import'); + expect(renderer).toContain('["index", () => import'); expect(renderer).not.toContain('"about"'); writePage( diff --git a/packages/void/src/pages/manifest.ts b/packages/void/src/pages/manifest.ts index 2a6470662..e7dd92393 100644 --- a/packages/void/src/pages/manifest.ts +++ b/packages/void/src/pages/manifest.ts @@ -4,35 +4,66 @@ import type { PageScanResult } from './types.ts'; /** * Generates the component import map and layout tree as code strings. * Used by both SSR renderer and client entry virtual modules. + * + * Client entries load this eagerly, so it grows with every page. Each page id + * is written once, identical layout chains are shared, and static routes are + * matched by exact lookup instead of a RegExp per page. The emitted bindings + * (`components`, `layoutTree`, `routeMeta`, `matchRoute`) keep their shape. */ export function generateComponentManifest(scan: PageScanResult, pagesDir: string): string { - const lines: Array = []; + const load = (filePath: string) => `() => import(${JSON.stringify(`${pagesDir}/${filePath}`)})`; - // Component lazy imports - lines.push('const components = {'); + const chains: Array = []; + const chainIndexes = new Map(); + const staticRows: Array = []; + const dynamicRows: Array = []; + // Routes are in precedence order. Static routes ahead of every dynamic route + // can be looked up exactly without changing which route matches first. + let inStaticPrefix = true; for (const page of scan.pages) { - const importPath = `${pagesDir}/${page.componentPath}`; - lines.push( - ` ${JSON.stringify(page.componentId)}: () => import(${JSON.stringify(importPath)}),`, - ); - } - for (const layout of [...scan.layouts, ...scan.namedLayouts]) { - const importPath = `${pagesDir}/${layout.filePath}`; - lines.push( - ` ${JSON.stringify(toLayoutId(layout))}: () => import(${JSON.stringify(importPath)}),`, - ); + const chain = JSON.stringify(resolveLayoutIds(page, scan.layouts, scan.namedLayouts)); + let chainIndex = chain === '[]' ? -1 : chainIndexes.get(chain); + if (chainIndex === undefined) { + chainIndex = chains.push(chain) - 1; + chainIndexes.set(chain, chainIndex); + } + const id = JSON.stringify(page.componentId); + inStaticPrefix &&= isStaticPattern(page.pattern); + if (inStaticPrefix) { + const pattern = + page.pattern === `/${page.componentId}` ? '' : `, ${JSON.stringify(page.pattern)}`; + staticRows.push(` [${id}, ${load(page.componentPath)}, ${chainIndex}${pattern}],`); + } else { + dynamicRows.push( + ` [${patternToRegex(page.pattern)}, ${id}, ${load(page.componentPath)}, ${chainIndex}],`, + ); + } } - lines.push('};'); - // Layout tree (component ID → ordered layout IDs) - lines.push('const layoutTree = {'); - for (const page of scan.pages) { - const chain = resolveLayoutIds(page, scan.layouts, scan.namedLayouts); - if (chain.length > 0) { - lines.push(` ${JSON.stringify(page.componentId)}: ${JSON.stringify(chain)},`); - } + const lines: Array = []; + lines.push('const components = {};'); + lines.push(`const __voidLayoutChains = [${chains.join(', ')}];`); + // [componentId, load, layout chain index (-1 for none), pattern unless "/" + componentId] + lines.push('const __voidStaticRoutes = [', ...staticRows, '];'); + // [pattern, componentId, load, layout chain index] + lines.push('const __voidDynamicRoutes = [', ...dynamicRows, '];'); + lines.push(`const layoutTree = {}; +const __voidStaticRouteIds = new Map(); +for (const [id, load, chain, pattern = "/" + id] of __voidStaticRoutes) { + components[id] = load; + if (chain >= 0) layoutTree[id] = __voidLayoutChains[chain]; + if (!__voidStaticRouteIds.has(pattern)) __voidStaticRouteIds.set(pattern, id); +} +const routes = []; +for (const [pattern, id, load, chain] of __voidDynamicRoutes) { + components[id] = load; + if (chain >= 0) layoutTree[id] = __voidLayoutChains[chain]; + routes.push({ pattern, id }); +}`); + // Layouts follow pages, as in the previous object literal where a layout won a shared key. + for (const layout of [...scan.layouts, ...scan.namedLayouts]) { + lines.push(`components[${JSON.stringify(toLayoutId(layout))}] = ${load(layout.filePath)};`); } - lines.push('};'); // Route metadata (sparse — only island pages) lines.push('const routeMeta = {'); @@ -43,16 +74,10 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string } lines.push('};'); - // Route pattern table for client-side URL matching - lines.push('const routes = ['); - for (const page of scan.pages) { - const regex = patternToRegex(page.pattern); - lines.push(` { pattern: ${regex}, id: ${JSON.stringify(page.componentId)} },`); - } - lines.push('];'); - lines.push(`function matchRoute(url) { const path = new URL(url, "http://x").pathname; + const id = __voidStaticRouteIds.get(path); + if (id !== undefined) return id; for (const r of routes) { if (r.pattern.test(path)) return r.id; } @@ -62,6 +87,11 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string return lines.join('\n'); } +/** Whether `patternToRegex` matches only the pattern's own text. */ +function isStaticPattern(pattern: string): boolean { + return pattern.split('/').every((segment) => segment !== '*' && !/^:[^/]+$/.test(segment)); +} + function patternToRegex(pattern: string): string { const regexSource = pattern .split('/') diff --git a/packages/void/test/unit/pages-manifest.test.ts b/packages/void/test/unit/pages-manifest.test.ts index 768e26285..99b3acbdf 100644 --- a/packages/void/test/unit/pages-manifest.test.ts +++ b/packages/void/test/unit/pages-manifest.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vite-plus/test'; +import { resolveLayoutIds } from '../../src/pages/layout-resolution.ts'; import { generateComponentManifest } from '../../src/pages/manifest.ts'; -import type { PageDefinition } from '../../src/pages/types.ts'; +import type { + LayoutDefinition, + NamedLayoutDefinition, + PageDefinition, + PageScanResult, +} from '../../src/pages/types.ts'; function matchRouteFor(pattern: string): (url: string) => string | null { const page: PageDefinition = { @@ -34,3 +40,155 @@ describe('generated Pages client route manifest', () => { expect(matchRoute('/docs')).toBeNull(); }); }); + +type GeneratedManifest = { + components: Record Promise>; + layoutTree: Record>; + routeMeta: Record; + matchRoute(url: string): string | null; +}; + +function evaluateManifest(scan: PageScanResult): GeneratedManifest { + const source = generateComponentManifest(scan, '/project/pages'); + return new Function( + 'URL', + `${source}\nreturn { components, layoutTree, routeMeta, matchRoute };`, + )(URL) as GeneratedManifest; +} + +function definePage( + componentId: string, + pattern: string, + componentPath: string, + extra: Partial = {}, +): PageDefinition { + return { + pattern, + params: [], + catchAll: false, + componentPath, + serverPath: null, + methods: [], + componentId, + ssrMode: 'auto', + prerenderMode: 'auto', + renderMode: 'server', + shouldPrerender: false, + ...extra, + }; +} + +/** The previous matcher: test each page's RegExp in route order. */ +function linearMatchRoute(pages: Array, url: string): string | null { + const path = new URL(url, 'http://x').pathname; + for (const page of pages) { + const source = page.pattern + .split('/') + .map((segment) => { + if (segment === '*') { + return '(.*)'; + } + if (/^:[^/{}]+\{\.\+\}$/.test(segment)) { + return '(.+)'; + } + if (/^:[^/]+$/.test(segment)) { + return '([^/]+)'; + } + return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }) + .join('/'); + if (new RegExp(`^${source}$`).test(path)) { + return page.componentId; + } + } + return null; +} + +const layouts: Array = [ + { directory: '', filePath: 'layout.tsx' }, + { directory: 'blog', filePath: 'blog/layout.tsx' }, +]; +const namedLayouts: Array = [ + { name: 'marketing', directory: '', filePath: '_layouts/marketing.tsx' }, +]; +// Route order as the scanner sorts it, plus a static route after a dynamic one. +const pages = [ + definePage('index', '/', 'index.tsx'), + definePage('about', '/about', 'about.tsx'), + definePage('about/index', '/about', 'about/index.tsx'), + definePage('blog', '/blog', 'blog/index.tsx'), + definePage('(marketing)/pricing', '/pricing', '(marketing)/pricing.tsx', { + layout: 'marketing', + }), + definePage('files/a*b', '/files/a*b', 'files/a*b.tsx', { layout: false }), + definePage('blog/[slug]', '/blog/:slug', 'blog/[slug].tsx', { params: ['slug'] }), + definePage('blog/featured', '/blog/featured', 'blog/featured.tsx'), + definePage('legacy/[...rest]', '/legacy/*', 'legacy/[...rest].tsx', { catchAll: true }), + definePage('docs/[...slug]', '/docs/:slug{.+}', 'docs/[...slug].tsx', { + params: ['slug'], + catchAll: true, + island: true, + }), +]; + +describe('compact Pages route manifest', () => { + it('matches the same route as testing each page pattern in order', () => { + const { matchRoute } = evaluateManifest({ pages, layouts, namedLayouts }); + const urls = [ + '/', + '/about', + '/about/', + '/blog', + '/blog/post', + '/blog/featured', + '/pricing', + '/(marketing)/pricing', + '/files/a*b', + '/files/axb', + '/legacy/', + '/legacy/a/b', + '/docs', + '/docs/a/b', + '/caf%C3%A9', + '/about?tab=1#top', + 'https://example.com/blog', + '/missing', + ]; + for (const url of urls) { + expect([url, matchRoute(url)]).toEqual([url, linearMatchRoute(pages, url)]); + } + }); + + it('keeps every component, layout chain and island flag', () => { + const manifest = evaluateManifest({ pages, layouts, namedLayouts }); + + expect(Object.keys(manifest.components).sort()).toEqual( + [ + ...pages.map((page) => page.componentId), + 'layout', + 'blog/layout', + '_layouts/marketing', + ].sort(), + ); + for (const page of pages) { + const chain = resolveLayoutIds(page, layouts, namedLayouts); + expect([page.componentId, manifest.layoutTree[page.componentId]]).toEqual([ + page.componentId, + chain.length > 0 ? chain : undefined, + ]); + } + expect(manifest.routeMeta).toEqual({ 'docs/[...slug]': { island: true } }); + }); + + it('emits no RegExp for static routes', () => { + const source = generateComponentManifest( + { + pages: [definePage('index', '/', 'index.tsx'), definePage('about', '/about', 'about.tsx')], + layouts, + namedLayouts: [], + }, + '/project/pages', + ); + expect(source).not.toMatch(/\/\^/); + }); +}); diff --git a/packages/vue/test/unit/plugin-pages.test.ts b/packages/vue/test/unit/plugin-pages.test.ts index e67656490..2d5ee7637 100644 --- a/packages/vue/test/unit/plugin-pages.test.ts +++ b/packages/vue/test/unit/plugin-pages.test.ts @@ -80,7 +80,7 @@ describe('pagesPlugin dev HMR', () => { const load = getHookHandler(plugin.load); const renderer = await load('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"test": () => import'); + expect(renderer).toContain('["test", () => import'); }); it('logs a page scan error from the watcher and keeps the last scan', async () => { @@ -110,7 +110,7 @@ describe('pagesPlugin dev HMR', () => { expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'"); expect(invalidateModule).not.toHaveBeenCalled(); const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); - expect(renderer).toContain('"index": () => import'); + expect(renderer).toContain('["index", () => import'); expect(renderer).not.toContain('"about"'); writePage('_layouts/docs.vue', '\n'); From b0dc9e54ec4c87fa2c7e1a357711c0906361dfe5 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 11:43:22 +0800 Subject: [PATCH 2/2] refactor(pages): match route segments without regex - One small segment parser decides which segments become capture groups. isStaticPattern and patternToRegex share it, so they cannot drift apart. The generated manifest is byte-for-byte the same. - The equivalence test compares against the same generator with every route forced onto the RegExp path, instead of a regex copy of the old matcher. - The layout ordering comment no longer points at removed code. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/pages/manifest.ts | 36 ++++++++++++------- .../void/test/unit/pages-manifest.test.ts | 36 ++++++------------- 2 files changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/void/src/pages/manifest.ts b/packages/void/src/pages/manifest.ts index e7dd92393..e6485cb33 100644 --- a/packages/void/src/pages/manifest.ts +++ b/packages/void/src/pages/manifest.ts @@ -60,7 +60,7 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) { if (chain >= 0) layoutTree[id] = __voidLayoutChains[chain]; routes.push({ pattern, id }); }`); - // Layouts follow pages, as in the previous object literal where a layout won a shared key. + // Layouts are set after pages, so a layout keeps an id it shares with a page. for (const layout of [...scan.layouts, ...scan.namedLayouts]) { lines.push(`components[${JSON.stringify(toLayoutId(layout))}] = ${load(layout.filePath)};`); } @@ -87,27 +87,39 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) { return lines.join('\n'); } +const REGEX_SYNTAX = '.*+?^${}()|[]\\'; + +/** The capture group for `*`, `:name{.+}` and `:name`; other segments match as written. */ +function captureGroup(segment: string): string | undefined { + if (segment === '*') { + return '(.*)'; + } + if (segment.length < 2 || segment[0] !== ':') { + return undefined; + } + const name = segment.endsWith('{.+}') ? segment.slice(1, -4) : ''; + return name && !name.includes('{') && !name.includes('}') ? '(.+)' : '([^\\/]+)'; +} + /** Whether `patternToRegex` matches only the pattern's own text. */ function isStaticPattern(pattern: string): boolean { - return pattern.split('/').every((segment) => segment !== '*' && !/^:[^/]+$/.test(segment)); + return pattern.split('/').every((segment) => captureGroup(segment) === undefined); } function patternToRegex(pattern: string): string { const regexSource = pattern .split('/') .map((segment) => { - if (segment === '*') { - return '(.*)'; - } - if (/^:[^/{}]+\{\.\+\}$/.test(segment)) { - return '(.+)'; + const group = captureGroup(segment); + if (group) { + return group; } - if (/^:[^/]+$/.test(segment)) { - return '([^/]+)'; + let literal = ''; + for (const char of segment) { + literal += REGEX_SYNTAX.includes(char) ? '\\' + char : char; } - return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return literal; }) - .join('/') - .replace(/\//g, '\\/'); + .join('\\/'); return `/^${regexSource}$/`; } diff --git a/packages/void/test/unit/pages-manifest.test.ts b/packages/void/test/unit/pages-manifest.test.ts index 99b3acbdf..333ce1df9 100644 --- a/packages/void/test/unit/pages-manifest.test.ts +++ b/packages/void/test/unit/pages-manifest.test.ts @@ -78,30 +78,13 @@ function definePage( }; } -/** The previous matcher: test each page's RegExp in route order. */ -function linearMatchRoute(pages: Array, url: string): string | null { - const path = new URL(url, 'http://x').pathname; - for (const page of pages) { - const source = page.pattern - .split('/') - .map((segment) => { - if (segment === '*') { - return '(.*)'; - } - if (/^:[^/{}]+\{\.\+\}$/.test(segment)) { - return '(.+)'; - } - if (/^:[^/]+$/.test(segment)) { - return '([^/]+)'; - } - return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - }) - .join('/'); - if (new RegExp(`^${source}$`).test(path)) { - return page.componentId; - } - } - return null; +/** + * The ordered RegExp scan alone: a dynamic route placed first leaves no static + * prefix, so every page is tested by its RegExp in route order. + */ +function regexOnlyMatchRoute(scan: PageScanResult): (url: string) => string | null { + const first = definePage('__first', '/__first/:never', '__first.tsx', { params: ['never'] }); + return evaluateManifest({ ...scan, pages: [first, ...scan.pages] }).matchRoute; } const layouts: Array = [ @@ -134,6 +117,7 @@ const pages = [ describe('compact Pages route manifest', () => { it('matches the same route as testing each page pattern in order', () => { const { matchRoute } = evaluateManifest({ pages, layouts, namedLayouts }); + const regexOnly = regexOnlyMatchRoute({ pages, layouts, namedLayouts }); const urls = [ '/', '/about', @@ -155,7 +139,7 @@ describe('compact Pages route manifest', () => { '/missing', ]; for (const url of urls) { - expect([url, matchRoute(url)]).toEqual([url, linearMatchRoute(pages, url)]); + expect([url, matchRoute(url)]).toEqual([url, regexOnly(url)]); } }); @@ -189,6 +173,6 @@ describe('compact Pages route manifest', () => { }, '/project/pages', ); - expect(source).not.toMatch(/\/\^/); + expect(source).not.toContain('/^'); }); });