@furystack/shades 13.2.2 → 14.0.0

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/esm/components/define-nested-routes.d.ts +27 -0
  3. package/esm/components/define-nested-routes.d.ts.map +1 -0
  4. package/esm/components/define-nested-routes.js +26 -0
  5. package/esm/components/define-nested-routes.js.map +1 -0
  6. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  7. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  8. package/esm/components/define-nested-routes.spec.js +81 -0
  9. package/esm/components/define-nested-routes.spec.js.map +1 -0
  10. package/esm/components/index.d.ts +3 -3
  11. package/esm/components/index.d.ts.map +1 -1
  12. package/esm/components/index.js +3 -3
  13. package/esm/components/index.js.map +1 -1
  14. package/esm/components/nested-hooks.d.ts +62 -0
  15. package/esm/components/nested-hooks.d.ts.map +1 -0
  16. package/esm/components/nested-hooks.js +73 -0
  17. package/esm/components/nested-hooks.js.map +1 -0
  18. package/esm/components/nested-hooks.spec.d.ts +2 -0
  19. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  20. package/esm/components/nested-hooks.spec.js +211 -0
  21. package/esm/components/nested-hooks.spec.js.map +1 -0
  22. package/esm/components/nested-navigate.d.ts +52 -21
  23. package/esm/components/nested-navigate.d.ts.map +1 -1
  24. package/esm/components/nested-navigate.js +32 -17
  25. package/esm/components/nested-navigate.js.map +1 -1
  26. package/esm/components/nested-navigate.spec.js +102 -12
  27. package/esm/components/nested-navigate.spec.js.map +1 -1
  28. package/esm/components/nested-replace.d.ts +36 -0
  29. package/esm/components/nested-replace.d.ts.map +1 -0
  30. package/esm/components/nested-replace.js +38 -0
  31. package/esm/components/nested-replace.js.map +1 -0
  32. package/esm/components/nested-replace.spec.d.ts +2 -0
  33. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  34. package/esm/components/nested-replace.spec.js +59 -0
  35. package/esm/components/nested-replace.spec.js.map +1 -0
  36. package/esm/components/nested-route-link.d.ts +36 -26
  37. package/esm/components/nested-route-link.d.ts.map +1 -1
  38. package/esm/components/nested-route-link.js +17 -19
  39. package/esm/components/nested-route-link.js.map +1 -1
  40. package/esm/components/nested-route-link.spec.js +61 -11
  41. package/esm/components/nested-route-link.spec.js.map +1 -1
  42. package/esm/components/nested-route-types.d.ts +79 -2
  43. package/esm/components/nested-route-types.d.ts.map +1 -1
  44. package/esm/components/nested-router.d.ts +77 -7
  45. package/esm/components/nested-router.d.ts.map +1 -1
  46. package/esm/components/nested-router.js +137 -11
  47. package/esm/components/nested-router.js.map +1 -1
  48. package/esm/components/nested-router.spec.js +265 -34
  49. package/esm/components/nested-router.spec.js.map +1 -1
  50. package/esm/services/location-service.spec.js +33 -0
  51. package/esm/services/location-service.spec.js.map +1 -1
  52. package/esm/services/route-match-service.spec.js +4 -0
  53. package/esm/services/route-match-service.spec.js.map +1 -1
  54. package/esm/services/route-meta-utils.d.ts +28 -6
  55. package/esm/services/route-meta-utils.d.ts.map +1 -1
  56. package/esm/services/route-meta-utils.js +18 -4
  57. package/esm/services/route-meta-utils.js.map +1 -1
  58. package/esm/services/route-meta-utils.spec.js +57 -2
  59. package/esm/services/route-meta-utils.spec.js.map +1 -1
  60. package/esm/vnode.d.ts +1 -1
  61. package/esm/vnode.js +1 -1
  62. package/package.json +2 -2
  63. package/src/components/define-nested-routes.spec.ts +91 -0
  64. package/src/components/define-nested-routes.ts +27 -0
  65. package/src/components/index.ts +3 -3
  66. package/src/components/nested-hooks.spec.ts +254 -0
  67. package/src/components/nested-hooks.ts +109 -0
  68. package/src/components/nested-navigate.spec.ts +128 -25
  69. package/src/components/nested-navigate.ts +75 -26
  70. package/src/components/nested-replace.spec.ts +88 -0
  71. package/src/components/nested-replace.ts +46 -0
  72. package/src/components/nested-route-link.spec.tsx +84 -16
  73. package/src/components/nested-route-link.tsx +53 -28
  74. package/src/components/nested-route-types.ts +82 -2
  75. package/src/components/nested-router.spec.tsx +316 -33
  76. package/src/components/nested-router.tsx +182 -22
  77. package/src/services/location-service.spec.ts +39 -0
  78. package/src/services/route-match-service.spec.ts +4 -0
  79. package/src/services/route-meta-utils.spec.ts +75 -7
  80. package/src/services/route-meta-utils.ts +44 -15
  81. package/src/vnode.ts +1 -1
  82. package/esm/components/link-to-route.d.ts +0 -10
  83. package/esm/components/link-to-route.d.ts.map +0 -1
  84. package/esm/components/link-to-route.js +0 -16
  85. package/esm/components/link-to-route.js.map +0 -1
  86. package/esm/components/link-to-route.spec.d.ts +0 -2
  87. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  88. package/esm/components/link-to-route.spec.js +0 -30
  89. package/esm/components/link-to-route.spec.js.map +0 -1
  90. package/esm/components/route-link.d.ts +0 -10
  91. package/esm/components/route-link.d.ts.map +0 -1
  92. package/esm/components/route-link.js +0 -25
  93. package/esm/components/route-link.js.map +0 -1
  94. package/esm/components/route-link.spec.d.ts +0 -2
  95. package/esm/components/route-link.spec.d.ts.map +0 -1
  96. package/esm/components/route-link.spec.js +0 -34
  97. package/esm/components/route-link.spec.js.map +0 -1
  98. package/esm/components/router.d.ts +0 -44
  99. package/esm/components/router.d.ts.map +0 -1
  100. package/esm/components/router.js +0 -66
  101. package/esm/components/router.js.map +0 -1
  102. package/esm/components/router.spec.d.ts +0 -2
  103. package/esm/components/router.spec.d.ts.map +0 -1
  104. package/esm/components/router.spec.js +0 -161
  105. package/esm/components/router.spec.js.map +0 -1
  106. package/src/components/link-to-route.spec.tsx +0 -45
  107. package/src/components/link-to-route.tsx +0 -26
  108. package/src/components/route-link.spec.tsx +0 -44
  109. package/src/components/route-link.tsx +0 -29
  110. package/src/components/router.spec.tsx +0 -217
  111. package/src/components/router.tsx +0 -103
