@furystack/shades 13.2.1 → 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.
- package/CHANGELOG.md +150 -0
- package/esm/components/define-nested-routes.d.ts +27 -0
- package/esm/components/define-nested-routes.d.ts.map +1 -0
- package/esm/components/define-nested-routes.js +26 -0
- package/esm/components/define-nested-routes.js.map +1 -0
- package/esm/components/define-nested-routes.spec.d.ts +2 -0
- package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
- package/esm/components/define-nested-routes.spec.js +81 -0
- package/esm/components/define-nested-routes.spec.js.map +1 -0
- package/esm/components/index.d.ts +3 -3
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +3 -3
- package/esm/components/index.js.map +1 -1
- package/esm/components/nested-hooks.d.ts +62 -0
- package/esm/components/nested-hooks.d.ts.map +1 -0
- package/esm/components/nested-hooks.js +73 -0
- package/esm/components/nested-hooks.js.map +1 -0
- package/esm/components/nested-hooks.spec.d.ts +2 -0
- package/esm/components/nested-hooks.spec.d.ts.map +1 -0
- package/esm/components/nested-hooks.spec.js +211 -0
- package/esm/components/nested-hooks.spec.js.map +1 -0
- package/esm/components/nested-navigate.d.ts +52 -21
- package/esm/components/nested-navigate.d.ts.map +1 -1
- package/esm/components/nested-navigate.js +32 -17
- package/esm/components/nested-navigate.js.map +1 -1
- package/esm/components/nested-navigate.spec.js +102 -12
- package/esm/components/nested-navigate.spec.js.map +1 -1
- package/esm/components/nested-replace.d.ts +36 -0
- package/esm/components/nested-replace.d.ts.map +1 -0
- package/esm/components/nested-replace.js +38 -0
- package/esm/components/nested-replace.js.map +1 -0
- package/esm/components/nested-replace.spec.d.ts +2 -0
- package/esm/components/nested-replace.spec.d.ts.map +1 -0
- package/esm/components/nested-replace.spec.js +59 -0
- package/esm/components/nested-replace.spec.js.map +1 -0
- package/esm/components/nested-route-link.d.ts +36 -26
- package/esm/components/nested-route-link.d.ts.map +1 -1
- package/esm/components/nested-route-link.js +17 -19
- package/esm/components/nested-route-link.js.map +1 -1
- package/esm/components/nested-route-link.spec.js +61 -11
- package/esm/components/nested-route-link.spec.js.map +1 -1
- package/esm/components/nested-route-types.d.ts +79 -2
- package/esm/components/nested-route-types.d.ts.map +1 -1
- package/esm/components/nested-router.d.ts +77 -7
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +137 -11
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +265 -34
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/services/location-service.spec.js +33 -0
- package/esm/services/location-service.spec.js.map +1 -1
- package/esm/services/route-match-service.spec.js +4 -0
- package/esm/services/route-match-service.spec.js.map +1 -1
- package/esm/services/route-meta-utils.d.ts +28 -6
- package/esm/services/route-meta-utils.d.ts.map +1 -1
- package/esm/services/route-meta-utils.js +18 -4
- package/esm/services/route-meta-utils.js.map +1 -1
- package/esm/services/route-meta-utils.spec.js +57 -2
- package/esm/services/route-meta-utils.spec.js.map +1 -1
- package/esm/vnode.d.ts +1 -1
- package/esm/vnode.js +1 -1
- package/package.json +9 -9
- package/src/components/define-nested-routes.spec.ts +91 -0
- package/src/components/define-nested-routes.ts +27 -0
- package/src/components/index.ts +3 -3
- package/src/components/nested-hooks.spec.ts +254 -0
- package/src/components/nested-hooks.ts +109 -0
- package/src/components/nested-navigate.spec.ts +128 -25
- package/src/components/nested-navigate.ts +75 -26
- package/src/components/nested-replace.spec.ts +88 -0
- package/src/components/nested-replace.ts +46 -0
- package/src/components/nested-route-link.spec.tsx +84 -16
- package/src/components/nested-route-link.tsx +53 -28
- package/src/components/nested-route-types.ts +82 -2
- package/src/components/nested-router.spec.tsx +316 -33
- package/src/components/nested-router.tsx +182 -22
- package/src/services/location-service.spec.ts +39 -0
- package/src/services/route-match-service.spec.ts +4 -0
- package/src/services/route-meta-utils.spec.ts +75 -7
- package/src/services/route-meta-utils.ts +44 -15
- package/src/vnode.ts +1 -1
- package/esm/components/link-to-route.d.ts +0 -10
- package/esm/components/link-to-route.d.ts.map +0 -1
- package/esm/components/link-to-route.js +0 -16
- package/esm/components/link-to-route.js.map +0 -1
- package/esm/components/link-to-route.spec.d.ts +0 -2
- package/esm/components/link-to-route.spec.d.ts.map +0 -1
- package/esm/components/link-to-route.spec.js +0 -30
- package/esm/components/link-to-route.spec.js.map +0 -1
- package/esm/components/route-link.d.ts +0 -10
- package/esm/components/route-link.d.ts.map +0 -1
- package/esm/components/route-link.js +0 -25
- package/esm/components/route-link.js.map +0 -1
- package/esm/components/route-link.spec.d.ts +0 -2
- package/esm/components/route-link.spec.d.ts.map +0 -1
- package/esm/components/route-link.spec.js +0 -34
- package/esm/components/route-link.spec.js.map +0 -1
- package/esm/components/router.d.ts +0 -44
- package/esm/components/router.d.ts.map +0 -1
- package/esm/components/router.js +0 -66
- package/esm/components/router.js.map +0 -1
- package/esm/components/router.spec.d.ts +0 -2
- package/esm/components/router.spec.d.ts.map +0 -1
- package/esm/components/router.spec.js +0 -161
- package/esm/components/router.spec.js.map +0 -1
- package/src/components/link-to-route.spec.tsx +0 -45
- package/src/components/link-to-route.tsx +0 -26
- package/src/components/route-link.spec.tsx +0 -44
- package/src/components/route-link.tsx +0 -29
- package/src/components/router.spec.tsx +0 -217
- 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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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 {
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
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 =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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 []
|