Skip to content

refactor(vite): lazy per-router chunk carries only derived match data + rule #1 bundle guard - #751

Merged
ivogt merged 1 commit into
mainfrom
refactor/lazy-manifest-data-only
Jul 10, 2026
Merged

ivogt merged 1 commit into
mainfrom
refactor/lazy-manifest-data-only

Conversation

@ivogt

@ivogt ivogt commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Problem statement

The lazy per-router virtual module (virtual:rsc-router/routes-manifest/<routerId>) re-exported the route name->path map even though the eager manifest module already sets the identical map via setRouterManifest(). On first request, ensureRouterManifest() overwrote the eagerly-set map with the lazy copy — two write paths into perRouterManifestMap where last-writer-wins would silently mask any future divergence between them. For routers without a gen file, the map was genuinely inlined twice (once in the eager module, once in the lazy chunk) — duplicated route data in two chunks, the exact shape Bundle Hygiene rule #1 exists to prevent, just below the size where anyone noticed. Separately, rule #1 (route data in exactly one lazy chunk) had no build-time guard at all — only the dev-React check existed in build-test-app.setup.ts.

No consumer-visible change: public API, generated .gen.ts files, and runtime behavior are identical; reverse()/href() resolve from the same eagerly-set map as before.

Before vs. after (generated lazy per-router module)

Before:

import { NamedRoutes as _r } from "/app/src/router.named-routes.gen.js";
function __flat(r) { /* O(routes) flatten, second run of the same transform */ }
export const manifest = __flat(_r);
export const trie = JSON.parse("…");
export const precomputedEntries = JSON.parse("…");

After:

import "/app/src/router.named-routes.gen.js"; // module-graph edge for dev invalidation only
export const trie = JSON.parse("…");
export const precomputedEntries = JSON.parse("…");

The bare import is load-bearing: it is the edge that invalidates the per-router module when routes change in dev. Without it, a Cloudflare dev program reload re-imports the cached module and ensureRouterManifest() re-installs a stale authoritative trie (wrong 404s until full restart). Pinned by a codegen unit test.

Semantics

Case Before After
Name->path map source Eager setRouterManifest(), then overwritten by lazy mod.manifest on first request Eager setRouterManifest() only; loader manifest field ignored
Gen-file router lazy chunk map re-export + __flat + trie/entries trie/entries + bare gen-file import
Router without gen file map inlined twice (eager + lazy chunks) map inlined once (eager only)
ensureRouterManifest early return manifestMap.has && trieMap.has trieMap.has (trie is the only lazy-supplied data)
Dev HMR invalidation of lazy module via named gen-file import edge via bare gen-file import edge (same mechanism)
First-request cost per router second __flat() pass + duplicate map allocation + GC of replaced copy none

Code-change summary

  • src/vite/discovery/virtual-module-codegen.ts — generatePerRouterModule drops the manifest export in both branches; keeps a bare side-effect gen-file import with a comment explaining why it must stay.
  • src/route-map-builder.ts — ensureRouterManifest no longer reads mod.manifest; early return gates on the trie only. Eager coverage is 1:1 with loader registration (both emitted from the same loop, discover-routers.ts:239/:267).
  • e2e/build-test-app.setup.ts — new bundle guard: serialized route data (detected as a JSON.parse string literal >= 8 KB; measured 40 KB trie vs 1.7 KB next-largest) must land in exactly one RSC chunk, zero client/SSR chunks, and be referenced only via dynamic import() (forbidden static-import shape matched directly so bundler formatting changes false-negative rather than failing CI). Shared collectJsFiles walker feeds both this and the existing dev-React guard.
  • src/build/__tests__/per-router-manifest.test.ts, src/vite/discovery/__tests__/virtual-module-codegen.test.ts — tests updated/added to pin the new contract, including: a stray manifest field on a lazy module must not clobber the eagerly-set map.
  • docs/manifests.md — documents the data-only lazy chunk and the bare-import invalidation edge.

