Skip to content

perf(pages): emit a compact route manifest - #534

Merged
Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
yangshun:perf/compact-pages-manifest
Oct 22, 2026
Merged

Brooooooklyn merged 2 commits into
voidzero-dev:mainfrom
yangshun:perf/compact-pages-manifest

Conversation

@yangshun

@yangshun yangshun commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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:

const components = {
  about: loadAbout,
  contact: loadContact,
  layout: loadLayout,
};

const layoutTree = {
  about: ["layout"],
  contact: ["layout"],
};

const routes = [
  { pattern: /^\/about$/, id: "about" },
  { pattern: /^\/contact$/, id: "contact" },
];

After:

const layoutChains = [["layout"]];
const rows = [
  ["about", loadAbout, 0],
  ["contact", loadContact, 0],
];

const components = {};
const layoutTree = {};
const staticRouteIds = new Map();

for (const [id, load, chain, path = "/" + id] of rows) {
  components[id] = load;
  if (chain >= 0) layoutTree[id] = layoutChains[chain];
  if (!staticRouteIds.has(path)) staticRouteIds.set(path, id);
}

components.layout = loadLayout;

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 void package changes between variants; the adapter and external dependencies are identical. Sizes measure the emitted Pages client entry.

Generated pages Gzip level 6, before → after Brotli quality 11, before → after
1 68,726 → 68,776 B (+0.07%) 59,489 → 59,516 B (+0.05%)
100 72,725 → 72,021 B (−0.97%) 61,356 → 61,038 B (−0.52%)
1,000 107,695 → 98,216 B (−8.80%) 76,396 → 72,989 B (−4.46%)
5,000 249,580 → 206,988 B (−17.07%) 142,147 → 124,254 B (−12.59%)

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:

Metric Before After Change
Cold manifest parse + initialization 15.784 ms 9.659 ms −38.8%
Module initialization alone 2.529 ms 4.510 ms +78.3%
Retained heap after initialization 3.03 MiB 2.66 MiB −12.4%
Warmed last-page route lookup 153.734 µs 0.291 µs −99.8%

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:

  • Chrome 155 on Apple M5 with 70% idle CPU host
  • 16 randomized pairs per page count
  • Cold timing uses an isolated function wrapper; initialization and heap measurements use real ES modules, with heap sampled after forced garbage collection.

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.
Metric at 5,000 generated pages Objects Maps Change
Module initialization 4.590 ms 4.364 ms −4.9%
Cold parse + initialization 10.325 ms 10.267 ms −0.6%
Retained heap after initialization 2.66 MiB 2.69 MiB +1.2%
Component + layout lookup 10.83 ns 9.44 ns −12.9%

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

  • 15 focused tests across the core and four framework adapters passed.
  • Core and installed fixture typechecks passed.
  • Ten production SSR builds passed, including identical repeated build fingerprints.
  • Browser hydration, counter interaction, and client navigation passed.
  • All 128 samples in each runtime experiment passed their route and lookup checks.
  • Benchmark tests, checks, formatting, and lint passed.

Measurements compare baseline 6ab6559ccec2f905c693bedeedd5734287946f1b with candidate 1b5ba4384f06a5df0d0c0092692d61fe13e5f882 and cover static React Pages output with a shared layout. Dynamic-route-heavy applications and other engines may produce different results.

yangshun and others added 2 commits October 21, 2026 20:59
## 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>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T03:49:29.253927Z b0dc9e5 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: b0dc9e54ec

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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
Brooooooklyn merged commit 9da69ac into voidzero-dev:main Oct 22, 2026
18 checks passed
@yangshun
yangshun deleted the perf/compact-pages-manifest branch October 22, 2026 03:53
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants