Skip to content

fix(router): dev/prod matching stability + lazy-include fixes - #541

Merged
ivogt merged 2 commits into
mainfrom
fix/router-matching-dev-prod-stability
Jun 7, 2026
Merged

ivogt merged 2 commits into
mainfrom
fix/router-matching-dev-prod-stability

Conversation

@ivogt

@ivogt ivogt commented Jun 7, 2026 •

Copy link
Copy Markdown
Collaborator

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 /files index 308-redirected /files to 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)

export const urlpatterns = urls(({ path }) => [
  path("/files/*", FilesPage, { name: "files" }), // no "/files" index route
]);
Request Before After
/files 308 → /file (broken) matches files, params["*"] === ""
/files/ served served (params["*"] === "")
/files/a/b served (* === "a/b") served (* === "a/b") — unchanged
/ vs /* trie missed → fallback matches the root wildcard, * === ""

A static index still wins over the wildcard at the same prefix (/files → files.index if defined).

Nested include under a trailing-slash prefix

urls(({ include }) => [
  include("/parent/", parentPatterns), // trailing slash
]);
// parentPatterns: include("/child", childPatterns) -> path("/leaf", ...)

Before, the nested entry got staticPrefix === "/parent//child" (double slash), which the trie's sp (/parent/child) could never match — silently forcing every request to that subtree onto the regex fallback. After, the join is normalized (matching include()/runWithPrefixes) so /parent/child/leaf matches 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)

Case Behavior
Bare wildcard prefix (/files vs /files/*, no index) matches, * === "", no redirect
Param-prefixed wildcard (/users/5 vs /users/:id/*, no /users/:id index) matches with { id, *: "" } (zero-or-more splat); a static/param terminal at that prefix wins if present
Static/param index vs wildcard at same prefix the index terminal wins
ts mode always / never (per-path or per-include) redirect to canonical (unchanged; works identically dev/prod)
ts mode ignore match both, no redirect (unchanged)
no explicit ts mode, alternate slash trie serves both forms (unchanged)
Regex fallback resolves a real match while the trie was present dev-only console.warn (trie-gap signal); folded out in prod

Code changes

Two commits:

fix(router): stabilize dev/prod matching + unify trie construction

  • trie-matching.ts — walkTrie/tryTrieMatch match a wildcard at the bare prefix with * === "" (C1).
  • pattern-matching.ts — new shared joinPrefix(); remove dead ancestry field.
  • lazy-includes.ts, router.ts — nested-include joins route through joinPrefix (C5).
  • find-match.ts — cache hit returns a clone with independent params (C7); dev-only regex-fallback warning (R3); drop unused import (R5); remove dead ancestry (R4).
  • build/route-trie.ts + new build/prefix-tree-utils.ts — one shared buildPerRouterTrie(manifest) used by both dev (manifest-init.ts) and prod (discover-routers.ts); pure prefix-tree walks moved out of vite/ so runtime can import them (R1/R2). Removes the dev/prod drift hazard.

fix(router): complete nested-include prefix normalization; precompute dev leaf includes

  • lazy-includes.ts + manifest.ts — joinPrefix now also normalizes the prefix the lazy handler runs under (not just the nested-placeholder staticPrefix), so a nested include under a trailing-slash parent registers normalized patterns into entry.routes/reverse()/EntryData.pattern/mountPath (completes C5).
  • manifest-init.ts — dev precomputes leaf-include entries via flattenLeafEntries, so dev takes the production match-time shortcut (LP2/C8).
  • manifest.ts — the manifest cache stays keyed by routeKey. An attempted handler-identity key was reverted: loadManifest builds a manifest pruned to forRoute=routeKey (path-helper.ts skips siblings), so dropping routeKey makes alternating siblings thrash (LP1 — see Notes).
  • docs — sync 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

  • Unit — 3373 pass + 6 todo (184 files). New: 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-only leaf.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.todo for LP1/LP3/LP4). Regressions: C1 (trie), C5 (nested-include staticPrefix and registered route pattern), C7 (cache clone).
  • e2e dev + production — route-resolution (dev 18 / prod 10) and navigation incl. semantic-matrix, intercepts, actions, use-cache (dev 175 / prod 97).
  • typecheck, oxlint (0/0), oxfmt, build-router.

Notes / call-outs

  • Trailing slash is already complete and configurable — per-path path(..., { trailingSlash }) and per-include include(..., { trailingSlash }), with never/always/ignore. The trie honors the explicit modes and the trailing-slash-config e2e 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.
  • Deferred future work (tracked as it.todo + a sentinel test): LP1 — an include with M routes runs its handler once per route on cold start (loadManifest prunes to forRoute, cache keyed by routeKey); 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 the isSSR=false/true resolves (dropping isSSR is 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.
  • Multi-router note (M2): the build-vs-runtime mountIndex difference only feeds the debug-only leaf.a ancestry (never read in the match/render path), so it is not a correctness bug — captured in the doc.

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).
@pkg-pr-new

pkg-pr-new Bot commented Jun 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a619d37

@ivogt
ivogt force-pushed the fix/router-matching-dev-prod-stability branch 2 times, most recently from e26faab to e1be09a Compare June 7, 2026 18:43
@ivogt ivogt changed the title fix(router): dev/prod matching stability + lazy-include cold-start perf fix(router): dev/prod matching stability + lazy-include fixes Jun 7, 2026
@ivogt
ivogt force-pushed the fix/router-matching-dev-prod-stability branch from e1be09a to 6070921 Compare June 7, 2026 19:05
… 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
ivogt force-pushed the fix/router-matching-dev-prod-stability branch from 6070921 to a619d37 Compare June 7, 2026 19:44
@ivogt
ivogt merged commit 96cdad3 into main Jun 7, 2026
25 checks passed
@ivogt
ivogt deleted the fix/router-matching-dev-prod-stability branch June 7, 2026 19:53
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