Skip to content
Draft
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/canonical-route-spelling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qwik.dev/router": patch
---

fix: preserve canonical route names across case-insensitive matches
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 @@ -1006,7 +1006,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\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>",
"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\\_C?\n\n\n</td><td>\n\n\n</td><td>\n\nRecord&lt;number, string&gt;\n\n\n</td><td>\n\n_(Optional)_ Canonical spellings of conflicting segments, indexed by route position.\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<tr><td>\n\n\\_S?\n\n\n</td><td>\n\n\n</td><td>\n\nstring\n\n\n</td><td>\n\n_(Optional)_ Original static segment spelling when different from its lowercase key.\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
30 changes: 30 additions & 0 deletions packages/docs/src/routes/api/qwik-router/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2485,6 +2485,21 @@ _(Optional)_ The JS bundle names for this route (SSR only)
</td></tr>
<tr><td>

\_C?

</td><td>

</td><td>

Record&lt;number, string&gt;

</td><td>

_(Optional)_ Canonical spellings of conflicting segments, indexed by route position.

</td></tr>
<tr><td>

\_D?

</td><td>
Expand Down Expand Up @@ -2617,6 +2632,21 @@ string[]

_(Optional)_ Inherited layout and server plugin routeLoader$ hashes.

</td></tr>
<tr><td>

\_S?

</td><td>

</td><td>

string

</td><td>

_(Optional)_ Original static segment spelling when different from its lowercase key.

</td></tr>
</tbody></table>

Expand Down
42 changes: 32 additions & 10 deletions packages/qwik-router/src/buildtime/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ function rewriteRoutes(ctx: RoutingContext, routes: BuiltRoute[]) {
const translatedRoutes: BuiltRoute[] = [];

let segmentsToTranslate = ctx.opts.rewriteRoutes.flatMap((rewriteConfig) => {
return Object.keys(rewriteConfig.paths || {});
return Object.keys(rewriteConfig.paths || {}).map((segment) => segment.toLowerCase());
});

segmentsToTranslate = Array.from(new Set(segmentsToTranslate));
Expand All @@ -188,10 +188,14 @@ function rewriteRoutes(ctx: RoutingContext, routes: BuiltRoute[]) {

const currentRouteSegments = route.pathname.split('/');
const foundSegmentToTranslate = currentRouteSegments.some((segment) =>
segmentsToTranslate.includes(segment)
segmentsToTranslate.includes(segment.toLowerCase())
);

if (foundSegmentToTranslate || route.pathname === '/') {
if (
foundSegmentToTranslate ||
route.pathname === '/' ||
ctx.opts.rewriteRoutes.some((config) => config.prefix)
) {
ctx.opts.rewriteRoutes.forEach((config, configIndex) => {
if (route.pathname === '/' && !config.prefix) {
return;
Expand All @@ -218,7 +222,8 @@ function translateRoute(
config: RewriteRouteOption,
configIndex: number
): BuiltRoute {
const replacePath = (part: string) => (config.paths || {})[part] ?? part;
const translations = getRewriteTranslations(config);
const replacePath = (part: string) => translations.get(part.toLowerCase()) ?? part;

const pathnamePrefix = config.prefix ? '/' + config.prefix : '';
const routeNamePrefix = config.prefix ? ensureSlash(config.prefix) : '';
Expand Down Expand Up @@ -271,6 +276,12 @@ function translateRoute(
};
}

function getRewriteTranslations(config: RewriteRouteOption) {
return new Map(
Object.entries(config.paths ?? {}).map(([key, value]) => [key.toLowerCase(), value])
);
}

/**
* Walk the trie and create _G (rewrite) nodes for each rewriteRoutes config.
*
Expand All @@ -284,6 +295,7 @@ function translateRoute(
function applyRewriteRoutes(root: BuildTrieNode, rewriteConfigs: RewriteRouteOption[]) {
interface TriePathStep {
key: string;
staticName?: string;
paramName?: string;
prefix?: string;
suffix?: string;
Expand All @@ -309,6 +321,7 @@ function applyRewriteRoutes(root: BuildTrieNode, rewriteConfigs: RewriteRouteOpt
...steps,
{
key,
staticName: child._S,
paramName: child._P,
prefix: child._0,
suffix: child._9,
Expand All @@ -321,8 +334,7 @@ function applyRewriteRoutes(root: BuildTrieNode, rewriteConfigs: RewriteRouteOpt
walk(root, []);

for (const config of rewriteConfigs) {
const translations = config.paths || {};
const translatable = new Set(Object.keys(translations).map((k) => k.toLowerCase()));
const translations = getRewriteTranslations(config);
const isExcluded = config.exclude?.length
? createRouteTester('/', config.exclude, undefined)
: undefined;
Expand All @@ -333,7 +345,7 @@ function applyRewriteRoutes(root: BuildTrieNode, rewriteConfigs: RewriteRouteOpt
continue;
}
// Check if any segment is translatable or if there's a prefix
const hasTranslatable = steps.some((s) => translatable.has(s.key));
const hasTranslatable = steps.some((s) => translations.has(s.key));
if (!hasTranslatable && !config.prefix) {
continue;
}
Expand All @@ -346,14 +358,21 @@ function applyRewriteRoutes(root: BuildTrieNode, rewriteConfigs: RewriteRouteOpt
const translatedSteps: TriePathStep[] = [];

if (config.prefix) {
translatedSteps.push({ key: config.prefix.toLowerCase() });
const key = config.prefix.toLowerCase();
translatedSteps.push({
key,
staticName: config.prefix !== key ? config.prefix : undefined,
});
}

for (const step of steps) {
const translated = translations[step.key];
const translated = translations.get(step.key);
const key = translated ? translated.toLowerCase() : step.key;
const staticName = translated || step.staticName;
translatedSteps.push({
...step,
key: translated ? translated.toLowerCase() : step.key,
key,
staticName: staticName !== key ? staticName : undefined,
});
}

Expand All @@ -372,6 +391,9 @@ function applyRewriteRoutes(root: BuildTrieNode, rewriteConfigs: RewriteRouteOpt
_files: [],
children: new Map(),
};
if (step.staticName) {
child._S = step.staticName;
}
if (step.paramName) {
child._P = step.paramName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ function getOrCreateChildNode(node: BuildTrieNode, dirName: string): BuildTrieNo
_files: [],
children: new Map(),
};
if (parsed.key !== null && !parsed.paramName && dirName !== parsed.key) {
child._S = dirName;
}
if (parsed.paramName) {
child._P = parsed.paramName;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,25 @@ test('param directory names may contain dashes', () => {
});
assert.deepEqual(parseDirName('[...rest-path]'), { key: '_A', paramName: 'rest-path' });
});

test('stores original static spelling only when its lookup key differs', async () => {
const entries: Record<string, string[]> = {
'/app/routes': ['Product', 'docs'],
'/app/routes/Product': ['[ItemId]'],
'/app/routes/Product/[ItemId]': ['index.tsx'],
'/app/routes/docs': ['index.tsx'],
};
vi.spyOn(fs.promises, 'readdir').mockImplementation(async (path) => entries[String(path)] as any);
vi.spyOn(fs.promises, 'stat').mockImplementation(
async (path) =>
({
isDirectory: () => !String(path).endsWith('.tsx'),
}) as fs.Stats
);

const root = await walkRoutes('/app/routes');
const product = root.children.get('product')!;
assert.equal(product._S, 'Product');
assert.notProperty(product.children.get('_W')!, '_S');
assert.notProperty(root.children.get('docs')!, '_S');
});
Loading
Loading