Skip to content

fix(qwik-router): backtrack from static prefixes and rank matches by specificity - #8899

Merged
wmertens merged 10 commits into
QwikDev:mainfrom
blakeley:fix/router-matcher-backtracking
Oct 1, 2026
Merged

wmertens merged 10 commits into
QwikDev:mainfrom
blakeley:fix/router-matcher-backtracking

Conversation

@blakeley

@blakeley blakeley commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #8893

Two symptoms

  1. 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 → _M groups → _W → _A) and never reconsiders. So with routes/(marketing)/pricing/index.tsx and routes/(app)/[a]/[b]/[c]/index.tsx, the URL /pricing/x/y matches pricing on the first segment, dead-ends under it, and 404s — even though [a]/[b]/[c] matches the whole URL.

  2. 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.xml in one group and [a]/[b] in another both match /foo/static.xml, and which one wins is an accident of _M order rather than the more specific route.

Approach

matchRouteTree now resolves the best complete match up front, then replays it:

  • findChildAll returns every candidate child for a segment (in the same priority order findChild already walks) rather than just the first.
  • findMatchChain does 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.
  • compareChains ranks 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, findMatchChain returns 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 existing exact child dead end does not fall back to sibling _M catchall test).

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 on main and pass with this change:

  • a static single-segment route in one group and [a]/[b]/[c] in another — /pricing/x/y reaches the dynamic route instead of 404ing
  • [x]/static.xml beats [a]/[b] for /foo/static.xml in both group orders

The 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.

@blakeley
blakeley requested a review from a team as a code owner August 1, 2026 00:02
@changeset-bot

changeset-bot Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 508615d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@qwik.dev/router Major
eslint-plugin-qwik Major
create-qwik Major
@qwik.dev/devtools Major
@qwik.dev/react Major
@qwik.dev/utils Major
@qwik.dev/core Major

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@qwik.dev/core

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@8899

@qwik.dev/router

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@8899

@qwik.dev/utils

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/utils@8899

eslint-plugin-qwik

npm i https://pkg.pr.new/QwikDev/qwik/eslint-plugin-qwik@8899

create-qwik

npm i https://pkg.pr.new/QwikDev/qwik/create-qwik@8899

@qwik.dev/optimizer

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/optimizer@8899

@qwik.dev/devtools

npm i https://pkg.pr.new/QwikDev/qwik/@qwik.dev/devtools@8899

commit: 508615d

wmertens and others added 4 commits September 29, 2026 11:32
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
@wmertens
wmertens requested review from a team as code owners September 29, 2026 11:42
wmertens and others added 2 commits September 29, 2026 11:57
# Conflicts:
#	packages/docs/src/routes/api/qwik-router/api.json
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

Copy link
Copy Markdown
Member

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 qwik.optimizer.api.md: the generated report is missing DecoratorOptions, which main added in 5829032. The report is generated from packages/optimizer/dist/index.d.ts, which comes out of the Rust optimizer cache. That cache's key only hashes Rust files, so this PR restored an older entry saved in its own cache scope. main is green at the same base commit.

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

@wmertens
wmertens merged commit 3a99049 into QwikDev:main Oct 1, 2026
59 checks passed
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.

[🐞] v2: route matcher doesn't backtrack — a static route prefix 404s deeper dynamic routes; no specificity ranking across groups

2 participants