@@ -9,6 +9,7 @@ import { createComponent, setRenderMode } from '../shade-component.js'
9
9
  import { Shade } from '../shade.js'
10
10
  import type { ViewTransitionConfig } from '../view-transition.js'
11
11
  import { maybeViewTransition } from '../view-transition.js'
12
+ import type { HashLiterals, QueryValidator } from './nested-route-types.js'
12
13
 
13
14
  /**
14
15
  * Options passed to a dynamic title resolver function.
@@ -47,13 +48,27 @@ export interface NestedRouteMeta<TMatchResult = unknown> {
47
48
  * A single route entry in a NestedRouter configuration.
48
49
  * Unlike flat `Route`, the URL is the Record key (not a field), and the
49
50
  * `component` receives an `outlet` for rendering matched child content.
51
+ *
52
+ * Routes may additionally declare:
53
+ * - `query`: a validator that parses the deserialized query string into a
54
+ * typed shape; `component` receives the parsed value (or `null` when
55
+ * validation fails). The route still matches on path alone — an invalid
56
+ * query never prevents navigation.
57
+ * - `hash`: a readonly tuple of allowed URL hash literals; `component`
58
+ * receives the current hash when it matches one of the listed literals,
59
+ * or `undefined` otherwise.
60
+ *
50
61
  * @typeParam TMatchResult - The type of matched URL parameters
62
+ * @typeParam TQuery - The typed query shape parsed from the URL search string (defaults to `never`)
63
+ * @typeParam THash - The readonly tuple of allowed hash literals (defaults to `never`)
51
64
  */
52
- export type NestedRoute<TMatchResult = unknown> = {
65
+ export type NestedRoute<TMatchResult = unknown, TQuery = any, THash extends HashLiterals = readonly any[]> = {
53
66
  meta?: NestedRouteMeta<TMatchResult>
54
67
  component: (options: {
55
68
  currentUrl: string
56
69
  match: MatchResult<TMatchResult extends object ? TMatchResult : object>
70
+ query: TQuery | null
71
+ hash: THash[number] | undefined
57
72
  outlet?: JSX.Element
58
73
  }) => JSX.Element
59
74
  routingOptions?: MatchOptions
@@ -70,8 +85,21 @@ export type NestedRoute<TMatchResult = unknown> = {
70
85
  * View Transition API when `viewTransition` is enabled.
71
86
  */
72
87
  onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
73
- children?: Record<string, NestedRoute<any>>
88
+ children?: Record<string, NestedRoute<any, any, any>>
74
89
  viewTransition?: boolean | ViewTransitionConfig
90
+ /**
91
+ * Optional validator that narrows the deserialized URL query string into a
92
+ * typed shape. Return `null` when the URL's query does not satisfy the route's
93
+ * contract — the route still matches on path, but `component` receives `null`.
94
+ */
95
+ query?: QueryValidator<TQuery>
96
+ /**
97
+ * Optional readonly tuple of URL hash literals the route understands. Declare
98
+ * with `as const` to preserve literal types, e.g. `hash: ['tab1', 'tab2'] as const`.
99
+ * The router forwards the current hash to `component` only when it matches one
100
+ * of the listed literals; otherwise `component.hash` is `undefined`.
101
+ */
102
+ hash?: THash
75
103
  }
76
104
 
77
105
  /**
@@ -79,17 +107,21 @@ export type NestedRoute<TMatchResult = unknown> = {
79
107
  * Routes are defined as a Record where keys are URL patterns.
80
108
  */
81
109
  export type NestedRouterProps = {
82
- routes: Record<string, NestedRoute<any>>
110
+ routes: Record<string, NestedRoute<any, any, any>>
83
111
  notFound?: JSX.Element
84
112
  viewTransition?: boolean | ViewTransitionConfig
85
113
  }
86
114
 
87
115
  /**
88
- * A single entry in a match chain, pairing a matched route with its match result.
116
+ * A single entry in a match chain, pairing a matched route with its match
117
+ * result and the typed `query` / `hash` values derived from the URL for that
118
+ * route's declared schema.
89
119
  */
90
120
  export type MatchChainEntry = {
91
- route: NestedRoute<unknown>
121
+ route: NestedRoute<unknown, any, any>
92
122
  match: MatchResult<object>
123
+ query: unknown
124
+ hash: string | undefined
93
125
  }
94
126
 
95
127
  /**
@@ -112,12 +144,15 @@ export type NestedRouterState = {
112
144
  *
113
145
  * For leaf routes (no children), only exact matching is used.
114
146
  *
147
+ * The returned entries contain placeholder `query: null` / `hash: undefined`
148
+ * values; callers are expected to populate them via {@link enrichMatchChain}.
149
+ *
115
150
  * @param routes - The route definitions to match against
116
151
  * @param currentUrl - The URL path to match
117
152
  * @returns An array of matched chain entries from outermost to innermost, or null if no match
118
153
  */
119
154
  export const buildMatchChain = (
120
- routes: Record<string, NestedRoute<any>>,
155
+ routes: Record<string, NestedRoute<any, any, any>>,
121
156
  currentUrl: string,
122
157
  ): MatchChainEntry[] | null => {
123
158
  for (const [pattern, route] of Object.entries(routes)) {
@@ -139,20 +174,20 @@ export const buildMatchChain = (
139
174
 
140
175
  const childChain = buildMatchChain(route.children, remainingUrl)
141
176
  if (childChain) {
142
- return [{ route, match: prefixResult }, ...childChain]
177
+ return [{ route, match: prefixResult, query: null, hash: undefined }, ...childChain]
143
178
  }
144
179
  }
145
180
 
146
181
  const exactMatchFn = match(pattern, route.routingOptions)
147
182
  const exactResult = exactMatchFn(currentUrl)
148
183
  if (exactResult) {
149
- return [{ route, match: exactResult }]
184
+ return [{ route, match: exactResult, query: null, hash: undefined }]
150
185
  }
151
186
  } else {
152
187
  const matchFn = match(pattern, route.routingOptions)
153
188
  const matchResult = matchFn(currentUrl)
154
189
  if (matchResult) {
155
- return [{ route, match: matchResult }]
190
+ return [{ route, match: matchResult, query: null, hash: undefined }]
156
191
  }
157
192
  }
158
193
  }
@@ -161,7 +196,44 @@ export const buildMatchChain = (
161
196
  }
162
197
 
163
198
  /**
164
- * Finds the first index where two match chains diverge.
199
+ * Populates each chain entry's `query` and `hash` fields by running the route's
200
+ * declared validator against the URL's deserialized query string, and matching
201
+ * the current URL hash against the route's declared literal tuple.
202
+ *
203
+ * Entries whose route declares neither `query` nor `hash` are returned with
204
+ * `query: null` / `hash: undefined`.
205
+ *
206
+ * When no entry in the chain declares either `query` or `hash`, the input
207
+ * array is returned unchanged to avoid a per-navigation allocation on the
208
+ * common path-only case.
209
+ *
210
+ * @param chain - The chain produced by {@link buildMatchChain}
211
+ * @param deserializedSearch - The deserialized URL query string
212
+ * @param currentHash - The current URL hash (without the leading `#`)
213
+ */
214
+ export const enrichMatchChain = (
215
+ chain: MatchChainEntry[],
216
+ deserializedSearch: Record<string, unknown>,
217
+ currentHash: string,
218
+ ): MatchChainEntry[] => {
219
+ const hasAnyDeclaration = chain.some((entry) => entry.route.query || entry.route.hash)
220
+ if (!hasAnyDeclaration) return chain
221
+
222
+ return chain.map((entry) => {
223
+ const validator = entry.route.query as QueryValidator<unknown> | undefined
224
+ const query: unknown = validator ? validator(deserializedSearch) : null
225
+ const declaredHash = entry.route.hash as readonly string[] | undefined
226
+ const hash = declaredHash?.includes(currentHash) ? currentHash : undefined
227
+ return { ...entry, query, hash }
228
+ })
229
+ }
230
+
231
+ /**
232
+ * Finds the first index where two match chains diverge, considering route
233
+ * identity and matched path parameters only. Used to scope lifecycle hooks
234
+ * (`onLeave` / `onVisit`) so that a query string or hash change does not
235
+ * fire spurious mount / unmount callbacks.
236
+ *
165
237
  * Returns the length of the shorter chain if one is a prefix of the other.
166
238
  */
167
239
  export const findDivergenceIndex = (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]): number => {
@@ -177,6 +249,56 @@ export const findDivergenceIndex = (oldChain: MatchChainEntry[], newChain: Match
177
249
  return minLength
178
250
  }
179
251
 
252
+ /**
253
+ * Shallow structural equality for query values. Handles the shapes produced by
254
+ * route-declared query validators: primitives, plain objects (by own enumerable
255
+ * string keys, recursively shallow-compared), and arrays (by index).
256
+ *
257
+ * Nested objects / arrays are compared shallowly one level deep, then fall back
258
+ * to `Object.is` — sufficient for the typed-query shapes the router surfaces,
259
+ * and order-independent unlike `JSON.stringify`.
260
+ */
261
+ const isShallowEqual = (a: unknown, b: unknown): boolean => {
262
+ if (Object.is(a, b)) return true
263
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
264
+
265
+ if (Array.isArray(a)) {
266
+ if (!Array.isArray(b) || a.length !== b.length) return false
267
+ for (let i = 0; i < a.length; i++) {
268
+ if (!Object.is(a[i], b[i])) return false
269
+ }
270
+ return true
271
+ }
272
+ if (Array.isArray(b)) return false
273
+
274
+ const aKeys = Object.keys(a as Record<string, unknown>)
275
+ const bKeys = Object.keys(b as Record<string, unknown>)
276
+ if (aKeys.length !== bKeys.length) return false
277
+ for (const key of aKeys) {
278
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false
279
+ if (!Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false
280
+ }
281
+ return true
282
+ }
283
+
284
+ /**
285
+ * Returns true when any chain entry differs in its `query` value or `hash`
286
+ * segment, ignoring path-level fields (route identity and params). Used to
287
+ * force a re-render when the URL's query string or hash changes without the
288
+ * matched route chain itself changing.
289
+ *
290
+ * Query values are compared with a key-order-independent shallow equality —
291
+ * sufficient for the typed shapes a route's `query` validator surfaces.
292
+ */
293
+ export const hasQueryOrHashChanged = (oldChain: MatchChainEntry[], newChain: MatchChainEntry[]): boolean => {
294
+ const minLength = Math.min(oldChain.length, newChain.length)
295
+ for (let i = 0; i < minLength; i++) {
296
+ if (oldChain[i].hash !== newChain[i].hash) return true
297
+ if (!isShallowEqual(oldChain[i].query, newChain[i].query)) return true
298
+ }
299
+ return false
300
+ }
301
+
180
302
  /**
181
303
  * The result of rendering a match chain, containing both the fully composed
182
304
  * JSX tree and per-entry elements for scoped lifecycle animations.
@@ -207,6 +329,8 @@ export const renderMatchChain = (chain: MatchChainEntry[], currentUrl: string):
207
329
  outlet = entry.route.component({
208
330
  currentUrl,
209
331
  match: entry.match,
332
+ query: entry.query,
333
+ hash: entry.hash,
210
334
  outlet,
211
335
  })
212
336
  chainElements[i] = outlet
@@ -246,6 +370,10 @@ export const resolveViewTransition = (
246
370
  * Routes are defined as a Record where keys are URL patterns (following the
247
371
  * RestApi pattern). The matching algorithm builds a chain from outermost to
248
372
  * innermost route, then renders inside-out so each parent wraps its child.
373
+ *
374
+ * The router subscribes to path, query string and hash changes; path-level
375
+ * changes drive `onLeave` / `onVisit` lifecycle hooks while query / hash
376
+ * changes re-render the chain without firing lifecycle callbacks.
249
377
  */
250
378
  export const NestedRouter = Shade<NestedRouterProps>({
251
379
  customElementName: 'shade-nested-router',
@@ -258,19 +386,26 @@ export const NestedRouter = Shade<NestedRouterProps>({
258
386
  chainElements: [],
259
387
  })
260
388
 
389
+ const locationService = injector.getInstance(LocationService)
390
+
261
391
  const updateUrl = async (currentUrl: string) => {
262
392
  const [lastState] = useState<NestedRouterState>('routerState', state)
263
393
  const { matchChain: lastChain, chainElements: lastChainElements } = lastState
264
394
  try {
265
- const newChain = buildMatchChain(options.props.routes, currentUrl)
395
+ const rawChain = buildMatchChain(options.props.routes, currentUrl)
396
+
397
+ if (rawChain) {
398
+ const deserializedSearch = locationService.onDeserializedLocationSearchChanged.getValue()
399
+ const currentHash = locationService.onLocationHashChanged.getValue()
400
+ const newChain = enrichMatchChain(rawChain, deserializedSearch, currentHash)
266
401
 
267
- if (newChain) {
268
402
  const lastChainEntries = lastChain ?? []
269
403
  const divergeIndex = findDivergenceIndex(lastChainEntries, newChain)
270
- const hasChanged =
404
+ const hasPathChanged =
271
405
  divergeIndex < lastChainEntries.length ||
272
406
  divergeIndex < newChain.length ||
273
407
  lastChainEntries.length !== newChain.length
408
+ const hasChanged = hasPathChanged || hasQueryOrHashChanged(lastChainEntries, newChain)
274
409
 
275
410
  if (hasChanged) {
276
411
  const version = ++versionRef.current
@@ -328,15 +463,40 @@ export const NestedRouter = Shade<NestedRouterProps>({
328
463
  }
329
464
  }
330
465
 
331
- const [locationPath] = useObservable(
332
- 'locationPathChanged',
333
- injector.getInstance(LocationService).onLocationPathChanged,
334
- {
335
- onChange: (newValue) => {
336
- void updateUrl(newValue)
337
- },
338
- },
339
- )
466
+ /**
467
+ * A single `LocationService.navigate` call synchronously fires three
468
+ * observables (path, search, hash). Without coalescing, each would kick
469
+ * off its own `updateUrl` and rely on `versionRef` to cancel the two
470
+ * that lose the race.
471
+ *
472
+ * We dedupe by the composed URL key: each observer fires after the
473
+ * browser's location has already been updated, so the key read at fire
474
+ * time is the final target URL. The first observer in the burst kicks
475
+ * off `updateUrl`; the remaining two see the same key and short-circuit.
476
+ */
477
+ const getUrlKey = () =>
478
+ `${locationService.onLocationPathChanged.getValue()}?${locationService.onLocationSearchChanged.getValue()}#${locationService.onLocationHashChanged.getValue()}`
479
+
480
+ const [lastKeyRef] = useState('navLastKey', { current: getUrlKey() })
481
+ const scheduleUpdate = () => {
482
+ const key = getUrlKey()
483
+ if (key === lastKeyRef.current) return
484
+ lastKeyRef.current = key
485
+ void updateUrl(locationService.onLocationPathChanged.getValue())
486
+ }
487
+
488
+ const [locationPath] = useObservable('locationPathChanged', locationService.onLocationPathChanged, {
489
+ onChange: scheduleUpdate,
490
+ })
491
+
492
+ useObservable('locationSearchChanged', locationService.onDeserializedLocationSearchChanged, {
493
+ onChange: scheduleUpdate,
494
+ })
495
+
496
+ useObservable('locationHashChanged', locationService.onLocationHashChanged, {
497
+ onChange: scheduleUpdate,
498
+ })
499
+
340
500
  void updateUrl(locationPath)
341
501
  return state.jsx
342
502
  },
@@ -49,6 +49,45 @@ describe('LocationService', () => {
49
49
  })
50
50
  })
51
51
 
52
+ describe('replace', () => {
53
+ it('Should update the observable path without pushing a new history entry', async () => {
54
+ await usingAsync(new Injector(), async (i) => {
55
+ const s = i.getInstance(LocationService)
56
+ const lengthBefore = history.length
57
+ s.replace('/replaced')
58
+ expect(s.onLocationPathChanged.getValue()).toBe('/replaced')
59
+ expect(history.length).toBe(lengthBefore)
60
+ })
61
+ })
62
+
63
+ it('Should call history.replaceState rather than pushState', async () => {
64
+ await usingAsync(new Injector(), async (i) => {
65
+ const s = i.getInstance(LocationService)
66
+ const pushSpy = vi.spyOn(history, 'pushState')
67
+ const replaceSpy = vi.spyOn(history, 'replaceState')
68
+
69
+ s.replace('/replaced-2')
70
+
71
+ expect(replaceSpy).toHaveBeenCalledTimes(1)
72
+ expect(replaceSpy).toHaveBeenCalledWith(null, '', '/replaced-2')
73
+ expect(pushSpy).not.toHaveBeenCalled()
74
+
75
+ pushSpy.mockRestore()
76
+ replaceSpy.mockRestore()
77
+ })
78
+ })
79
+
80
+ it('Should notify path subscribers after replace', async () => {
81
+ await usingAsync(new Injector(), async (i) => {
82
+ const s = i.getInstance(LocationService)
83
+ const onLocationChanged = vi.fn()
84
+ s.onLocationPathChanged.subscribe(onLocationChanged)
85
+ s.replace('/notify')
86
+ expect(onLocationChanged).toHaveBeenCalledWith('/notify')
87
+ })
88
+ })
89
+ })
90
+
52
91
  describe('useSearchParam', () => {
53
92
  it('Should create observables lazily', async () => {
54
93
  await usingAsync(new Injector(), async (i) => {
@@ -14,6 +14,8 @@ describe('RouteMatchService', () => {
14
14
  {
15
15
  route: { component: () => ({}) as JSX.Element },
16
16
  match: { path: '/about', params: {} },
17
+ query: null,
18
+ hash: undefined,
17
19
  },
18
20
  ]
19
21
  service.currentMatchChain.setValue(chain)
@@ -29,6 +31,8 @@ describe('RouteMatchService', () => {
29
31
  {
30
32
  route: { meta: { title: 'Home' }, component: () => ({}) as JSX.Element },
31
33
  match: { path: '/', params: {} },
34
+ query: null,
35
+ hash: undefined,
32
36
  },
33
37
  ]
34
38
  service.currentMatchChain.setValue(chain)
@@ -1,7 +1,15 @@
1
1
  import { Injector } from '@furystack/inject'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, expectTypeOf, it } from 'vitest'
3
3
  import type { MatchChainEntry, NestedRoute } from '../components/nested-router.js'
4
- import { buildDocumentTitle, extractNavTree, resolveRouteTitle, resolveRouteTitles } from './route-meta-utils.js'
4
+ import {
5
+ buildDocumentTitle,
6
+ extractNavTree,
7
+ type NavTreeNode,
8
+ resolveRouteTitle,
9
+ resolveRouteTitles,
10
+ } from './route-meta-utils.js'
11
+
12
+ type TestRoute = Pick<NestedRoute<unknown, any, any>, 'component'>
5
13
 
6
14
  describe('resolveRouteTitle', () => {
7
15
  const injector = new Injector()
@@ -10,6 +18,8 @@ describe('resolveRouteTitle', () => {
10
18
  const entry: MatchChainEntry = {
11
19
  route: { component: () => ({}) as JSX.Element },
12
20
  match: { path: '/about', params: {} },
21
+ query: null,
22
+ hash: undefined,
13
23
  }
14
24
  expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
15
25
  })
@@ -18,6 +28,8 @@ describe('resolveRouteTitle', () => {
18
28
  const entry: MatchChainEntry = {
19
29
  route: { meta: {}, component: () => ({}) as JSX.Element },
20
30
  match: { path: '/about', params: {} },
31
+ query: null,
32
+ hash: undefined,
21
33
  }
22
34
  expect(await resolveRouteTitle(entry, injector)).toBeUndefined()
23
35
  })
@@ -26,6 +38,8 @@ describe('resolveRouteTitle', () => {
26
38
  const entry: MatchChainEntry = {
27
39
  route: { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
28
40
  match: { path: '/about', params: {} },
41
+ query: null,
42
+ hash: undefined,
29
43
  }
30
44
  expect(await resolveRouteTitle(entry, injector)).toBe('About')
31
45
  })
@@ -37,6 +51,8 @@ describe('resolveRouteTitle', () => {
37
51
  component: () => ({}) as JSX.Element,
38
52
  },
39
53
  match: { path: '/users/42', params: { id: '42' } },
54
+ query: null,
55
+ hash: undefined,
40
56
  }
41
57
  expect(await resolveRouteTitle(entry, injector)).toBe('User 42')
42
58
  })
@@ -53,6 +69,8 @@ describe('resolveRouteTitle', () => {
53
69
  component: () => ({}) as JSX.Element,
54
70
  },
55
71
  match: { path: '/movies/7', params: { id: '7' } },
72
+ query: null,
73
+ hash: undefined,
56
74
  }
57
75
  expect(await resolveRouteTitle(entry, injector)).toBe('Movie 7')
58
76
  })
@@ -66,6 +84,8 @@ describe('resolveRouteTitle', () => {
66
84
  component: () => ({}) as JSX.Element,
67
85
  },
68
86
  match: { path: '/test', params: {} },
87
+ query: null,
88
+ hash: undefined,
69
89
  }
70
90
  expect(await resolveRouteTitle(entry, injector)).toBe('has-injector')
71
91
  })
@@ -79,10 +99,14 @@ describe('resolveRouteTitles', () => {
79
99
  {
80
100
  route: { meta: { title: 'Media' }, component: () => ({}) as JSX.Element },
81
101
  match: { path: '/media', params: {} },
102
+ query: null,
103
+ hash: undefined,
82
104
  },
83
105
  {
84
106
  route: { meta: { title: 'Movies' }, component: () => ({}) as JSX.Element },
85
107
  match: { path: '/movies', params: {} },
108
+ query: null,
109
+ hash: undefined,
86
110
  },
87
111
  {
88
112
  route: {
@@ -90,6 +114,8 @@ describe('resolveRouteTitles', () => {
90
114
  component: () => ({}) as JSX.Element,
91
115
  },
92
116
  match: { path: '/7', params: { id: '7' } },
117
+ query: null,
118
+ hash: undefined,
93
119
  },
94
120
  ]
95
121
  const titles = await resolveRouteTitles(chain, injector)
@@ -106,10 +132,14 @@ describe('resolveRouteTitles', () => {
106
132
  {
107
133
  route: { meta: { title: 'Root' }, component: () => ({}) as JSX.Element },
108
134
  match: { path: '/', params: {} },
135
+ query: null,
136
+ hash: undefined,
109
137
  },
110
138
  {
111
139
  route: { component: () => ({}) as JSX.Element },
112
140
  match: { path: '/child', params: {} },
141
+ query: null,
142
+ hash: undefined,
113
143
  },
114
144
  ]
115
145
  const titles = await resolveRouteTitles(chain, injector)
@@ -155,7 +185,7 @@ describe('buildDocumentTitle', () => {
155
185
 
156
186
  describe('extractNavTree', () => {
157
187
  it('should extract a flat route tree', () => {
158
- const routes: Record<string, NestedRoute<unknown>> = {
188
+ const routes: Record<string, NestedRoute<any, any, any>> = {
159
189
  '/about': { meta: { title: 'About' }, component: () => ({}) as JSX.Element },
160
190
  '/contact': { meta: { title: 'Contact' }, component: () => ({}) as JSX.Element },
161
191
  }
@@ -167,7 +197,7 @@ describe('extractNavTree', () => {
167
197
  })
168
198
 
169
199
  it('should extract nested routes recursively', () => {
170
- const routes: Record<string, NestedRoute<unknown>> = {
200
+ const routes: Record<string, NestedRoute<any, any, any>> = {
171
201
  '/media': {
172
202
  meta: { title: 'Media' },
173
203
  component: () => ({}) as JSX.Element,
@@ -197,7 +227,7 @@ describe('extractNavTree', () => {
197
227
  })
198
228
 
199
229
  it('should handle root "/" parent path correctly', () => {
200
- const routes: Record<string, NestedRoute<unknown>> = {
230
+ const routes: Record<string, NestedRoute<any, any, any>> = {
201
231
  '/': {
202
232
  meta: { title: 'Home' },
203
233
  component: () => ({}) as JSX.Element,
@@ -212,7 +242,7 @@ describe('extractNavTree', () => {
212
242
  })
213
243
 
214
244
  it('should compute correct fullPath for deeply nested routes (3+ levels)', () => {
215
- const routes: Record<string, NestedRoute<unknown>> = {
245
+ const routes: Record<string, NestedRoute<any, any, any>> = {
216
246
  '/a': {
217
247
  meta: { title: 'A' },
218
248
  component: () => ({}) as JSX.Element,
@@ -234,10 +264,48 @@ describe('extractNavTree', () => {
234
264
  })
235
265
 
236
266
  it('should include routes without meta', () => {
237
- const routes: Record<string, NestedRoute<unknown>> = {
267
+ const routes: Record<string, NestedRoute<any, any, any>> = {
238
268
  '/hidden': { component: () => ({}) as JSX.Element },
239
269
  }
240
270
  const tree = extractNavTree(routes)
241
271
  expect(tree[0].meta).toBeUndefined()
242
272
  })
273
+
274
+ describe('typed output', () => {
275
+ it('should narrow pattern and fullPath for a flat typed tree', () => {
276
+ const routes = {
277
+ '/about': { component: () => ({}) as JSX.Element } satisfies TestRoute,
278
+ '/contact': { component: () => ({}) as JSX.Element } satisfies TestRoute,
279
+ } satisfies Record<string, NestedRoute<any, any, any>>
280
+
281
+ const tree = extractNavTree(routes)
282
+ expectTypeOf(tree[0].pattern).toEqualTypeOf<'/about' | '/contact'>()
283
+ expectTypeOf(tree[0].fullPath).toEqualTypeOf<'/about' | '/contact'>()
284
+ })
285
+
286
+ it('should include nested composed paths in the fullPath union', () => {
287
+ const routes = {
288
+ '/media': {
289
+ component: () => ({}) as JSX.Element,
290
+ children: {
291
+ '/movies': { component: () => ({}) as JSX.Element } satisfies TestRoute,
292
+ '/music': { component: () => ({}) as JSX.Element } satisfies TestRoute,
293
+ },
294
+ },
295
+ } satisfies Record<string, NestedRoute<any, any, any>>
296
+
297
+ const tree = extractNavTree(routes)
298
+ expectTypeOf(tree[0].fullPath).toEqualTypeOf<'/media' | '/media/movies' | '/media/music'>()
299
+ expectTypeOf(tree[0].children).toEqualTypeOf<Array<NavTreeNode<typeof routes>> | undefined>()
300
+ })
301
+
302
+ it('should preserve backward compatibility with the widened default', () => {
303
+ const node: NavTreeNode = {
304
+ pattern: '/anything',
305
+ fullPath: '/anything',
306
+ }
307
+ expectTypeOf(node.pattern).toEqualTypeOf<string>()
308
+ expectTypeOf(node.fullPath).toEqualTypeOf<string>()
309
+ })
310
+ })
243
311
  })
@@ -1,5 +1,6 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
 
3
+ import type { ExtractRoutePaths } from '../components/nested-route-types.js'
3
4
  import type { MatchChainEntry, NestedRoute, NestedRouteMeta } from '../components/nested-router.js'
4
5
 
5
6
  /**
@@ -57,29 +58,57 @@ export const buildDocumentTitle = (
57
58
 
58
59
  /**
59
60
  * A node in a navigation tree extracted from route definitions.
61
+ *
62
+ * When {@link extractNavTree} is called with an explicit route tree type
63
+ * parameter (e.g. `extractNavTree<typeof appRoutes>(...)`), `pattern` narrows
64
+ * to the declared route keys and `fullPath` narrows to the union of all
65
+ * composed paths in the subtree. Without a type parameter the generic falls
66
+ * back to a widened shape so ad-hoc consumers keep compiling.
67
+ *
68
+ * @typeParam TRoutes - The route tree the node was extracted from
60
69
  */
61
- export type NavTreeNode = {
62
- pattern: string
63
- fullPath: string
70
+ export type NavTreeNode<
71
+ TRoutes extends Record<string, NestedRoute<any, any, any>> = Record<string, NestedRoute<any, any, any>>,
72
+ > = {
73
+ pattern: keyof TRoutes & string
74
+ fullPath: ExtractRoutePaths<TRoutes> & string
64
75
  meta?: NestedRouteMeta
65
- children?: NavTreeNode[]
76
+ children?: Array<NavTreeNode<TRoutes>>
66
77
  }
67
78
 
68
79
  /**
69
80
  * Extracts a navigation tree from route definitions.
70
- * Useful for rendering sidebar navigation or sitemap-like structures.
81
+ *
82
+ * The returned nodes preserve the route tree's static typing: `pattern` is
83
+ * narrowed to the declared keys of `TRoutes` and `fullPath` is narrowed to
84
+ * the union of composed paths reachable from `TRoutes` (as produced by
85
+ * {@link ExtractRoutePaths}). This makes the output a single, typed source
86
+ * of truth for sidebar navigation, breadcrumbs and other route-tree-aware
87
+ * UIs — without requiring `as` casts at the call site.
88
+ *
89
+ * Child nodes share their parent's `TRoutes` type parameter; the resulting
90
+ * union is a safe upper bound of the actual paths at any depth, which keeps
91
+ * the helper simple while still accepted by `createNestedRouteLink`,
92
+ * `createNestedNavigate` and `createNestedReplace` factories bound to the
93
+ * same route tree.
94
+ *
71
95
  * @param routes - The route definitions to extract from
72
96
  * @param parentPath - The parent path prefix (used internally for recursion)
73
97
  * @returns An array of navigation tree nodes
74
98
  */
75
- export const extractNavTree = (routes: Record<string, NestedRoute<unknown>>, parentPath?: string): NavTreeNode[] => {
76
- return Object.entries(routes).map(([pattern, route]) => {
77
- const fullPath = parentPath ? `${parentPath === '/' ? '' : parentPath}${pattern}` : pattern
78
- return {
79
- pattern,
80
- fullPath,
81
- meta: route.meta,
82
- children: route.children ? extractNavTree(route.children, fullPath) : undefined,
83
- }
84
- })
99
+ export const extractNavTree = <TRoutes extends Record<string, NestedRoute<any, any, any>>>(
100
+ routes: TRoutes,
101
+ parentPath?: string,
102
+ ): Array<NavTreeNode<TRoutes>> => {
103
+ const build = (rs: Record<string, NestedRoute<any, any, any>>, pp?: string): Array<NavTreeNode<TRoutes>> =>
104
+ Object.entries(rs).map(([pattern, route]) => {
105
+ const fullPath = pp ? `${pp === '/' ? '' : pp}${pattern}` : pattern
106
+ return {
107
+ pattern,
108
+ fullPath,
109
+ meta: route.meta,
110
+ children: route.children ? build(route.children, fullPath) : undefined,
111
+ } as NavTreeNode<TRoutes>
112
+ })
113
+ return build(routes, parentPath)
85
114
  }
package/src/vnode.ts CHANGED
@@ -183,7 +183,7 @@ export const shallowEqual = (a: Record<string, unknown>, b: Record<string, unkno
183
183
  * VChild array suitable for `patchChildren`.
184
184
  *
185
185
  * Real DOM elements can appear here when component state stores elements created
186
- * outside renderMode (e.g. in async callbacks like Router's `updateUrl`).
186
+ * outside renderMode (e.g. in async callbacks like NestedRouter's `updateUrl`).
187
187
  */
188
188
  export const toVChildArray = (renderResult: unknown): VChild[] => {
189
189
  if (renderResult === null || renderResult === undefined) return []