diff --git a/.changeset/router-matcher-backtracking.md b/.changeset/router-matcher-backtracking.md new file mode 100644 index 00000000000..8cc0fc7f603 --- /dev/null +++ b/.changeset/router-matcher-backtracking.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/router': patch +--- + +Route matching now backtracks when a static prefix dead-ends and ranks fully-matching candidates by segment specificity (static < dynamic < catch-all), restoring Qwik 1 semantics where static pages and dynamic route families can share URL prefixes. diff --git a/packages/qwik-router/src/runtime/src/routing.ts b/packages/qwik-router/src/runtime/src/routing.ts index 0d1e38ff1c6..00502a68a13 100644 --- a/packages/qwik-router/src/runtime/src/routing.ts +++ b/packages/qwik-router/src/runtime/src/routing.ts @@ -334,8 +334,16 @@ function tryWildcardMatch( parts: string[], partIndex: number ): Omit | undefined { - // Wildcard [param] - let next = node._W as RouteData | undefined; + return matchWildcardNode(node, part, partLower) ?? matchRestNode(node, parts, partIndex); +} + +/** Try to match a segment against a node's `[param]` wildcard child (`_W`). */ +function matchWildcardNode( + node: RouteData, + part: string, + partLower: string +): Omit | undefined { + const next = node._W as RouteData | undefined; if (next) { const prefix = next._0; const suffix = next._9; @@ -371,8 +379,16 @@ function tryWildcardMatch( } } - // Rest wildcard [...param] - next = node._A as RouteData | undefined; + return undefined; +} + +/** Try to match the remaining segments against a node's `[...param]` rest child (`_A`). */ +function matchRestNode( + node: RouteData, + parts: string[], + partIndex: number +): Omit | undefined { + const next = node._A as RouteData | undefined; if (next) { const paramName = next._P!; const restValue = parts.slice(partIndex).join('/'); @@ -389,6 +405,112 @@ function tryWildcardMatch( return undefined; } +/** + * Every child of `node` that could match `partLower`, in the same priority order `findChild` walks: + * exact match → `_M` groups (recursively) → `_W` wildcard → `_A` rest wildcard. + * + * `findChild` stops at the first of these; this returns them all so the matcher can backtrack when + * the highest-priority one dead-ends deeper in the trie. + */ +function findChildAll( + node: RouteData, + part: string, + partLower: string, + parts: string[], + partIndex: number +): ChildMatch[] { + const candidates: ChildMatch[] = []; + + const exact = node[partLower] as RouteData | undefined; + if (exact) { + candidates.push({ + next: exact, + groups: [], + routePart: part, + done: false, + kind: ChildMatchKind.Exact, + }); + } + + if (node._M) { + for (let j = 0; j < node._M.length; j++) { + const group = node._M[j]; + const groupCandidates = findChildAll(group, part, partLower, parts, partIndex); + for (let k = 0; k < groupCandidates.length; k++) { + const candidate = groupCandidates[k]; + candidates.push({ ...candidate, groups: [group, ...candidate.groups] }); + } + } + } + + const wildcard = matchWildcardNode(node, part, partLower); + if (wildcard) { + candidates.push({ ...wildcard, groups: [] }); + } + + const rest = matchRestNode(node, parts, partIndex); + if (rest) { + candidates.push({ ...rest, groups: [] }); + } + + return candidates; +} + +/** + * Order two complete matches by specificity, segment by segment: a static segment beats a + * `[param]`, which beats a `[...rest]`. A chain that ran out of segments (because an earlier + * `[...rest]` swallowed them) ranks last. Negative when `a` is the better match. + */ +function compareChains(a: ChildMatch[], b: ChildMatch[]): number { + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const kindA = i < a.length ? a[i].kind : -1; + const kindB = i < b.length ? b[i].kind : -1; + if (kindA !== kindB) { + return kindA - kindB; + } + } + return 0; +} + +/** + * Search for the most specific chain of child matches that consumes `parts` from `i` and lands on a + * node that actually has a route (an index, or a rest wildcard soaking up the remainder). + * + * Unlike the greedy walk in `matchRouteTree`, this backtracks: a static prefix that dead-ends + * deeper no longer hides a dynamic route in a sibling group. Returns undefined when nothing + * matches, and the walk then proceeds exactly as before. + */ +function findMatchChain(node: RouteData, parts: string[], i: number): ChildMatch[] | undefined { + if (i === parts.length) { + return findIndexNode(node) || findRestNode(node) ? [] : undefined; + } + + const part = parts[i]; + const partLower = part.toLowerCase(); + const candidates = findChildAll(node, part, partLower, parts, i); + // An exact route subtree owns its unmatched descendants: it 404s rather than handing the URL to a + // rest wildcard beside it. Dynamic subtrees may still fall back to one. + const ownsSubtree = candidates.some((c) => c.kind === ChildMatchKind.Exact); + + let best: ChildMatch[] | undefined; + for (let c = 0; c < candidates.length; c++) { + const found = candidates[c]; + if (ownsSubtree && found.kind === ChildMatchKind.Rest) { + continue; + } + const rest = found.done ? [] : findMatchChain(found.next, parts, i + 1); + if (!rest) { + continue; + } + const chain = [found, ...rest]; + if (!best || compareChains(chain, best) < 0) { + best = chain; + } + } + return best; +} + /** * Descend through a node and its pathless `_M` groups (depth-first, in `_M` order), returning the * first node for which `hit` yields a value, plus the chain of groups entered to reach it. The @@ -538,11 +660,15 @@ function matchRouteTree( let i = 0; const len = parts.length; + // The best complete match, found up front so the walk can take a less obvious branch when the + // greedy one dead-ends. Undefined when no route matches at all — the walk is then unchanged, and + // still produces the routeParts/params/boundaries the 404 path reports. + const chain = findMatchChain(root, parts, 0); for (; !done && i < len; i++) { const part = parts[i]; const partLower = part.toLowerCase(); - const found = findChild(node, part, partLower, parts, i); + const found = chain ? chain[i] : findChild(node, part, partLower, parts, i); if (!found) { matched = false; break; diff --git a/packages/qwik-router/src/runtime/src/routing.unit.ts b/packages/qwik-router/src/runtime/src/routing.unit.ts index 0c50bd3a750..b6051317718 100644 --- a/packages/qwik-router/src/runtime/src/routing.unit.ts +++ b/packages/qwik-router/src/runtime/src/routing.unit.ts @@ -729,6 +729,43 @@ test('loadRoute — exact child dead end does not fall back to sibling _M catcha assert.notDeepEqual(result.$params$, { catchall: 'loader-redirect/notexist' }); }); +// ─── Backtracking and specificity tests ────────────────────────────────────────── + +test('loadRoute — a static prefix that dead-ends backtracks to a dynamic route in another group', async () => { + // routes/(marketing)/pricing/index.tsx and routes/(app)/[a]/[b]/[c]/index.tsx. + // /pricing/x/y must reach the dynamic route even though `pricing` matched first. + const pricingLoader = makeLoader(); + const dynamicLoader = makeLoader(); + const routes: RouteData = { + _M: [ + { pricing: { _I: pricingLoader } }, + { _W: { _P: 'a', _W: { _P: 'b', _W: { _P: 'c', _I: dynamicLoader } } } }, + ], + }; + const result = await loadRoute(routes, false, '/pricing/x/y'); + assert.isFalse(result.$notFound$); + assert.deepEqual(result.$params$, { a: 'pricing', b: 'x', c: 'y' }); + assert.equal(result.$routeName$, '/[a]/[b]/[c]'); +}); + +test('loadRoute — a later segment being static outranks an all-dynamic match', async () => { + // [x]/static.xml beats [a]/[b] for /foo/static.xml, whichever group comes first. + const staticLoader = makeLoader(); + const dynamicLoader = makeLoader(); + const staticGroup = { _W: { _P: 'x', 'static.xml': { _I: staticLoader } } }; + const dynamicGroup = { _W: { _P: 'a', _W: { _P: 'b', _I: dynamicLoader } } }; + + for (const groups of [ + [staticGroup, dynamicGroup], + [dynamicGroup, staticGroup], + ]) { + const result = await loadRoute({ _M: groups }, false, '/foo/static.xml'); + assert.isFalse(result.$notFound$); + assert.deepEqual(result.$params$, { x: 'foo' }); + assert.equal(result.$routeName$, '/[x]/static.xml'); + } +}); + // ─── Menu (_N) trie tests ─────────────────────────────────────────────────────── test('loadRoute — _N menu from ancestor is used for child route', async () => {