Skip to content

fix(vite): register per-router manifest loaders in build only — CF dev stale authoritative trie - #752

Merged
ivogt merged 1 commit into
mainfrom
fix/cf-dev-stale-authoritative-trie
Jul 10, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/cf-dev-stale-authoritative-trie

Conversation

@ivogt

@ivogt ivogt commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Problem statement

In Cloudflare dev, removing a route while the browser had it open could leave the page permanently rendering the removed route: the workerd program reload triggered a browser full-reload, but the reloaded worker served the removed route from a stale route trie marked authoritative, so the page never converged to the catch-all — and because the trie was authoritative, misses on other routes were hard 404s instead of falling back to live matching. This is the persistently-failing e2e hmr-route-mutations › converges the open page to the catch-all when the viewed route is removed (red on every run of current main), whose serial failure also blocked the 7 tests after it in that file.

Root cause: the eager routes-manifest module registered lazy per-router manifest loaders in dev exactly as in a production build. ensureRouterManifest() runs before the handler's buildRouterTrieFromUrlpatterns fallback (router.ts:1101 vs rsc/handler.ts:513), so after a program reload it imported the cached per-router virtual module — whose only invalidation edge (the gen-file import) lags an urls.tsx edit by one reload cycle — and installed its stale trie, marking it authoritative (route-map-builder.ts, loader path). That violated the documented invariant that dev tries are never authoritative and preempted the always-correct live-urlpatterns fallback the handler comment promises. The fetch-polling sibling test masked the window; a browser's single full reload landing inside it did not.

Consumer-visible effect: CF dev route removal/rename now converges reliably; no production behavior changes.

Before vs. after (generated eager routes-manifest module, dev)

Before (dev and build identical):

import { setCachedManifest, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";
clearAllRouterData();
setCachedManifest(...); setRouterManifest("id", ...);
registerRouterManifestLoader("id", () => import("virtual:rsc-router/routes-manifest/id")); // dev: can install a stale authoritative trie

After (dev):

import { setCachedManifest, setRouterManifest, clearAllRouterData } from "@rangojs/router/server";
clearAllRouterData();
setCachedManifest(...); setRouterManifest("id", ...);
// no loader: first request rebuilds the trie from live router.urlpatterns
// (buildRouterTrieFromUrlpatterns, non-authoritative)

Build output is unchanged (loader still registered; trie still authoritative — correct for a trie serialized from complete discovery).

Semantics

Case Before After
Build: per-router trie source lazy loader chunk, authoritative unchanged
CF dev: trie after program reload stale per-router virtual module via loader, authoritative rebuilt from live router.urlpatterns, non-authoritative
CF dev: removed route, page open stale route served; page never converges converges to catch-all
CF dev: trie miss hard 404 (authoritative) regex fallback over live routes
Node dev tries pushed by propagateDiscoveryState; loader raced in the re-eval window and could install an authoritative trie loaders absent; propagate + handler fallback only
Loader registry across clearAllRouterData() survived the clear cleared with the rest of the router data

Code-change summary

  • src/vite/discovery/virtual-module-codegen.ts — registerRouterManifestLoader emission (and its import) gated on state.isBuildMode, with a comment encoding the stale-authoritative-trie scar tissue. The codegen site is the only viable gate: isBuildMode exists only on DiscoveryState, and gating markRouterTrieAuthoritative downstream would still install a stale trie whose hits serve removed routes.
  • src/route-map-builder.ts — clearAllRouterData() also clears routerManifestLoaders (a surviving loader closes over pre-clear data).
  • src/vite/discovery/__tests__/virtual-module-codegen.test.ts — new describe pinning loaders-are-build-only (dev emits no loader but keeps the eager name→path wiring); defensive-pin annotation on the dev Text-module test.
  • src/build/__tests__/per-router-manifest.test.ts — pins that cleared loaders are never re-invoked.
  • docs/manifests.md — documents the build-only loader contract and the dev rebuild path.

Test plan

  • Red-before-green: converges the open page to the catch-all when the viewed route is removed fails on current main (verified this session), passes with the fix; full hmr-route-mutations.test.ts now 11/11 (previously 3 passed, 1 failed, 7 serially blocked)
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format
  • New unit pins: generateRoutesManifestModule — per-router loaders are build-only (dev/build codegen shape); clearAllRouterData clears registered loaders
  • CF dev e2e: hmr.test.ts + hmr-burst-save pass; dev routing subset (navigation/routing/wildcard/catch) 50 passed
  • Node dev: route-types-hmr --project=hmr-routes 18/18
  • Production unaffected: cloudflare-basic production subset 61 passed; build-test-app bundle guard green (build still registers the loader; data chunk still lazy-only)

Notes

  • Residual flake, tracked separately: under --repeat-each=3 stress one iteration can still fail with the same visible symptom via a different mechanism — the browser's full reload being served by the old worker before the program-reload swap (vite/workerd reload sequencing, orthogonal to the trie). On main the test failed every run; post-fix it only reproduces under back-to-back mutate/restore stress.
  • Dev cost: workerd rebuilds the trie from live urlpatterns once per program reload (this was always the designed fallback; node dev still receives tries via propagateDiscoveryState, no double build).
  • /simplify protocol ran (reuse/altitude/simplification/efficiency): no findings applied beyond a defensive-pin comment; the always-true-in-build isBuildMode conjunct in useTextModule kept deliberately as defense against dev loaders returning.
  • Found by the post-refactor(vite): lazy per-router chunk carries only derived match data + rule #1 bundle guard #751 audit of the route-data singletons; remaining audit items (dead merged trie/precomputedEntries + double __flat, Text-module/multi-router/prerender guards) are queued as follow-ups.

In Cloudflare dev, the eager routes-manifest module registered lazy
per-router manifest loaders just like a production build. After a program
reload, ensureRouterManifest ran before the handler's
buildRouterTrieFromUrlpatterns fallback, imported the cached per-router
virtual module (its only invalidation edge — the gen-file import — lags an
urls.tsx edit by one reload cycle), and installed its STALE trie as
authoritative. A browser full-reload landing in that window rendered a
removed route from the stale trie and never converged; misses were hard
404s instead of falling back to regex over live routes. This violated the
documented invariant that dev tries are never authoritative
(route-map-builder.ts) and preempted the fallback handler.ts promises.

Loaders are now registered in BUILD only; dev deterministically rebuilds
the per-router trie from live router.urlpatterns (fresh, non-authoritative).
clearAllRouterData additionally clears the loader registry so a loader can
never outlive the data it closes over.

Fixes the persistently-failing cloudflare-basic e2e
'converges the open page to the catch-all when the viewed route is
removed' (red on main, green with this change; unblocks the 7 serially
blocked tests in hmr-route-mutations.test.ts).
@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@752

commit: 86190a6

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