Skip to content
Merged
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': major
---

breaking: the most specific route now wins regardless of group order, and catch-alls also receive unmatched urls below static routes
5 changes: 5 additions & 0 deletions .changeset/router-prototype-segments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@qwik.dev/router': patch
---

fix: urls like `/constructor` or `/tostring` now reach dynamic routes instead of 404ing
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ jobs:
with:
lookup-only: true
path: packages/optimizer/
key: ${{ hashfiles('Makefile', 'rust-toolchain', '**/Cargo.toml', '**/Cargo.lock', '**/*.rs') }}
# The cache also holds dist/*.d.ts built from the TS bridge, so its sources must invalidate it.
key: ${{ hashfiles('Makefile', 'rust-toolchain', '**/Cargo.toml', '**/Cargo.lock', '**/*.rs', 'packages/optimizer/src/**', 'packages/optimizer/package.json', 'packages/optimizer/tsconfig.json', 'packages/optimizer/vite.config.ts', 'scripts/submodule-optimizer.ts') }}
- run: 'echo ${{ steps.cache-rust.outputs.cache-primary-key }} > rust-key.txt'
- name: 'check cache: others'
id: cache-others
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-router/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -950,7 +950,7 @@
}
],
"kind": "Interface",
"content": "A nested route trie structure. The root represents `/` and each level represents a URL segment.\n\nKeys starting with a single `_` are metadata; all other keys are child route segments. A static segment that starts with `_` is stored with an extra `_` (`_drafts` → `__drafts`<!-- -->).\n\n- Use `_W` as the key for a single dynamic segment (param); `_P` on that node names the param. - Use `_A` as the key for a rest/catch-all segment; `_P` on that node names the param. - For infix params like `pre[slug]post`<!-- -->, use `_W` with `_0` (prefix) and `_9` (suffix). - Use `_M` for an array of group (pathless layout) nodes, sorted by group name.\n\nWhen matching, exact segments are tried first (case-insensitive), then `_W` (with optional prefix/suffix), then `_A`<!-- -->. When no route matches, the closest `_E` (error.tsx) or `_4` (404.tsx) loader in the ancestor chain is used to render the error page.\n\n\n```typescript\nexport interface RouteData \n```\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n\\_0?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Prefix for infix params (e.g. \"pre\" for `pre[slug]post`<!-- -->) — only on `_W` nodes\n\n\n</td></tr>\n<tr><td>\n\n\\_4?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader \\| ModuleLoader\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Not-found (404) boundary: single loader (runtime prepends layouts) or override chain (`404@layout`<!-- -->/`!`<!-- -->).\n\n\n</td></tr>\n<tr><td>\n\n\\_9?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Suffix for infix params (e.g. \"post\" for `pre[slug]post`<!-- -->) — only on `_W` nodes\n\n\n</td></tr>\n<tr><td>\n\n\\_B?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\\[\\]\n\n\n</td><td>\n\n_(Optional)_ The JS bundle names for this route (SSR only)\n\n\n</td></tr>\n<tr><td>\n\n\\_D?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Page loader hashes; override pages include their selected layouts and plugins.\n\n\n</td></tr>\n<tr><td>\n\n\\_E?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader \\| ModuleLoader\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Error (error.tsx) boundary, same single-or-override-chain shape as `_4`<!-- -->.\n\n\n</td></tr>\n<tr><td>\n\n\\_G?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Rewrite/goto target path. Matcher re-walks trie from root using this path's keys.\n\n\n</td></tr>\n<tr><td>\n\n\\_I?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader \\| ModuleLoader\\[\\]\n\n\n</td><td>\n\n_(Optional)_ This node's index/page loader. Single = normal (runtime prepends gathered \\_L). Array = override (layout stop / named layout — IS the complete chain).\n\n\n</td></tr>\n<tr><td>\n\n\\_L?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader\n\n\n</td><td>\n\n_(Optional)_ This node's layout loader (single). Runtime accumulates these during trie traversal.\n\n\n</td></tr>\n<tr><td>\n\n\\_M?\n\n\n</td><td>\n\n\n</td><td>\n\n[RouteData](#routedata)<!-- -->\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Group (pathless layout) nodes merged into this level, sorted by group name\n\n\n</td></tr>\n<tr><td>\n\n\\_N?\n\n\n</td><td>\n\n\n</td><td>\n\nMenuModuleLoader\n\n\n</td><td>\n\n_(Optional)_ Menu loader for this subtree (from menu.md). Runtime uses nearest ancestor during traversal.\n\n\n</td></tr>\n<tr><td>\n\n\\_P?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ The parameter name when this node is reached via `_W` or `_A` from the parent\n\n\n</td></tr>\n<tr><td>\n\n\\_R?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Inherited layout and server plugin routeLoader$ hashes.\n\n\n</td></tr>\n</tbody></table>",
"content": "A nested route trie structure. The root represents `/` and each level represents a URL segment.\n\nKeys starting with a single `_` are metadata; all other keys are child route segments. A static segment that starts with `_` is stored with an extra `_` (`_drafts` → `__drafts`<!-- -->).\n\n- Use `_W` as the key for a single dynamic segment (param); `_P` on that node names the param. - Use `_A` as the key for a rest/catch-all segment; `_P` on that node names the param. - For infix params like `pre[slug]post`<!-- -->, use `_W` with `_0` (prefix) and `_9` (suffix). - Use `_M` for an array of group (pathless layout) nodes, sorted by group name.\n\nExact segments match case-insensitively. When several routes match, the most specific wins, compared segment by segment: an exact segment beats `_W` (with optional prefix/suffix), which beats `_A`<!-- -->. When no route matches, the closest `_E` (error.tsx) or `_4` (404.tsx) loader in the ancestor chain is used to render the error page.\n\n\n```typescript\nexport interface RouteData \n```\n\n\n<table><thead><tr><th>\n\nProperty\n\n\n</th><th>\n\nModifiers\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\n\\_0?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Prefix for infix params (e.g. \"pre\" for `pre[slug]post`<!-- -->) — only on `_W` nodes\n\n\n</td></tr>\n<tr><td>\n\n\\_4?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader \\| ModuleLoader\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Not-found (404) boundary: single loader (runtime prepends layouts) or override chain (`404@layout`<!-- -->/`!`<!-- -->).\n\n\n</td></tr>\n<tr><td>\n\n\\_9?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Suffix for infix params (e.g. \"post\" for `pre[slug]post`<!-- -->) — only on `_W` nodes\n\n\n</td></tr>\n<tr><td>\n\n\\_B?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\\[\\]\n\n\n</td><td>\n\n_(Optional)_ The JS bundle names for this route (SSR only)\n\n\n</td></tr>\n<tr><td>\n\n\\_D?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Page loader hashes; override pages include their selected layouts and plugins.\n\n\n</td></tr>\n<tr><td>\n\n\\_E?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader \\| ModuleLoader\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Error (error.tsx) boundary, same single-or-override-chain shape as `_4`<!-- -->.\n\n\n</td></tr>\n<tr><td>\n\n\\_G?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Rewrite/goto target path. Matcher re-walks trie from root using this path's keys.\n\n\n</td></tr>\n<tr><td>\n\n\\_I?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader \\| ModuleLoader\\[\\]\n\n\n</td><td>\n\n_(Optional)_ This node's index/page loader. Single = normal (runtime prepends gathered \\_L). Array = override (layout stop / named layout — IS the complete chain).\n\n\n</td></tr>\n<tr><td>\n\n\\_L?\n\n\n</td><td>\n\n\n</td><td>\n\nContentModuleLoader\n\n\n</td><td>\n\n_(Optional)_ This node's layout loader (single). Runtime accumulates these during trie traversal.\n\n\n</td></tr>\n<tr><td>\n\n\\_M?\n\n\n</td><td>\n\n\n</td><td>\n\n[RouteData](#routedata)<!-- -->\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Group (pathless layout) nodes merged into this level, sorted by group name\n\n\n</td></tr>\n<tr><td>\n\n\\_N?\n\n\n</td><td>\n\n\n</td><td>\n\nMenuModuleLoader\n\n\n</td><td>\n\n_(Optional)_ Menu loader for this subtree (from menu.md). Runtime uses nearest ancestor during traversal.\n\n\n</td></tr>\n<tr><td>\n\n\\_P?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ The parameter name when this node is reached via `_W` or `_A` from the parent\n\n\n</td></tr>\n<tr><td>\n\n\\_R?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\\[\\]\n\n\n</td><td>\n\n_(Optional)_ Inherited layout and server plugin routeLoader$ hashes.\n\n\n</td></tr>\n</tbody></table>",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts",
"mdFile": "router.routedata.md"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/routes/api/qwik-router/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2330,7 +2330,7 @@ Keys starting with a single `_` are metadata; all other keys are child route seg

