Repository navigation
fix(qwik-router): backtrack from static prefixes and rank matches by specificity - #8899
Conversation
🦋 Changeset detectedLatest commit: 508615d The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@qwik.dev/core
@qwik.dev/router
@qwik.dev/utils
eslint-plugin-qwik
create-qwik
@qwik.dev/optimizer
@qwik.dev/devtools
commit: |
A segment like `constructor` or `_4` hit an inherited property or a metadata key of the route trie and was treated as a static route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
…match search Follow Qwik 1: any route that matches the whole URL can win, ranked by specificity. A static prefix that dead-ends no longer blocks a sibling catch-all, so it behaves the same at any depth. The search now only descends into branches that can still beat the best match found so far, and ignores catch-alls without a page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
# Conflicts: # packages/docs/src/routes/api/qwik-router/api.json
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
The metadata guard rejected every two-character `_x` segment, so routes like `routes/_a/` 404ed. Only `_0`, `_4` and `_9` can collide with a lowercased segment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
…cktracking The route trie now escapes underscore-prefixed static keys, so the `_0`/`_4`/`_9` segment guard is gone; only the own-property check stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
The rust cache restores all of packages/optimizer/, including dist/*.d.ts built from src/*.ts, but its key only hashed Rust inputs. A TS-only change to the optimizer types (like DecoratorOptions) kept restoring the old declarations, so the API check failed on PRs whose cache scope held an older entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJopjVkVPH82ccXf4SNLb9
|
This CI run failed in Build Packages, and every downstream job was skipped or failed along with it. The failure isn't caused by this PR's changes. Build Packages failed its API check on Fix: #9083 adds the optimizer's TypeScript sources to the cache key. The same commit is being ported into this branch, so the next run misses the cache and rebuilds the optimizer, and the port becomes a no-op once 9083 is merged. Generated by Claude Code |
Fixes #8893
Two symptoms
A static prefix dead-ends the match. The matcher walks the route trie greedily: at each segment it takes the first child that matches (exact →
_Mgroups →_W→_A) and never reconsiders. So withroutes/(marketing)/pricing/index.tsxandroutes/(app)/[a]/[b]/[c]/index.tsx, the URL/pricing/x/ymatchespricingon the first segment, dead-ends under it, and 404s — even though[a]/[b]/[c]matches the whole URL.No specificity ranking across groups. When two branches both match a URL, the winner is whichever the trie happens to be walked into first, so it depends on group ordering.
[x]/static.xmlin one group and[a]/[b]in another both match/foo/static.xml, and which one wins is an accident of_Morder rather than the more specific route.Approach
matchRouteTreenow resolves the best complete match up front, then replays it:findChildAllreturns every candidate child for a segment (in the same priority orderfindChildalready walks) rather than just the first.findMatchChaindoes a DFS over those candidates, only accepting a chain that consumes the whole pathname and lands on a node that actually has a route — so a static prefix that dead-ends no longer hides a dynamic sibling.compareChainsranks complete chains lexicographically by per-segment kind (static < dynamic < catch-all), so the most specific match wins regardless of group order.When no chain matches,
findMatchChainreturns undefined and the segment loop runs exactly as before — the greedy walk, the rest-wildcard fallback, and the 404/boundary recovery are untouched, so unmatched URLs keep producing the same routeParts, params, and error boundaries they do today.One existing rule is preserved deliberately: an exact route subtree still owns its unmatched descendants, so a dead end under a static route 404s rather than being swallowed by a sibling
[...rest](covered by the existingexact child dead end does not fall back to sibling _M catchalltest).Repro
Minimal reproduction: https://github.com/blakeley/router-matcher-no-backtracking
Tests
Two cases added to
packages/qwik-router/src/runtime/src/routing.unit.ts, both of which fail onmainand pass with this change:[a]/[b]/[c]in another —/pricing/x/yreaches the dynamic route instead of 404ing[x]/static.xmlbeats[a]/[b]for/foo/static.xmlin both group ordersThe rest of the existing matcher suite (the static route itself still winning, plain dynamic matches, catch-all fallback, and an exact dead end not falling through to a sibling catch-all) is unchanged and still green, which is what pins the behaviour this change must not alter.
This restores the documented Qwik 1 routing semantics, where static pages and dynamic route families can share a URL prefix and the most specific route wins.