Repository navigation
fix(router): dev/prod matching stability + lazy-include fixes - #541
Merged
Merged
Conversation
Extreme review of the render pipeline, route matching, and lazy include discovery to prove dev/production matching is stable for v1. Findings are tracked in docs/internal/matching-stability-review.md. Bug fixes (each with a regression unit test): - Trie now matches a bare wildcard prefix: `/files` against `/files/*` resolves with `*=""` instead of missing the trie and triggering the regex fallback's corrupt `/files` -> `/file` redirect (also `/` against `/*`). - Nested include() under a trailing-slash parent prefix no longer produces a double-slash staticPrefix (`/parent//child`) that the trie sp can never match and that silently forced the regex fallback. Joins now route through a shared joinPrefix() that collapses the boundary slash like include()/ runWithPrefixes. - createFindMatch's single-entry cache returns a clone with an independent params object on a hit, so a handler mutating ctx.params cannot corrupt the module-lifetime cache shared across same-pathname requests. Refactors / hardening: - Dev (runtime rebuild) and production (build-time serialize) now construct the per-router trie through one shared buildPerRouterTrie(manifest); the dev inline visitPrefixNode copy is removed. This structurally eliminates the dev/prod trie-drift hazard. Pure prefix-tree walks moved to build/prefix-tree-utils.ts (re-exported from vite/utils/manifest-utils.ts so vite-side imports are unchanged). - Phase-2 regex fallback emits a dev-only warning when it resolves a real match while the trie was present (signals a trie gap; the trie is the single source of truth and is built before findMatch in both modes). - Removed the dead `ancestry` field from TrieMatchResult/RouteMatchResult and the find-match remap (kept leaf.a + extractAncestryFromTrie for the __debug_manifest endpoint); removed an unused getRouteTrie import. New tests: - dev-prod-trie-parity.test.ts: the dev rebuilt trie equals the prod serialized trie (multi-router differs only in debug-only leaf.a). - trie-regex-parity.test.ts: the two matchers agree on the stable surface; known intentional divergences documented as trie-canonical. - find-match.test.ts: createFindMatch cache clone, recompute, and the MAX_LAZY_ITERATIONS cap. Verified: typecheck, 3367 unit tests, oxlint, oxfmt, build-router, and the route-resolution e2e in both dev (18) and production (10).
commit: |
ivogt
force-pushed
the
fix/router-matching-dev-prod-stability
branch
2 times, most recently
from
June 7, 2026 18:43
e26faab to
e1be09a
Compare
ivogt
force-pushed
the
fix/router-matching-dev-prod-stability
branch
from
June 7, 2026 19:05
e1be09a to
6070921
Compare
… dev leaf includes
Addresses review findings on the lazy-include work. See
docs/internal/matching-stability-review.md ("Lazy include() performance audit").
- C5 (completion): joinPrefix now also normalizes the prefix the lazy handler
RUNS under (evaluateLazyEntry + loadManifest), not just the nested-placeholder
staticPrefix. A nested include under a trailing-slash parent — include("/parent/",
...) containing include("/child", ...) — previously registered "/parent//child/leaf"
into entry.routes / reverse() / EntryData.pattern / mountPath because the
handler-run fullPrefix raw-concatenated urlPrefix + includePrefix. Now collapsed.
- LP2 (C8): buildRouterTrieFromUrlpatterns precomputes leaf-include entries via
flattenLeafEntries, so dev/Cloudflare takes the same match-time shortcut as
production (no re-running the leaf include handler at match time).
- LP1 (reverted): an earlier attempt keyed manifestModuleCache by handler identity
(dropping routeKey) to run an include's handler once instead of once per route.
That is a REGRESSION: loadManifest builds a manifest pruned to forRoute=routeKey
(path-helper.ts skips sibling routes), so a sibling request misses and overwrites
the pruned entry — alternating siblings thrash. Restored the per-routeKey key
(known-good; M runs amortized). Running the handler once per include needs an
unpruned-cache + prune-on-read design — deferred, tracked as it.todo (LP1).
- Audit + tests: lazy-include-perf.test.ts instruments handler executions across
the lifecycle — proves include() defers (unmatched includes never run), documents
the match-vs-render runs and the precomputed shortcut, and pins C8. Verdict:
lazy-by-default is correct; warm requests run zero handlers. LP1/LP3/LP4 are
deferred it.todo markers; the Part B run-count is an LP3 sentinel.
- Docs: sync docs/README.md, feature-map.md (buildPerRouterTrie export), and
feature-file-map.md (build/prefix-tree-utils.ts, build/route-trie.ts) for the
added doc/file/export.
Verified: typecheck, 3373 unit tests (+6 todo), oxlint, oxfmt, and the navigation +
route-resolution e2e suites in both dev and production.
ivogt
force-pushed
the
fix/router-matching-dev-prod-stability
branch
from
June 7, 2026 19:44
6070921 to
a619d37
Compare
3 tasks done
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.
Problem
An "extreme" review of the render pipeline, route matching, and lazy
include()discovery — aimed at proving dev/production matching is stable for v1 — surfaced one consumer-observable matching bug, an incomplete nested-include prefix fix, a structural dev/prod drift hazard, and a set of cold-start lazy-include redundancies (one fixed, the rest deferred).The headline consumer bug: a catch-all route
path("/files/*")with no/filesindex 308-redirected/filesto the wrong path/file. The trie matcher never matched a wildcard at its own bare prefix, so matching fell through to the regex fallback, whose no-config trailing-slash branch computed"/files".slice(0, -1)→/file. Identical in dev and production.Two trie-construction code paths (dev rebuilds at request time; prod serializes at build time) were kept in sync by hand — a latent dev/prod divergence with no test pinning their equality. And a lazy-include performance audit confirmed lazy-by-default is the correct design (unmatched includes never run; warm requests run zero handlers), but found dev/Cloudflare lacked the match-time precomputed shortcut production already had.
What a consumer sees differently
Wildcard bare-prefix (the redirect bug)
/files/file(broken)files,params["*"] === ""/files/params["*"] === "")/files/a/b* === "a/b")* === "a/b") — unchanged/vs/** === ""A static index still wins over the wildcard at the same prefix (
/files→files.indexif defined).Nested include under a trailing-slash prefix
Before, the nested entry got
staticPrefix === "/parent//child"(double slash), which the trie'ssp(/parent/child) could never match — silently forcing every request to that subtree onto the regex fallback. After, the join is normalized (matchinginclude()/runWithPrefixes) so/parent/child/leafmatches via the trie like every other route.Cold-start performance (no behavior change)
Dev/Cloudflare leaf includes now take the same match-time precomputed shortcut production already had (first-request handler runs 2 → 1). Warm requests run zero handlers in both modes. (Reducing the remaining once-per-sibling-route handler runs is deferred — see Notes/LP1.)
Matching semantics (trie is canonical; the regex path is a should-not-happen fallback)
/filesvs/files/*, no index)* === "", no redirect/users/5vs/users/:id/*, no/users/:idindex){ id, *: "" }(zero-or-more splat); a static/param terminal at that prefix wins if presenttsmodealways/never(per-path or per-include)tsmodeignoretsmode, alternate slashconsole.warn(trie-gap signal); folded out in prodCode changes
Two commits:
fix(router): stabilize dev/prod matching + unify trie constructiontrie-matching.ts—walkTrie/tryTrieMatchmatch a wildcard at the bare prefix with* === ""(C1).pattern-matching.ts— new sharedjoinPrefix(); remove deadancestryfield.lazy-includes.ts,router.ts— nested-include joins route throughjoinPrefix(C5).find-match.ts— cache hit returns a clone with independentparams(C7); dev-only regex-fallback warning (R3); drop unused import (R5); remove deadancestry(R4).build/route-trie.ts+ newbuild/prefix-tree-utils.ts— one sharedbuildPerRouterTrie(manifest)used by both dev (manifest-init.ts) and prod (discover-routers.ts); pure prefix-tree walks moved out ofvite/so runtime can import them (R1/R2). Removes the dev/prod drift hazard.fix(router): complete nested-include prefix normalization; precompute dev leaf includeslazy-includes.ts+manifest.ts—joinPrefixnow also normalizes the prefix the lazy handler runs under (not just the nested-placeholderstaticPrefix), so a nested include under a trailing-slash parent registers normalized patterns intoentry.routes/reverse()/EntryData.pattern/mountPath(completes C5).manifest-init.ts— dev precomputes leaf-include entries viaflattenLeafEntries, so dev takes the production match-time shortcut (LP2/C8).manifest.ts— the manifest cache stays keyed byrouteKey. An attempted handler-identity key was reverted:loadManifestbuilds a manifest pruned toforRoute=routeKey(path-helper.tsskips siblings), so droppingrouteKeymakes alternating siblings thrash (LP1 — see Notes).README.md,feature-map.md(buildPerRouterTrie),feature-file-map.md(build/prefix-tree-utils.ts).Findings, evidence, and the deferred items are tracked in
docs/internal/matching-stability-review.md.Test plan
trie-regex-parity.test.ts(the two matchers agree on the stable surface; known divergences documented),dev-prod-trie-parity.test.ts(dev rebuilt trie === prod serialized trie, modulo debug-onlyleaf.a),find-match.test.ts(cache clone, recompute,MAX_LAZY_ITERATIONS),lazy-include-perf.test.ts(instruments handler-exec counts; proves laziness; pins LP2/C8;it.todofor LP1/LP3/LP4). Regressions: C1 (trie), C5 (nested-includestaticPrefixand registered route pattern), C7 (cache clone).route-resolution(dev 18 / prod 10) andnavigationincl. semantic-matrix, intercepts, actions, use-cache (dev 175 / prod 97).build-router.Notes / call-outs
path(..., { trailingSlash })and per-includeinclude(..., { trailingSlash }), withnever/always/ignore. The trie honors the explicit modes and thetrailing-slash-confige2e proves they behave identically in dev and production. The only nuance (M3 in the doc): the trie keeps the explicit mode but not the pattern-implicit slash (path("/foo/")), so with no explicit mode the never-reached regex fallback would canonicalize while the trie serves both. This is consistent across dev/prod (the trie is the live matcher) and configurable, so it's a documented non-issue, not a v1 decision — adding pattern-implicit canonicalization to the trie is optional future polish.it.todo+ a sentinel test): LP1 — an include with M routes runs its handler once per route on cold start (loadManifestprunes toforRoute, cache keyed byrouteKey); running it once per include needs an unpruned-cache + prune-on-read design (a handler-identity key thrashes — verified, reverted). LP3 — a nested / precomputed-miss include runs its handler twice on the first request (match + render); the match-time build is discarded. LP4 — a cold document request runs each handler twice across theisSSR=false/trueresolves (droppingisSSRis unsafe — it changes the tree). All are cold-start-only and amortized (each cached after first); a timing benchmark is the suggested next step before deciding if any is worth the needs-design change.mountIndexdifference only feeds the debug-onlyleaf.aancestry (never read in the match/render path), so it is not a correctness bug — captured in the doc.