- Use `_W` as the key for a single dynamic segment (param); `_P` on that node names the param. - Use `_A` as the key for a rest/catch-all segment; `_P` on that node names the param. - For infix params like `pre[slug]post`, use `_W` with `_0` (prefix) and `_9` (suffix). - Use `_M` for an array of group (pathless layout) nodes, sorted by group name.

When matching, exact segments are tried first (case-insensitive), then `_W` (with optional prefix/suffix), then `_A`. When no route matches, the closest `_E` (error.tsx) or `_4` (404.tsx) loader in the ancestor chain is used to render the error page.
Exact segments match case-insensitively. When several routes match, the most specific wins, compared segment by segment: an exact segment beats `_W` (with optional prefix/suffix), which beats `_A`. When no route matches, the closest `_E` (error.tsx) or `_4` (404.tsx) loader in the ancestor chain is used to render the error page.

```typescript
export interface RouteData
Expand Down
17 changes: 17 additions & 0 deletions packages/docs/src/routes/docs/(qwikrouter)/routing/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ export default component$(() => {
});
```

### Route priority

When more than one route matches a URL, the most specific one wins. Qwik Router compares matching routes segment by segment, from left to right: a static segment beats a `[param]`, and a `[param]` beats a `[...catchAll]`. The folder order and route group names don't matter.

```bash title="Directory layout"
src/routes/(marketing)/pricing/index.tsx → /pricing
src/routes/(rss)/[section]/feed.xml/index.tsx → /news/feed.xml
src/routes/(pages)/[section]/[topic]/index.tsx → /news/today
src/routes/(app)/[org]/[...path]/index.tsx → /acme/settings/billing
```

- `/pricing` matches `pricing/`, and also `[org]/[...path]` with an empty `path`. The static segment wins.
- `/news/feed.xml` matches all three dynamic routes. The static `feed.xml` segment is more specific than `[topic]` or `[...path]`, so the feed wins.
- `/pricing/plans/pro` doesn't fit `pricing/` or the two-segment routes, so it falls through to `[org]/[...path]` with `org` set to `pricing`. A URL only ends up at a 404 page when no route matches it at all.

<Note>If a catch-all shouldn't handle URLs under a static page, check for that prefix in the catch-all's `onRequest` or `onGet` handler and throw `error(404, ...)`.</Note>

## `index` files

Inside the `src/routes` directory, all files named `index` are considered pages/endpoint/middleware, Qwik supports the following extensions: `.ts`, `.tsx`, `.md` and `.mdx`.
Expand Down
146 changes: 139 additions & 7 deletions packages/qwik-router/src/runtime/src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,15 @@ interface ChildMatch {
paramValue?: string;
}

/** The static child for a segment; inherited object properties (`constructor`, …) are not routes. */
function getStaticChild(node: RouteData, partLower: string): RouteData | undefined {
const key = escapeStaticTrieKey(partLower);
if (!Object.prototype.hasOwnProperty.call(node, key)) {
return undefined;
}
return node[key] as RouteData;
}

/**
* Try to find a child matching `partLower` in `node`, including inside group children (_M). Returns
* the matched child node, the groups entered to reach it, what to push to routeParts, and which
Expand All @@ -295,7 +304,7 @@ function findChild(
partIndex: number
): ChildMatch | undefined {
// 1. Try exact match on this node's direct children
const exact = node[escapeStaticTrieKey(partLower)] as RouteData | undefined;
const exact = getStaticChild(node, partLower);
if (exact) {
return {
next: exact,
Expand Down Expand Up @@ -338,8 +347,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 @@ -375,8 +392,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 @@ -393,6 +418,109 @@ 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 = getStaticChild(node, partLower);
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;
}

/**
* Search for the most specific chain of child matches that consumes `parts` from `i` and lands on a
* route. Chains rank segment by segment: a static segment beats a `[param]`, which beats a
* `[...rest]`; ties go to the first in `findChild` order.
*
* Only chains strictly more specific than `toBeat` (the best tail found so far) are returned, so
* branches that can no longer win are pruned. Returns undefined when nothing matches.
*/
function findMatchChain(
node: RouteData,
parts: string[],
i: number,
toBeat?: ChildMatch[]
): ChildMatch[] | undefined {
if (i === parts.length) {
// Reaching the end with a bound means an equally specific chain already exists.
return !toBeat && (findIndexNode(node) || findRestNode(node)) ? [] : undefined;
}

const part = parts[i];
const candidates = findChildAll(node, part, part.toLowerCase(), parts, i).sort(
(a, b) => a.kind - b.kind
);

let best: ChildMatch[] | undefined;
for (let c = 0; c < candidates.length; c++) {
const found = candidates[c];
const bound = best ?? toBeat;
if (bound && found.kind > bound[0].kind) {
break;
}
const tailToBeat = bound && found.kind === bound[0].kind ? bound.slice(1) : undefined;
const tail = found.done
? matchRestTail(found.next, tailToBeat)
: findMatchChain(found.next, parts, i + 1, tailToBeat);
if (tail) {
best = [found, ...tail];
}
}
return best;
}

/** A `[...rest]` match ends the chain; it only counts when it has a page and beats the bound. */
function matchRestTail(restNode: RouteData, toBeat?: ChildMatch[]): ChildMatch[] | undefined {
return !toBeat && isRouteNode(restNode) ? [] : undefined;
}

/** Whether a node renders something: a page (`_I`) or a rewrite (`_G`). */
function isRouteNode(node: RouteData): boolean {
return !!node._I || node._G != null;
}

/**
* 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 @@ -425,7 +553,7 @@ function descendGroups<T>(
* isn't double-counted).
*/
function findIndexNode(node: RouteData): { target: RouteData; groups: RouteData[] } | undefined {
const r = descendGroups(node, (n) => (n._I || n._G != null ? n : undefined));
const r = descendGroups(node, (n) => (isRouteNode(n) ? n : undefined));
return r ? { target: r.node, groups: r.groups.filter((g) => g !== r.node) } : undefined;
}

Expand Down Expand Up @@ -544,11 +672,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
Loading
Loading