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..e6485cb33 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 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)};`); } - 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,22 +87,39 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string 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) => 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 768e26285..333ce1df9 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,139 @@ 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 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 = [ + { 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 regexOnly = regexOnlyMatchRoute({ 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, regexOnly(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.toContain('/^'); + }); +}); 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');