Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/router-matcher-backtracking.md
Original file line number Diff line number Diff line change
@@ -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.
136 changes: 131 additions & 5 deletions packages/qwik-router/src/runtime/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,16 @@ function tryWildcardMatch(
parts: string[],
partIndex: number
): Omit<ChildMatch, 'groups'> | 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<ChildMatch, 'groups'> | undefined {
const next = node._W as RouteData | undefined;
if (next) {
const prefix = next._0;
const suffix = next._9;
Expand Down Expand Up @@ -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<ChildMatch, 'groups'> | undefined {
const next = node._A as RouteData | undefined;
if (next) {
const paramName = next._P!;
const restValue = parts.slice(partIndex).join('/');
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions packages/qwik-router/src/runtime/src/routing.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading