@buzola/router 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine/matcher.d.ts +1 -1
- package/dist/engine/matcher.d.ts.map +1 -1
- package/dist/engine/matcher.js +41 -20
- package/dist/engine/matcher.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/matcher.test.ts +44 -0
- package/src/__tests__/type-safety.test.tsx +74 -1
- package/src/engine/matcher.ts +41 -18
- package/src/index.ts +3 -0
package/dist/engine/matcher.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { RouteMatch, RouteNode } from './types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Match a URL against the route tree.
|
|
4
4
|
* Returns an array of RouteMatch from root layout to leaf page, or null if no match.
|
|
5
|
-
*
|
|
5
|
+
* When multiple routes match, selects the most specific one (fewest dynamic params).
|
|
6
6
|
*/
|
|
7
7
|
export declare function matchRoutes(nodes: RouteNode[], url: URL): RouteMatch[] | null;
|
|
8
8
|
//# sourceMappingURL=matcher.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"matcher.d.ts","sourceRoot":"","sources":["../../src/engine/matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAEvD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,GAAG,EAAE,GAAG,GAAG,UAAU,EAAE,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"matcher.d.ts","sourceRoot":"","sources":["../../src/engine/matcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAEvD;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,GAAG,EAAE,GAAG,GAAG,UAAU,EAAE,GAAG,IAAI,CAG7E"}
|
package/dist/engine/matcher.js
CHANGED
|
@@ -1,31 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Match a URL against the route tree.
|
|
3
3
|
* Returns an array of RouteMatch from root layout to leaf page, or null if no match.
|
|
4
|
-
*
|
|
4
|
+
* When multiple routes match, selects the most specific one (fewest dynamic params).
|
|
5
5
|
*/
|
|
6
6
|
export function matchRoutes(nodes, url) {
|
|
7
7
|
const pathname = url.pathname;
|
|
8
|
+
return pickBestMatch(nodes, pathname);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Find the best match among sibling nodes.
|
|
12
|
+
* Tries all siblings and picks the most specific match (fewest dynamic params).
|
|
13
|
+
*/
|
|
14
|
+
function pickBestMatch(nodes, pathname) {
|
|
15
|
+
let best = null;
|
|
16
|
+
let bestParamCount = Infinity;
|
|
8
17
|
for (const node of nodes) {
|
|
9
18
|
const result = matchNode(node, pathname);
|
|
10
|
-
if (result)
|
|
11
|
-
|
|
19
|
+
if (result) {
|
|
20
|
+
const paramCount = countParams(result);
|
|
21
|
+
if (paramCount < bestParamCount) {
|
|
22
|
+
best = result;
|
|
23
|
+
bestParamCount = paramCount;
|
|
24
|
+
if (paramCount === 0)
|
|
25
|
+
break; // Can't do better than zero params
|
|
26
|
+
}
|
|
27
|
+
}
|
|
12
28
|
}
|
|
13
|
-
return
|
|
29
|
+
return best;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Count total dynamic params across all matches in a chain.
|
|
33
|
+
*/
|
|
34
|
+
function countParams(matches) {
|
|
35
|
+
let count = 0;
|
|
36
|
+
for (const match of matches) {
|
|
37
|
+
count += Object.keys(match.params).length;
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
14
40
|
}
|
|
15
41
|
function matchNode(node, pathname) {
|
|
16
42
|
// Layout nodes: try to match children, prepend self if any child matches
|
|
17
43
|
if (node.isLayout && !node.isIndex) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
27
|
-
return [layoutMatch, ...childMatch];
|
|
28
|
-
}
|
|
44
|
+
const childMatch = pickBestMatch(node.children, pathname);
|
|
45
|
+
if (childMatch) {
|
|
46
|
+
const layoutMatch = {
|
|
47
|
+
node,
|
|
48
|
+
params: extractLayoutParams(node, pathname),
|
|
49
|
+
pathname,
|
|
50
|
+
};
|
|
51
|
+
return [layoutMatch, ...childMatch];
|
|
29
52
|
}
|
|
30
53
|
return null;
|
|
31
54
|
}
|
|
@@ -37,11 +60,9 @@ function matchNode(node, pathname) {
|
|
|
37
60
|
const match = { node, params, pathname };
|
|
38
61
|
// If this node has children (e.g., a page with nested routes), try them too
|
|
39
62
|
if (node.children.length > 0) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return [match, ...childMatch];
|
|
44
|
-
}
|
|
63
|
+
const childMatch = pickBestMatch(node.children, pathname);
|
|
64
|
+
if (childMatch) {
|
|
65
|
+
return [match, ...childMatch];
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
68
|
return [match];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"matcher.js","sourceRoot":"","sources":["../../src/engine/matcher.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,KAAkB,EAAE,GAAQ;IACvD,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAA;IAE7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;QACxC,IAAI,MAAM;
|
|
1
|
+
{"version":3,"file":"matcher.js","sourceRoot":"","sources":["../../src/engine/matcher.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AACnE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEzE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxE,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEpE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAC9G,YAAY,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAA;AAGpF,YAAY,EACX,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,cAAc,EACd,WAAW,EACX,UAAU,EACV,SAAS,EACT,WAAW,EACX,cAAc,GACd,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAGtC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAC9E,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAGlG,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA;AACzB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,SAAS,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,qBAAqB,CAAA;AAG5B,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AACnE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAEzE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AACxE,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEpE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAC9G,YAAY,EAAE,8BAA8B,EAAE,MAAM,gCAAgC,CAAA;AAGpF,YAAY,EACX,SAAS,EACT,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,cAAc,EACd,cAAc,EACd,WAAW,EACX,UAAU,EACV,SAAS,EACT,WAAW,EACX,cAAc,GACd,MAAM,mBAAmB,CAAA;AAG1B,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAGtC,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAC9E,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAGlG,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA;AACzB,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,SAAS,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// ─── Polyfills ──────────────────────────────────────────────────────────────
|
|
2
|
+
import 'urlpattern-polyfill';
|
|
1
3
|
// ─── Engine ─────────────────────────────────────────────────────────────────
|
|
2
4
|
export { LoaderCache } from './engine/loader-cache.js';
|
|
3
5
|
export { NavigationAbortedError, Router } from './engine/router.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAGnE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAGxE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAsB9G,gFAAgF;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAEtC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG9E,+EAA+E;AAC/E,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,OAAO,qBAAqB,CAAA;AAE5B,+EAA+E;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAA;AAEtD,OAAO,EAAE,sBAAsB,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAA;AAGnE,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAGxE,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEjD,OAAO,EAAE,8BAA8B,EAAE,6BAA6B,EAAE,MAAM,gCAAgC,CAAA;AAsB9G,gFAAgF;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAEtC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG9E,+EAA+E;AAC/E,OAAO,EACN,cAAc,EACd,aAAa,EACb,IAAI,EACJ,MAAM,EACN,YAAY,EACZ,aAAa,EACb,UAAU,EACV,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,cAAc,EACd,eAAe,GACf,MAAM,kBAAkB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buzola/router",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -36,5 +36,8 @@
|
|
|
36
36
|
"happy-dom": "^20.8.3",
|
|
37
37
|
"react": "^19.0.0",
|
|
38
38
|
"typescript": "^5.7.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"urlpattern-polyfill": "^10.1.0"
|
|
39
42
|
}
|
|
40
43
|
}
|
|
@@ -145,6 +145,50 @@ describe('matchRoutes', () => {
|
|
|
145
145
|
expect(matches![1].params).toEqual({ userId: '42' })
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
it('prefers static route over dynamic sibling when layout with matchPath comes first', () => {
|
|
149
|
+
// Simulates the real-world scenario: layout with dynamic matchPath children
|
|
150
|
+
// appears before a static route at the same level (e.g., /project/detail layout
|
|
151
|
+
// with matchPath /project/:id vs /project/create static page)
|
|
152
|
+
const tree = createTestTree([
|
|
153
|
+
{
|
|
154
|
+
path: '/',
|
|
155
|
+
component: dummyComponent,
|
|
156
|
+
isLayout: true,
|
|
157
|
+
children: [
|
|
158
|
+
{
|
|
159
|
+
path: '/project/detail',
|
|
160
|
+
component: dummyComponent,
|
|
161
|
+
isLayout: true,
|
|
162
|
+
children: [
|
|
163
|
+
{ path: '/', matchPath: '/project/:id', component: dummyComponent, isIndex: true },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{ path: '/project/create', component: dummyComponent },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
// /project/create should match the static route, not /project/:id with id=create
|
|
172
|
+
const matches = matchRoutes(tree, new URL('http://localhost/project/create'))
|
|
173
|
+
expect(matches).not.toBeNull()
|
|
174
|
+
const leaf = matches![matches!.length - 1]
|
|
175
|
+
expect(leaf.node.fullPath).toBe('/project/create')
|
|
176
|
+
expect(leaf.params).toEqual({})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('prefers route with fewer dynamic params among siblings', () => {
|
|
180
|
+
const tree = createTestTree([
|
|
181
|
+
{ path: '/users/:userId', component: dummyComponent },
|
|
182
|
+
{ path: '/users/me', component: dummyComponent },
|
|
183
|
+
])
|
|
184
|
+
|
|
185
|
+
const matches = matchRoutes(tree, new URL('http://localhost/users/me'))
|
|
186
|
+
expect(matches).not.toBeNull()
|
|
187
|
+
expect(matches).toHaveLength(1)
|
|
188
|
+
expect(matches![0].node.fullPath).toBe('/users/me')
|
|
189
|
+
expect(matches![0].params).toEqual({})
|
|
190
|
+
})
|
|
191
|
+
|
|
148
192
|
it('does not match partial paths', () => {
|
|
149
193
|
const tree = createTestTree([
|
|
150
194
|
{ path: '/about', component: dummyComponent },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
2
|
import React from 'react'
|
|
3
3
|
import { createPage } from '../define/create-page.js'
|
|
4
|
-
import type { StandardSchema } from '../engine/types.js'
|
|
4
|
+
import type { EffectivePageParams, NavigateOptions, RegisteredPage, StandardSchema } from '../engine/types.js'
|
|
5
5
|
import type { LinkProps } from '../react/link.js'
|
|
6
6
|
|
|
7
7
|
// Register pages for type-safe testing
|
|
@@ -229,3 +229,76 @@ describe('Link JSX generic inference', () => {
|
|
|
229
229
|
expect(true).toBe(true)
|
|
230
230
|
})
|
|
231
231
|
})
|
|
232
|
+
|
|
233
|
+
// ─── NavigateFn type safety ─────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Replicate the NavigateFn type from hooks.ts for testing without React hooks.
|
|
237
|
+
*/
|
|
238
|
+
type NavigateFn = <P extends RegisteredPage>(
|
|
239
|
+
to: P,
|
|
240
|
+
...args: [keyof EffectivePageParams<P>] extends [never] ? [options?: NavigateOptions]
|
|
241
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
242
|
+
: {} extends EffectivePageParams<P> ? [options?: NavigateOptions & { params?: EffectivePageParams<P> }]
|
|
243
|
+
: [options: NavigateOptions & { params: EffectivePageParams<P> }]
|
|
244
|
+
) => void
|
|
245
|
+
|
|
246
|
+
describe('NavigateFn type safety', () => {
|
|
247
|
+
const noop = (() => {}) as NavigateFn
|
|
248
|
+
|
|
249
|
+
it('static page does not require options', () => {
|
|
250
|
+
noop('home')
|
|
251
|
+
noop('about')
|
|
252
|
+
expect(true).toBe(true)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('static page accepts NavigateOptions without params', () => {
|
|
256
|
+
noop('home', { replace: true })
|
|
257
|
+
expect(true).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('static page rejects params', () => {
|
|
261
|
+
// @ts-expect-error — static page should not accept params
|
|
262
|
+
noop('home', { params: { foo: 'bar' } })
|
|
263
|
+
expect(true).toBe(true)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('dynamic page requires options with params', () => {
|
|
267
|
+
noop('users/detail', { params: { userId: '1' } })
|
|
268
|
+
|
|
269
|
+
// @ts-expect-error — missing required options with params
|
|
270
|
+
noop('users/detail')
|
|
271
|
+
expect(true).toBe(true)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('dynamic page requires correct param names', () => {
|
|
275
|
+
// @ts-expect-error — wrong param name (id instead of userId)
|
|
276
|
+
noop('users/detail', { params: { id: '1' } })
|
|
277
|
+
expect(true).toBe(true)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('dynamic page rejects params passed directly as options (without params wrapper)', () => {
|
|
281
|
+
// @ts-expect-error — { userId: '1' } is not valid, must be { params: { userId: '1' } }
|
|
282
|
+
noop('users/detail', { userId: '1' })
|
|
283
|
+
expect(true).toBe(true)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('multi-param page requires all params', () => {
|
|
287
|
+
noop('users/posts', { params: { userId: '1', postId: '2' } })
|
|
288
|
+
|
|
289
|
+
// @ts-expect-error — missing postId
|
|
290
|
+
noop('users/posts', { params: { userId: '1' } })
|
|
291
|
+
expect(true).toBe(true)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('rejects unregistered pages', () => {
|
|
295
|
+
// @ts-expect-error — page not in BuzolaPageMap
|
|
296
|
+
noop('unknown')
|
|
297
|
+
expect(true).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('allows NavigateOptions alongside params', () => {
|
|
301
|
+
noop('users/detail', { params: { userId: '1' }, replace: true })
|
|
302
|
+
expect(true).toBe(true)
|
|
303
|
+
})
|
|
304
|
+
})
|
package/src/engine/matcher.ts
CHANGED
|
@@ -3,33 +3,58 @@ import type { RouteMatch, RouteNode } from './types.js'
|
|
|
3
3
|
/**
|
|
4
4
|
* Match a URL against the route tree.
|
|
5
5
|
* Returns an array of RouteMatch from root layout to leaf page, or null if no match.
|
|
6
|
-
*
|
|
6
|
+
* When multiple routes match, selects the most specific one (fewest dynamic params).
|
|
7
7
|
*/
|
|
8
8
|
export function matchRoutes(nodes: RouteNode[], url: URL): RouteMatch[] | null {
|
|
9
9
|
const pathname = url.pathname
|
|
10
|
+
return pickBestMatch(nodes, pathname)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find the best match among sibling nodes.
|
|
15
|
+
* Tries all siblings and picks the most specific match (fewest dynamic params).
|
|
16
|
+
*/
|
|
17
|
+
function pickBestMatch(nodes: RouteNode[], pathname: string): RouteMatch[] | null {
|
|
18
|
+
let best: RouteMatch[] | null = null
|
|
19
|
+
let bestParamCount = Infinity
|
|
10
20
|
|
|
11
21
|
for (const node of nodes) {
|
|
12
22
|
const result = matchNode(node, pathname)
|
|
13
|
-
if (result)
|
|
23
|
+
if (result) {
|
|
24
|
+
const paramCount = countParams(result)
|
|
25
|
+
if (paramCount < bestParamCount) {
|
|
26
|
+
best = result
|
|
27
|
+
bestParamCount = paramCount
|
|
28
|
+
if (paramCount === 0) break // Can't do better than zero params
|
|
29
|
+
}
|
|
30
|
+
}
|
|
14
31
|
}
|
|
15
32
|
|
|
16
|
-
return
|
|
33
|
+
return best
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Count total dynamic params across all matches in a chain.
|
|
38
|
+
*/
|
|
39
|
+
function countParams(matches: RouteMatch[]): number {
|
|
40
|
+
let count = 0
|
|
41
|
+
for (const match of matches) {
|
|
42
|
+
count += Object.keys(match.params).length
|
|
43
|
+
}
|
|
44
|
+
return count
|
|
17
45
|
}
|
|
18
46
|
|
|
19
47
|
function matchNode(node: RouteNode, pathname: string): RouteMatch[] | null {
|
|
20
48
|
// Layout nodes: try to match children, prepend self if any child matches
|
|
21
49
|
if (node.isLayout && !node.isIndex) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
params: extractLayoutParams(node, pathname),
|
|
29
|
-
pathname,
|
|
30
|
-
}
|
|
31
|
-
return [layoutMatch, ...childMatch]
|
|
50
|
+
const childMatch = pickBestMatch(node.children, pathname)
|
|
51
|
+
if (childMatch) {
|
|
52
|
+
const layoutMatch: RouteMatch = {
|
|
53
|
+
node,
|
|
54
|
+
params: extractLayoutParams(node, pathname),
|
|
55
|
+
pathname,
|
|
32
56
|
}
|
|
57
|
+
return [layoutMatch, ...childMatch]
|
|
33
58
|
}
|
|
34
59
|
return null
|
|
35
60
|
}
|
|
@@ -43,11 +68,9 @@ function matchNode(node: RouteNode, pathname: string): RouteMatch[] | null {
|
|
|
43
68
|
|
|
44
69
|
// If this node has children (e.g., a page with nested routes), try them too
|
|
45
70
|
if (node.children.length > 0) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return [match, ...childMatch]
|
|
50
|
-
}
|
|
71
|
+
const childMatch = pickBestMatch(node.children, pathname)
|
|
72
|
+
if (childMatch) {
|
|
73
|
+
return [match, ...childMatch]
|
|
51
74
|
}
|
|
52
75
|
}
|
|
53
76
|
|
package/src/index.ts
CHANGED