Skip to content

fix(router): drop shared-staticPrefix precomputed shortcut (C19) + R3 warn + e2e parity - #544

Merged
ivogt merged 1 commit into
mainfrom
fix/router-precomputed-prefix-collision
Jun 8, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/router-precomputed-prefix-collision

Conversation

@ivogt

@ivogt ivogt commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

If two include()s resolve to the same static prefix — which happens whenever a dynamic param eats their literal prefix (e.g. /shop/:category and /shop/:brand both reduce to "/shop") — and at least one of them is discovered lazily (nested inside another include), a request to one of those routes returns an opaque 500 (RouteNotFoundError) on a route that genuinely exists. The failure is identical in dev and production and is permanent for the isolate once triggered. From the consumer's seat: a valid URL that should render simply 500s, with no hint that two includes "collided."

Mechanically: the precomputed match shortcut was built as new Map(entries.map(e => [e.staticPrefix, e.routes])). A JS Map is last-wins on duplicate keys, so one include's routes were silently dropped and the survivor's routes were mis-assigned to whichever lazy entry evaluated first. The existing prefixIsShared guard counts only already-spliced route entries, so it is blind to a nested sibling that hasn't been discovered yet — leaving a window where the shortcut fires with the wrong routes. findMatch then selects the wrong include's entry (its corrupted routes contain the sibling's key), loadManifest runs the wrong handler, and the Store.manifest.has(routeKey) invariant fails.

This branch had zero coverage — every test passed getPrecomputedByPrefix: () => null, so the collapse path was never exercised.

Before vs. after (consumer)

// urls.ts (root)
export default urls(({ include }) => [
  include("/shop/:category", () => import("./category")), // staticPrefix "/shop"
  include("", () => import("./storefront")),              // root-scoped feature
]);

// storefront.ts — nested include, discovered lazily on first hit
export default urls(({ include }) => [
  include("/shop/:brand", () => import("./brand")),       // ALSO staticPrefix "/shop"
]);

// category.ts →  path("/page", CategoryPage, { name: "page" })  →  /shop/:category/page
// brand.ts    →  path("/list", BrandList,    { name: "list" })  →  /shop/:brand/list
Request (a valid route) Before After
GET /shop/nike/list (cold isolate, brand owner not yet spliced) 500 RouteNotFoundError 200, renders BrandList
GET /shop/cars/page after the category entry was poisoned wrong-handler 500 / corrupted entry 200, renders CategoryPage
Two includes with distinct static prefixes ✅ shortcut ✅ shortcut (unchanged)
Two includes sharing a prefix, both live up front ✅ (prefixIsShared catches it) ✅ (now omitted upstream)

What a consumer does differently

Nothing. This is a pure correctness fix — no API change, no migration. Consumers who never hit a shared-static-prefix + lazy-nested combination are unaffected; consumers who did were getting a 500 and now get the correct route.

Why this fix shape

Two distinct includes under one static prefix cannot be collapsed to a single routes object (last-wins drops data) and must not be merged (assigning a merged set to the first matching entry makes findMatch pick the wrong handler — the exact failure here). So buildPrecomputedByPrefix omits any static prefix owned by more than one leaf include from the shortcut. Those includes fall through to the handler path — the ground truth, identical to behavior before the shortcut existed.

Because the shortcut is purely an optimization, dropping a prefix can only ever cost a handler run; it can never change a result. The fix lives at the single point where the array becomes a Map (router.ts); the precomputed array shape (asserted by per-router-manifest.test.ts) is untouched.

- precomputedByPrefix = current
-   ? new Map(current.map((e) => [e.staticPrefix, e.routes]))
-   : null;
+ // shared staticPrefix -> omitted (resolve via handler path) instead of last-wins collapse
+ precomputedByPrefix = current ? buildPrecomputedByPrefix(current) : null;

The timing-blind prefixIsShared check in lazy-includes.ts is kept as defense-in-depth, with a comment recording that the load-bearing protection now lives upstream.

Bonus: R3 dev-warning false positive

The dev-only "route resolved via the regex fallback even though the trie was present — please report this" warning fired on the first request to a 2+-level nested lazy include (the trie matched, but its entry hadn't been spliced yet, so resolution fell to the regex path). That is the supported lazy-splicing flow, not a trie gap. The warning is now suppressed when the trie did match (trieMatched); genuine trie gaps still warn. This stops the router from manufacturing false bug reports.

Why the C19 repro is an integration test, not a live HTTP e2e

The corruption only fires when the top-level entry is evaluated while its nested sibling is not yet spliced — i.e. a /shop request must be the very first handler-triggering request to a cold isolate, before any sp:"" request splices the sibling. A shared Playwright worker/dev-server isolate (plus build-time route discovery, which also evaluates handlers) cannot guarantee that ordering, so an HTTP e2e would pass regardless of the fix — false assurance.

Instead, the repro drives the real createFindMatch → evaluateLazyEntry → loadManifest chain in the exact evaluate-alone → splice → request sequence. Verified by toggling the fix: old collapse routes the brand request to the wrong include and loadManifest throws (the 500); the fix routes it to the right include and it renders.

Code-change summary

  • src/build/prefix-tree-utils.ts — new pure buildPrecomputedByPrefix(entries); omits shared static prefixes.
  • src/router.ts — getPrecomputedByPrefix uses it instead of the last-wins Map.
  • src/router/lazy-includes.ts — prefixIsShared comment updated (protection moved upstream; live-count is timing-blind defense-in-depth).
  • src/router/find-match.ts — track trieMatched; suppress the R3 warning on lazy-splicing lag.
  • docs/internal/matching-stability-review.md — Pass 2 section documenting C19.

Test plan

  • Unit — precomputed-prefix-collision.test.ts (new): real config emits duplicate-staticPrefix leaves; naive collapse loses routes vs. buildPrecomputedByPrefix omits the shared prefix and keeps unshared ones; evaluateLazyEntry no longer mis-assigns; request-path integration test (real findMatch → loadManifest, old collapse 500s, fix renders).
  • Unit — find-match.test.ts: R3 suppress-on-lazy-lag and warn-on-genuine-gap.
  • E2e (dev + production) — cloudflare-basic/e2e/trie-routing.test.ts converted to a dev + (production) factory; new /files/* route + C1 bare-prefix empty-splat assertion (/files → "", /files/a/b → a/b) verified in both modes. router.named-routes.gen.ts updated for filesWildcard.
  • typecheck clean · unit 3383 pass / 6 todo · lint clean · format clean · e2e-bucketing guard OK.

Notes

  • No deprecation / no migration — pure correctness fix; existing behavior for distinct-prefix and all-live-sibling cases is unchanged.
  • The deferred lazy-include perf items (LP1/LP3/LP4) were independently re-audited during this work and confirmed correctness-neutral (run-count only); they remain tracked as it.todo + the matching-stability doc. This PR does not touch them.

… warn + e2e parity

C19 — 500 on a valid route when two leaf includes share a staticPrefix.

Two distinct leaf includes legitimately collapse to the same staticPrefix when a
dynamic param eats their literal prefix (e.g. include("/shop/:category", ...) and
a nested include("/shop/:brand", ...) both extract "/shop"). The runtime built its
precomputed shortcut as `new Map(entries.map(e => [e.staticPrefix, e.routes]))` —
last-wins — so one include's routes were dropped and the survivor's routes were
mis-assigned to whichever lazy entry evaluated first. The prefixIsShared guard did
not save it: it counts only already-spliced routesEntries, so it is blind to a
nested sibling not yet discovered. Net effect: findMatch selects the wrong include's
entry (its corrupted routes contain the sibling's key), loadManifest runs the wrong
handler, the Store.manifest.has(routeKey) invariant fails -> RouteNotFoundError (500)
on a route that genuinely exists. Identical in dev and production. The branch had no
coverage (every test passed getPrecomputedByPrefix: () => null).

Fix: buildPrecomputedByPrefix omits any staticPrefix owned by more than one leaf
include from the shortcut (collapsing is lossy, merging picks the wrong handler), so
those includes resolve via the handler path — ground truth, identical to behavior
before the shortcut existed. Dropping a prefix can only cost a handler run, never
change a result. The prefixIsShared comment now records that the load-bearing
protection moved upstream and the live-count check is timing-blind defense-in-depth.

R3 — suppress the dev "regex fallback resolved while the trie was present" warning
when the trie DID match but its entry was not resolvable yet (first request to a
2+-level nested lazy include). That is the supported lazy-splicing flow, not a trie
gap, so warning on it manufactured false bug reports.

Tests:
- precomputed-prefix-collision.test.ts: real config emits duplicate-staticPrefix
  leaves; naive collapse loses routes while buildPrecomputedByPrefix omits the shared
  prefix; evaluateLazyEntry no longer mis-assigns; and a deterministic request-path
  integration test drives the real findMatch -> loadManifest chain in the
  evaluate-alone -> splice -> request sequence (old collapse 500s, fix renders). A
  live HTTP e2e cannot reliably reproduce this (the corruption needs a cold-isolate
  first-request ordering that a shared worker / build-time discovery cannot force).
- find-match.test.ts: R3 suppress-on-lazy-lag and warn-on-genuine-gap cases.

E2e bucketing (dev+prod mandate):
- cloudflare-basic trie-routing.test.ts converted to a dev + (production) factory.
- New "/files/*" route + C1 bare-prefix empty-splat assertion (/files -> "", verified
  in both dev and production).
- router.named-routes.gen.ts updated for the new filesWildcard route.

docs/internal/matching-stability-review.md: Pass 2 section documenting C19.
@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ada23d9

@ivogt
ivogt merged commit e138987 into main Jun 8, 2026
25 checks passed
@ivogt
ivogt deleted the fix/router-precomputed-prefix-collision branch June 8, 2026 06:21
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