Test plan

  • pnpm run typecheck
  • pnpm run test:unit:all — includes new/updated pins: virtual-module-codegen.test.ts "carries only derived match data" (module shape + bare import), per-router-manifest.test.ts "stray manifest field must not clobber the eagerly-set map"
  • pnpm run lint, pnpm run format
  • Bundle guard red-proven: planted duplicate data chunk fails the exactly-one assertion; planted static/bare/export-from imports of the data chunk fail the lazy-only matcher; real dynamic wiring and import.meta do not false-positive
  • build-test-app setup (guard) green; test-app rebuilt — lazy chunk starts at var trie = JSON.parse(, single dynamic reference from entry
  • route-types-hmr --project=hmr-routes: 17 passed, 1 flaky (flaky on base af355625 too)
  • Cloudflare HMR suites (hmr.test.ts, hmr-burst-save): pass
  • cloudflare-basic production e2e: 377 passed
  • Multi-router example rebuilt: 4 eager setRouterManifest + 4 lazy loaders, each per-router chunk data-only

Notes

  • Pre-existing failure (not this PR): tests/cloudflare-basic/e2e/hmr-route-mutations.test.ts "converges the open page to the catch-all when the viewed route is removed" fails persistently and identically on base af355625 (verified by stash + rebuild + rerun); its serial mode blocks 7 later tests in that file.
  • /simplify protocol ran (4 agents). Applied: shared walker in the setup test, collect-then-assert + formatting-resilient static-import matcher for the lazy-only guard, trie-only early return, sourceFile binding. Skipped: "perRouterManifestDataMap values are write-only" (false positive — propagateDiscoveryState consumes the values, router-discovery.ts:975); relocating the guard into the plugin's generateBundle (out of scope; repo convention keeps bundle guards in the setup test — noted as a possible future deepening since chunk.imports/dynamicImports would remove the size heuristic entirely).
  • The guard covers test-app (node preset, inline literal channel). The Cloudflare Text-module channel (RANGO_MANIFEST_TEXT) has no JSON.parse literal and is not covered by this guard; it remains covered by codegen unit tests.

The lazy virtual:rsc-router/routes-manifest/<routerId> module re-exported
the name->path map (__flat(NamedRoutes), or a second inlined JSON.parse
copy for routers without a gen file) even though the eager manifest module
already sets the identical map via setRouterManifest(). ensureRouterManifest
then overwrote the eagerly-set map with the lazy copy, so a divergence
between the two paths would have been silently masked by last-writer-wins.

The lazy module now carries only the derived match data (trie +
precomputedEntries); a bare side-effect import of the gen file is kept as
the module-graph edge that invalidates the module on route changes in dev.
ensureRouterManifest ignores any manifest field on the loaded module and
gates purely on the trie.

Also adds a bundle guard to build-test-app.setup.ts pinning Bundle Hygiene
rule #1: serialized route data lands in exactly one RSC chunk, never in
client/ssr, and is referenced only via dynamic import().
@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@751

commit: cfd5c7b

@ivogt
ivogt merged commit 54a5f3a into main Jul 10, 2026
34 checks passed
@ivogt
ivogt deleted the refactor/lazy-manifest-data-only branch July 10, 2026 16:08
ivogt added a commit that referenced this pull request Jul 10, 2026
…nks, prerender/shell/static discipline (#754)

Closes the guard gaps the route-data audit found. Everything asserted here
is currently true in real builds but had no automated enforcement:

- cloudflare-basic manifest-text-module.test.ts: the workerd Text-module
  channel (the largest generated artifact, on the preset where cold-start
  matters most) now pins exactly one staged .txt per router, no dual
  channel (route data must not ALSO ship as an inline JSON.parse literal),
  and no leak of the module or its payload bytes into the client bundle.
- examples/cloudflare-multi-router e2e/bundle-guard.test.ts (new): parses
  the registerRouterManifestLoader wiring out of the built RSC entry (the
  exact routerId -> chunk mapping the runtime uses) and asserts one lazy
  chunk per router, pairwise-distinct content, dynamic-import-only
  references, and a clean client bundle. Multi-router is where the #751
  regression class compounds and nothing inspected its dist before.
- build-test-app.setup.ts: prerender/shell/static artifact discipline —
  __pr-*/__ps-*/__st-* payload chunks are RSC-only and never statically
  imported; __static-manifest.js (the one eagerly-imported generated
  artifact, required before static-store.ts evaluates) stays a thunk-only
  lookup table with a size tripwire for O(#Static handlers) eager growth.

All detectors red-proven against planted regressions (payload copied into
ssr, payload marker in the static manifest, planted inline dual-channel
literal, duplicated chunk content). Guards are production-only by nature
(build artifacts; dev has no dist), following the manifest-text-module
precedent.
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.

1 participant