Repository navigation
fix(router): drop shared-staticPrefix precomputed shortcut (C19) + R3 warn + e2e parity - #544
Merged
Merged
Conversation
… 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.
commit: |
This was referenced Jun 8, 2026
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
If two
include()s resolve to the same static prefix — which happens whenever a dynamic param eats their literal prefix (e.g./shop/:categoryand/shop/:brandboth 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 opaque500(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 JSMapis 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 existingprefixIsSharedguard 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.findMatchthen selects the wrong include's entry (its corrupted routes contain the sibling's key),loadManifestruns the wrong handler, and theStore.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)
GET /shop/nike/list(cold isolate, brand owner not yet spliced)RouteNotFoundErrorBrandListGET /shop/cars/pageafter the category entry was poisonedCategoryPageprefixIsSharedcatches it)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
routesobject (last-wins drops data) and must not be merged (assigning a merged set to the first matching entry makesfindMatchpick the wrong handler — the exact failure here). SobuildPrecomputedByPrefixomits 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 byper-router-manifest.test.ts) is untouched.The timing-blind
prefixIsSharedcheck inlazy-includes.tsis 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
/shoprequest must be the very first handler-triggering request to a cold isolate, before anysp:""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 → loadManifestchain in the exactevaluate-alone → splice → requestsequence. Verified by toggling the fix: old collapse routes the brand request to the wrong include andloadManifestthrows (the 500); the fix routes it to the right include and it renders.Code-change summary
src/build/prefix-tree-utils.ts— new purebuildPrecomputedByPrefix(entries); omits shared static prefixes.src/router.ts—getPrecomputedByPrefixuses it instead of the last-winsMap.src/router/lazy-includes.ts—prefixIsSharedcomment updated (protection moved upstream; live-count is timing-blind defense-in-depth).src/router/find-match.ts— tracktrieMatched; suppress the R3 warning on lazy-splicing lag.docs/internal/matching-stability-review.md— Pass 2 section documenting C19.Test plan
precomputed-prefix-collision.test.ts(new): real config emits duplicate-staticPrefix leaves; naive collapse loses routes vs.buildPrecomputedByPrefixomits the shared prefix and keeps unshared ones;evaluateLazyEntryno longer mis-assigns; request-path integration test (realfindMatch → loadManifest, old collapse 500s, fix renders).find-match.test.ts: R3 suppress-on-lazy-lag and warn-on-genuine-gap.cloudflare-basic/e2e/trie-routing.test.tsconverted 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.tsupdated forfilesWildcard.Notes
it.todo+ the matching-stability doc. This PR does not touch them.