Repository navigation
perf(pages): emit a compact route manifest - #534
Merged
Brooooooklyn merged 2 commits intoOct 22, 2026
Merged
Conversation
## 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 <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Member
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Brooooooklyn
approved these changes
Oct 22, 2026
Brooooooklyn
added a commit
that referenced
this pull request
Oct 22, 2026
) Follow-up to #534. Fixes page routing bugs that were already on main. ## What was broken | Case | Before | |---|---| | `about.tsx` + `about/index.tsx`, `x.tsx` + `x.vue`, `(a)/team.tsx` + `(b)/team.tsx` | Two files for one route, no warning. The server and the client could each pick a different file (one file's loader fed the other file's component). Route groups changed winner between runs. With two loaders, dev failed with `Identifier '_pload_about' has already been declared`. | | `index.tsx` + `index/index.tsx` | Different routes, same page id `index`, so one route rendered the other page. | | `layout/index.tsx` next to `layout.tsx` | The page had the layout's id, so `/layout` rendered the layout as the page. | | `café.tsx`, `a b.tsx` | Never matched during client navigation: the client compared the encoded `/caf%C3%A9` with the raw pattern. | | A clashing page added while `void dev` runs | The dev server process exited. | ## What changes ``` scanPages same route / same page id → one error, every clash listed, sorted [id] + [slug] (same URLs) → one warning; the first page always wins today framework app (Nuxt …) → no check: users.vue + users/index.vue is Nuxt nesting manifest layout id == page id → that layout is keyed "_layout" (only then) client navigation URL → pathname → decodePagePath() → matchRoute(path) decodePagePath copies Hono's getPath decoding (no regex) void dev scan error → logged once, last good route table kept ``` Example error: ``` pages: Multiple page files resolve to the same route: "/about": "about.tsx", "about/index.tsx" "/team": "(a)/team.tsx", "(b)/team.tsx" Keep one page file per route and rename or remove the others. ``` Notes: - Apps without a clash get the same ids and manifest rows. The generated `matchRoute` now takes a decoded pathname. The engine and `matchAdminRoute` follow that. - The dev guard keeps the last good route table for any scan error, not only page clashes. - No repo playground has a clash: all 17 pages dirs were scanned. ## Tests - `pages-scan.test.ts`: the route clash error (exact text), the page id clash, the warning printed once across rescans, and no false positive for `/users/[id]/edit` next to `/users/[userId]/posts` or for `layout.tsx` + `layout/index.tsx`. - `scan.test.ts`: a Void app with `users.vue` + `users/index.vue` throws, and a Nuxt app does not. - `pages-manifest.test.ts`: the layout folder case. Encoded paths are checked against a real Hono app for 19 URLs (`%C3%A9`, `%c3%a9`, `%20`, `%25`, `%2525`, `%2F`, `%zz`, over-encoded ASCII, query/hash), with and without a root `[slug]`. - `plugin-routing.test.ts`: moving `about.tsx` to `about/index.tsx` in dev logs once, keeps the old table, and recovers on unlink. Nothing is logged when an adapter pages plugin is present. - Each new test fails on main. Unit suites (7,564), the browser tests for all four adapters and the smart runner's affected integration files pass locally. ## Not in this PR (already on main) - `routes/` has the same silent first-file-wins clash. `.dev`/`.prod` pairs are intended there, so it needs its own rule. - `a-b.tsx` and `a_b.tsx`, each with a `.server.ts`, both become `_pload_a_b` (dev SyntaxError). - ISR page TTLs compare the encoded pathname with the raw pattern (`@void/edge` `matchSegmentPattern`). - An unnamed catch-all `[...].tsx` matches the bare `/files` on the server but not on the client. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Pages clients eagerly load a manifest that repeats page IDs and layout chains. Emit compact rows and share identical layout chains to reduce this payload.
Initialization expands the rows into the existing component and layout lookup objects. Static routes before the first dynamic route use a path Map; remaining routes retain ordered regex matching to preserve precedence.
Generated structure
Simplified example with two static pages sharing a layout. Loader names represent lazy imports; dynamic routes are omitted.
Before:
After:
Component and layout selection continue to use object lookups. Static route matching uses
Map.get(path).Caution
Yes this format is less readable (by a bit), but it's definitely more space and runtime efficient for large number of pages. I'd argue this is a better change.
Bundle size
React production builds with generated static pages, a homepage, and one shared layout. Only the
voidpackage changes between variants; the adapter and external dependencies are identical. Sizes measure the emitted Pages client entry.The complete JavaScript dependency set required to hydrate page 1 also shrinks; bytes were not moved into another eagerly required chunk.
Runtime measurements
At 5,000 generated pages:
The compact representation adds about 2 ms of initialization work, while combined cold parse + initialization improves by 6.12 ms (paired 95% interval: 5.12–7.15 ms faster; adjusted p = 0.00012). The one-page cold case increases 29.3%, equivalent to 0.019 ms.
Measurements made using:
End-to-end loading and hydration speed remain unmeasured.
Note
Why keep objects for components and layoutTree?
Click to see comparison
A separate 16-pair experiment replaced both lookup objects with Maps while keeping compact rows, layout chains, and route matching identical.Maps improved initialization by 0.226 ms, but combined cold timing showed no statistically significant improvement (p = 0.90). Reads saved approximately 1.4 ns, while retained heap increased by 32.5 KiB.
We retained objects because these gains did not establish an overall startup improvement and would require changing shared runtime and adapter interfaces. The static-route Map replaces a linear regex scan, where the benefit is much larger.
Validation
Measurements compare baseline
6ab6559ccec2f905c693bedeedd5734287946f1bwith candidate1b5ba4384f06a5df0d0c0092692d61fe13e5f882and cover static React Pages output with a shared layout. Dynamic-route-heavy applications and other engines may produce different results.