diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 01fab3c9c..174b72fc6 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -94,6 +94,8 @@ Pages can be flat files such as `about.vue` or directory-based routes such as `a +Keep one file per route. Void reports an error when two page files resolve to the same path, such as `about.vue` and `about/index.vue`, or the same page in two route groups. + Each page can have a companion `.server.ts` file that runs exclusively on the server. It can export: - A [**loader**](./loaders), which runs on `GET` and returns the data that becomes the page component's props diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index 97688b3a5..3b050f3a4 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -48,7 +48,8 @@ export type ReactPagesConfig = Readonly<{ components: Record Promise>; layoutTree: Record>; routeMeta: Record; - matchRoute(url: string): string | null; + /** Page id for a decoded pathname. */ + matchRoute(path: string): string | null; staticPageData: boolean; staticPageDataFastPath: boolean; viewTransitions: boolean; diff --git a/packages/void/src/application-routing.ts b/packages/void/src/application-routing.ts index 48d7c6fb7..fd21d1660 100644 --- a/packages/void/src/application-routing.ts +++ b/packages/void/src/application-routing.ts @@ -10,6 +10,8 @@ export async function completeApplicationAnalysis( const scan = await scanRoutes(analysis.root, { output: analysis.config.output, paths: analysis.paths, + // A framework app's pages/ follows that framework's rules, not Void Pages. + checkPageRoutes: !analysis.framework, inventory: { routes: analysis.routes, middleware: analysis.middleware, diff --git a/packages/void/src/pages/manifest.ts b/packages/void/src/pages/manifest.ts index e6485cb33..b0757b123 100644 --- a/packages/void/src/pages/manifest.ts +++ b/packages/void/src/pages/manifest.ts @@ -8,11 +8,24 @@ import type { PageScanResult } from './types.ts'; * 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. + * (`components`, `layoutTree`, `routeMeta`, `matchRoute`) keep their shape; + * `matchRoute` takes a decoded pathname (see `decodePagePath`). */ export function generateComponentManifest(scan: PageScanResult, pagesDir: string): string { const load = (filePath: string) => `() => import(${JSON.stringify(`${pagesDir}/${filePath}`)})`; + // A page in a folder named "layout" (layout/index.tsx) has the id of the + // layout file beside it (layout.tsx). No page path has a segment starting + // with "_", so that layout is keyed "_layout" instead. + const pageIds = new Set(scan.pages.map((page) => page.componentId)); + const layoutKey = (id: string): string => { + if (!pageIds.has(id)) { + return id; + } + const slash = id.lastIndexOf('/'); + return id.slice(0, slash + 1) + '_' + id.slice(slash + 1); + }; + const chains: Array = []; const chainIndexes = new Map(); const staticRows: Array = []; @@ -21,7 +34,9 @@ export function generateComponentManifest(scan: PageScanResult, pagesDir: string // can be looked up exactly without changing which route matches first. let inStaticPrefix = true; for (const page of scan.pages) { - const chain = JSON.stringify(resolveLayoutIds(page, scan.layouts, scan.namedLayouts)); + const chain = JSON.stringify( + resolveLayoutIds(page, scan.layouts, scan.namedLayouts).map(layoutKey), + ); let chainIndex = chain === '[]' ? -1 : chainIndexes.get(chain); if (chainIndex === undefined) { chainIndex = chains.push(chain) - 1; @@ -60,9 +75,10 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) { 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( + `components[${JSON.stringify(layoutKey(toLayoutId(layout)))}] = ${load(layout.filePath)};`, + ); } // Route metadata (sparse — only island pages) @@ -74,8 +90,7 @@ for (const [pattern, id, load, chain] of __voidDynamicRoutes) { } lines.push('};'); - lines.push(`function matchRoute(url) { - const path = new URL(url, "http://x").pathname; + lines.push(`function matchRoute(path) { const id = __voidStaticRouteIds.get(path); if (id !== undefined) return id; for (const r of routes) { @@ -106,7 +121,8 @@ function isStaticPattern(pattern: string): boolean { return pattern.split('/').every((segment) => captureGroup(segment) === undefined); } -function patternToRegex(pattern: string): string { +/** The RegExp literal a page pattern matches with, the same on the client and the server. */ +export function patternToRegex(pattern: string): string { const regexSource = pattern .split('/') .map((segment) => { diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index 62b869ad2..217981c09 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -8,7 +8,7 @@ import { PrefetchCache, parseCacheFor } from './prefetch.ts'; import type { CacheForInput, CacheForResult } from './prefetch.ts'; import type { PageObject } from './protocol.ts'; import { createScrollRestoration } from './scroll-restoration.ts'; -import { isSameOrigin } from './url.ts'; +import { decodePagePath, isSameOrigin } from './url.ts'; /** * The server leaves a named action's key out of the page URL. When the page URL @@ -77,7 +77,8 @@ export type PageNavigationEngineConfig = { components: Record Promise>; layoutTree: Record>; routeMeta: Record; - matchRoute(url: string): string | null; + /** Page id for a decoded pathname (see `decodePagePath`). */ + matchRoute(path: string): string | null; staticPageData?: boolean; staticPageDataFastPath?: boolean; viewTransitions: boolean; @@ -239,7 +240,6 @@ export async function createPageNavigationEngine( components, layoutTree, routeMeta, - matchRoute, staticPageData: STATIC_PAGE_DATA_ENABLED = false, staticPageDataFastPath: STATIC_PAGE_DATA_FAST_PATH = STATIC_PAGE_DATA_ENABLED, prefetchHoverDelay: PREFETCH_HOVER_DELAY, @@ -247,6 +247,9 @@ export async function createPageNavigationEngine( cacheVisitedPageData: CACHE_VISITED_PAGE_DATA = false, } = config; + const matchRoute = (url: string) => + config.matchRoute(decodePagePath(new URL(url, 'http://x').pathname)); + const _prefetchCache = new PrefetchCache(); let _nextNavigationId = 0; let _pendingResource: PageNavigationResource | null = null; diff --git a/packages/void/src/pages/scan.ts b/packages/void/src/pages/scan.ts index e8f53635c..53fd11823 100644 --- a/packages/void/src/pages/scan.ts +++ b/packages/void/src/pages/scan.ts @@ -19,6 +19,7 @@ import { import { staticExportInitializers, staticLiteral } from '../shared/static-exports.ts'; import { scriptBlocks } from './island-imports.ts'; import { resolveNamedLayout } from './layout-resolution.ts'; +import { patternToRegex } from './manifest.ts'; import type { ExplicitBoolean, PageDefinition, @@ -37,6 +38,8 @@ export const MAX_REVALIDATE = 31536000; // 1 year in seconds /** Layout paths already reported; a dev server rescans pages on every file change. */ const warnedLayoutPaths = new Set(); +/** Unreachable pages already reported, for the same reason. */ +const warnedShadowedPages = new Set(); type PageRenderingSource = { componentPath: string; @@ -49,7 +52,13 @@ type PageRenderingSource = { hasGetPrerenderPaths?: boolean; }; -type PageScanOptions = { output?: 'server' | 'static'; paths?: ProjectPaths; pagesDir?: string }; +type PageScanOptions = { + output?: 'server' | 'static'; + paths?: ProjectPaths; + pagesDir?: string; + /** Report pages that share a route. Off when another framework owns pages/ (Nuxt nests users.vue and users/index.vue). */ + checkRoutes?: boolean; +}; type ResolvedPageRendering = { ssrMode: SsrMode; @@ -363,6 +372,11 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis return a.pattern.localeCompare(b.pattern); }); + if (options?.checkRoutes !== false) { + assertOnePagePerRoute(pages); + warnShadowedPages(pagesDir, pages); + } + // Validate: throw when regular pages are under island layouts for (const page of pages) { if (page.island) { @@ -405,6 +419,84 @@ export async function scanPages(root: string, options?: PageScanOptions): Promis return { pages, layouts, namedLayouts }; } +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** Group sorted pages by `key`, keeping only groups with more than one page. */ +function pagesSharing( + pages: Array, + key: (page: PageDefinition) => string, +): Array> { + const groups = new Map>(); + for (const page of pages) { + const group = groups.get(key(page)); + if (group) { + group.push(page); + } else { + groups.set(key(page), [page]); + } + } + return [...groups.values()].filter((group) => group.length > 1); +} + +/** + * Two files for one path have no stable winner: the server and the client can + * each pick a different one, and the order can change between runs. + */ +function assertOnePagePerRoute(pages: Array): void { + assertNoneShare( + pages, + (page) => page.pattern, + 'pages: Multiple page files resolve to the same route:', + 'Keep one page file per route and rename or remove the others.', + ); + // index.tsx and index/index.tsx have different routes but the same page id. + assertNoneShare( + pages, + (page) => page.componentId, + 'pages: Multiple page files resolve to the same page id:', + 'Rename or remove one of them.', + ); +} + +function assertNoneShare( + pages: Array, + key: (page: PageDefinition) => string, + title: string, + hint: string, +): void { + const clashes = pagesSharing(pages, key) + .map((group) => ({ + key: key(group[0]), + files: group.map((page) => page.componentPath).sort(compareText), + })) + .sort((left, right) => compareText(left.key, right.key)) + .map( + (clash) => + ` ${JSON.stringify(clash.key)}: ${clash.files.map((file) => JSON.stringify(file)).join(', ')}`, + ); + if (clashes.length > 0) { + throw new Error([title, ...clashes, hint].join('\n')); + } +} + +/** Dynamic routes that match the same URLs, such as [id] and [slug]: only the first is ever used. */ +function warnShadowedPages(pagesDir: string, pages: Array): void { + for (const [used, ...shadowed] of pagesSharing(pages, (page) => patternToRegex(page.pattern))) { + for (const page of shadowed) { + const key = `${pagesDir}\0${used.componentPath}\0${page.componentPath}`; + if (warnedShadowedPages.has(key)) { + continue; + } + warnedShadowedPages.add(key); + voidWarn( + `pages: Page '${page.componentPath}' (${page.pattern}) is never used: '${used.componentPath}' (${used.pattern}) matches the same URLs first. Remove one of them.`, + ); + } + } +} + // Same leading-script rule as @void/md's extractScript: a markdown page may open // with a