@furystack/shades 13.2.2 → 15.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 (146) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/esm/component-factory.spec.d.ts.map +1 -1
  3. package/esm/component-factory.spec.js +2 -2
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/define-nested-routes.d.ts +27 -0
  6. package/esm/components/define-nested-routes.d.ts.map +1 -0
  7. package/esm/components/define-nested-routes.js +26 -0
  8. package/esm/components/define-nested-routes.js.map +1 -0
  9. package/esm/components/define-nested-routes.spec.d.ts +2 -0
  10. package/esm/components/define-nested-routes.spec.d.ts.map +1 -0
  11. package/esm/components/define-nested-routes.spec.js +81 -0
  12. package/esm/components/define-nested-routes.spec.js.map +1 -0
  13. package/esm/components/index.d.ts +3 -3
  14. package/esm/components/index.d.ts.map +1 -1
  15. package/esm/components/index.js +3 -3
  16. package/esm/components/index.js.map +1 -1
  17. package/esm/components/nested-hooks.d.ts +62 -0
  18. package/esm/components/nested-hooks.d.ts.map +1 -0
  19. package/esm/components/nested-hooks.js +73 -0
  20. package/esm/components/nested-hooks.js.map +1 -0
  21. package/esm/components/nested-hooks.spec.d.ts +2 -0
  22. package/esm/components/nested-hooks.spec.d.ts.map +1 -0
  23. package/esm/components/nested-hooks.spec.js +211 -0
  24. package/esm/components/nested-hooks.spec.js.map +1 -0
  25. package/esm/components/nested-navigate.d.ts +52 -21
  26. package/esm/components/nested-navigate.d.ts.map +1 -1
  27. package/esm/components/nested-navigate.js +32 -17
  28. package/esm/components/nested-navigate.js.map +1 -1
  29. package/esm/components/nested-navigate.spec.js +109 -19
  30. package/esm/components/nested-navigate.spec.js.map +1 -1
  31. package/esm/components/nested-replace.d.ts +36 -0
  32. package/esm/components/nested-replace.d.ts.map +1 -0
  33. package/esm/components/nested-replace.js +38 -0
  34. package/esm/components/nested-replace.js.map +1 -0
  35. package/esm/components/nested-replace.spec.d.ts +2 -0
  36. package/esm/components/nested-replace.spec.d.ts.map +1 -0
  37. package/esm/components/nested-replace.spec.js +59 -0
  38. package/esm/components/nested-replace.spec.js.map +1 -0
  39. package/esm/components/nested-route-link.d.ts +36 -26
  40. package/esm/components/nested-route-link.d.ts.map +1 -1
  41. package/esm/components/nested-route-link.js +18 -20
  42. package/esm/components/nested-route-link.js.map +1 -1
  43. package/esm/components/nested-route-link.spec.js +67 -17
  44. package/esm/components/nested-route-link.spec.js.map +1 -1
  45. package/esm/components/nested-route-types.d.ts +79 -2
  46. package/esm/components/nested-route-types.d.ts.map +1 -1
  47. package/esm/components/nested-router.d.ts +77 -7
  48. package/esm/components/nested-router.d.ts.map +1 -1
  49. package/esm/components/nested-router.js +139 -13
  50. package/esm/components/nested-router.js.map +1 -1
  51. package/esm/components/nested-router.spec.js +283 -52
  52. package/esm/components/nested-router.spec.js.map +1 -1
  53. package/esm/services/location-service.d.ts +39 -42
  54. package/esm/services/location-service.d.ts.map +1 -1
  55. package/esm/services/location-service.js +115 -134
  56. package/esm/services/location-service.js.map +1 -1
  57. package/esm/services/location-service.spec.js +59 -24
  58. package/esm/services/location-service.spec.js.map +1 -1
  59. package/esm/services/route-match-service.d.ts +3 -2
  60. package/esm/services/route-match-service.d.ts.map +1 -1
  61. package/esm/services/route-match-service.js +13 -22
  62. package/esm/services/route-match-service.js.map +1 -1
  63. package/esm/services/route-match-service.spec.js +42 -49
  64. package/esm/services/route-match-service.spec.js.map +1 -1
  65. package/esm/services/route-meta-utils.d.ts +28 -6
  66. package/esm/services/route-meta-utils.d.ts.map +1 -1
  67. package/esm/services/route-meta-utils.js +18 -4
  68. package/esm/services/route-meta-utils.js.map +1 -1
  69. package/esm/services/route-meta-utils.spec.js +60 -5
  70. package/esm/services/route-meta-utils.spec.js.map +1 -1
  71. package/esm/services/screen-service.d.ts +13 -72
  72. package/esm/services/screen-service.d.ts.map +1 -1
  73. package/esm/services/screen-service.js +45 -137
  74. package/esm/services/screen-service.js.map +1 -1
  75. package/esm/services/screen-service.spec.js +19 -18
  76. package/esm/services/screen-service.spec.js.map +1 -1
  77. package/esm/services/spatial-navigation-service.d.ts +17 -39
  78. package/esm/services/spatial-navigation-service.d.ts.map +1 -1
  79. package/esm/services/spatial-navigation-service.js +280 -313
  80. package/esm/services/spatial-navigation-service.js.map +1 -1
  81. package/esm/services/spatial-navigation-service.spec.js +163 -158
  82. package/esm/services/spatial-navigation-service.spec.js.map +1 -1
  83. package/esm/shade.d.ts +1 -1
  84. package/esm/shade.d.ts.map +1 -1
  85. package/esm/shade.js +10 -7
  86. package/esm/shade.js.map +1 -1
  87. package/esm/vnode.d.ts +1 -1
  88. package/esm/vnode.js +1 -1
  89. package/package.json +5 -4
  90. package/src/component-factory.spec.tsx +3 -2
  91. package/src/components/define-nested-routes.spec.ts +91 -0
  92. package/src/components/define-nested-routes.ts +27 -0
  93. package/src/components/index.ts +3 -3
  94. package/src/components/nested-hooks.spec.ts +254 -0
  95. package/src/components/nested-hooks.ts +109 -0
  96. package/src/components/nested-navigate.spec.ts +136 -32
  97. package/src/components/nested-navigate.ts +75 -26
  98. package/src/components/nested-replace.spec.ts +89 -0
  99. package/src/components/nested-replace.ts +46 -0
  100. package/src/components/nested-route-link.spec.tsx +90 -22
  101. package/src/components/nested-route-link.tsx +54 -29
  102. package/src/components/nested-route-types.ts +82 -2
  103. package/src/components/nested-router.spec.tsx +334 -51
  104. package/src/components/nested-router.tsx +184 -24
  105. package/src/services/location-service.spec.ts +68 -25
  106. package/src/services/location-service.tsx +156 -122
  107. package/src/services/route-match-service.spec.ts +41 -28
  108. package/src/services/route-match-service.ts +17 -8
  109. package/src/services/route-meta-utils.spec.ts +78 -10
  110. package/src/services/route-meta-utils.ts +44 -15
  111. package/src/services/screen-service.spec.ts +19 -18
  112. package/src/services/screen-service.ts +65 -110
  113. package/src/services/spatial-navigation-service.spec.ts +165 -160
  114. package/src/services/spatial-navigation-service.ts +299 -287
  115. package/src/shade.ts +11 -8
  116. package/src/vnode.ts +1 -1
  117. package/esm/components/link-to-route.d.ts +0 -10
  118. package/esm/components/link-to-route.d.ts.map +0 -1
  119. package/esm/components/link-to-route.js +0 -16
  120. package/esm/components/link-to-route.js.map +0 -1
  121. package/esm/components/link-to-route.spec.d.ts +0 -2
  122. package/esm/components/link-to-route.spec.d.ts.map +0 -1
  123. package/esm/components/link-to-route.spec.js +0 -30
  124. package/esm/components/link-to-route.spec.js.map +0 -1
  125. package/esm/components/route-link.d.ts +0 -10
  126. package/esm/components/route-link.d.ts.map +0 -1
  127. package/esm/components/route-link.js +0 -25
  128. package/esm/components/route-link.js.map +0 -1
  129. package/esm/components/route-link.spec.d.ts +0 -2
  130. package/esm/components/route-link.spec.d.ts.map +0 -1
  131. package/esm/components/route-link.spec.js +0 -34
  132. package/esm/components/route-link.spec.js.map +0 -1
  133. package/esm/components/router.d.ts +0 -44
  134. package/esm/components/router.d.ts.map +0 -1
  135. package/esm/components/router.js +0 -66
  136. package/esm/components/router.js.map +0 -1
  137. package/esm/components/router.spec.d.ts +0 -2
  138. package/esm/components/router.spec.d.ts.map +0 -1
  139. package/esm/components/router.spec.js +0 -161
  140. package/esm/components/router.spec.js.map +0 -1
  141. package/src/components/link-to-route.spec.tsx +0 -45
  142. package/src/components/link-to-route.tsx +0 -26
  143. package/src/components/route-link.spec.tsx +0 -44
  144. package/src/components/route-link.tsx +0 -29
  145. package/src/components/router.spec.tsx +0 -217
  146. package/src/components/router.tsx +0 -103
@@ -1,13 +1,17 @@
1
- import { Injector } from '@furystack/inject'
1
+ import { createInjector } from '@furystack/inject'
2
+ import { serializeValue } from '@furystack/rest'
2
3
  import { sleepAsync, usingAsync } from '@furystack/utils'
3
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
5
  import { initializeShadeRoot } from '../initialize.js'
5
6
  import { createComponent } from '../shade-component.js'
6
7
  import { flushUpdates } from '../shade.js'
7
8
  import { RouteMatchService } from '../services/route-match-service.js'
9
+ import { LocationService } from '../services/location-service.js'
8
10
  import {
9
11
  buildMatchChain,
12
+ enrichMatchChain,
10
13
  findDivergenceIndex,
14
+ hasQueryOrHashChanged,
11
15
  NestedRouter,
12
16
  renderMatchChain,
13
17
  resolveViewTransition,
@@ -138,6 +142,8 @@ describe('findDivergenceIndex', () => {
138
142
  const makeEntry = (id: number, params: Record<string, string> = {}): MatchChainEntry => ({
139
143
  route: { component: () => <div>{id}</div> },
140
144
  match: { path: '/', params },
145
+ query: null,
146
+ hash: undefined,
141
147
  })
142
148
 
143
149
  it('should return 0 for completely different chains', () => {
@@ -162,10 +168,120 @@ describe('findDivergenceIndex', () => {
162
168
 
163
169
  it('should detect divergence from changed params', () => {
164
170
  const route: NestedRoute = { component: () => <div /> }
165
- const oldChain: MatchChainEntry[] = [{ route, match: { path: '/', params: { id: '1' } } }]
166
- const newChain: MatchChainEntry[] = [{ route, match: { path: '/', params: { id: '2' } } }]
171
+ const oldChain: MatchChainEntry[] = [
172
+ { route, match: { path: '/', params: { id: '1' } }, query: null, hash: undefined },
173
+ ]
174
+ const newChain: MatchChainEntry[] = [
175
+ { route, match: { path: '/', params: { id: '2' } }, query: null, hash: undefined },
176
+ ]
167
177
  expect(findDivergenceIndex(oldChain, newChain)).toBe(0)
168
178
  })
179
+
180
+ it('should ignore query and hash differences when deciding divergence', () => {
181
+ const route: NestedRoute = { component: () => <div /> }
182
+ const oldChain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: { page: 1 }, hash: 'a' }]
183
+ const newChain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: { page: 2 }, hash: 'b' }]
184
+ expect(findDivergenceIndex(oldChain, newChain)).toBe(1)
185
+ })
186
+ })
187
+
188
+ describe('enrichMatchChain', () => {
189
+ it('should return the input chain unchanged when no entry declares query or hash', () => {
190
+ const route: NestedRoute = { component: () => <div /> }
191
+ const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
192
+ const result = enrichMatchChain(chain, {}, '')
193
+ expect(result).toBe(chain)
194
+ })
195
+
196
+ it('should populate query from the route validator', () => {
197
+ const route: NestedRoute<unknown, { page: number }> = {
198
+ component: () => <div />,
199
+ query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
200
+ }
201
+ const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
202
+ const result = enrichMatchChain(chain, { page: 3 }, '')
203
+ expect(result).not.toBe(chain)
204
+ expect(result[0].query).toEqual({ page: 3 })
205
+ })
206
+
207
+ it('should set query to null when the validator rejects the current search', () => {
208
+ const route: NestedRoute<unknown, { page: number }> = {
209
+ component: () => <div />,
210
+ query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
211
+ }
212
+ const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
213
+ const result = enrichMatchChain(chain, { page: 'nope' }, '')
214
+ expect(result[0].query).toBeNull()
215
+ })
216
+
217
+ it('should populate hash only when the current hash is listed in the declared tuple', () => {
218
+ const route: NestedRoute<unknown, any, readonly ['a', 'b']> = {
219
+ component: () => <div />,
220
+ hash: ['a', 'b'] as const,
221
+ }
222
+ const chain: MatchChainEntry[] = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }]
223
+ expect(enrichMatchChain(chain, {}, 'a')[0].hash).toBe('a')
224
+ expect(enrichMatchChain(chain, {}, 'unknown')[0].hash).toBeUndefined()
225
+ })
226
+
227
+ it('should leave entries without a declared schema as null/undefined', () => {
228
+ const bareRoute: NestedRoute = { component: () => <div /> }
229
+ const declaringRoute: NestedRoute<unknown, any, readonly ['a']> = {
230
+ component: () => <div />,
231
+ hash: ['a'] as const,
232
+ }
233
+ const chain: MatchChainEntry[] = [
234
+ { route: bareRoute, match: { path: '/', params: {} }, query: null, hash: undefined },
235
+ { route: declaringRoute, match: { path: '/', params: {} }, query: null, hash: undefined },
236
+ ]
237
+ const result = enrichMatchChain(chain, {}, 'a')
238
+ expect(result[0].query).toBeNull()
239
+ expect(result[0].hash).toBeUndefined()
240
+ expect(result[1].hash).toBe('a')
241
+ })
242
+ })
243
+
244
+ describe('hasQueryOrHashChanged', () => {
245
+ const makeEntry = (query: unknown, hash: string | undefined): MatchChainEntry => ({
246
+ route: { component: () => <div /> },
247
+ match: { path: '/', params: {} },
248
+ query,
249
+ hash,
250
+ })
251
+
252
+ it('should return false when both chains are empty', () => {
253
+ expect(hasQueryOrHashChanged([], [])).toBe(false)
254
+ })
255
+
256
+ it('should return false for identical chains', () => {
257
+ const oldChain = [makeEntry({ page: 1 }, 'a')]
258
+ const newChain = [makeEntry({ page: 1 }, 'a')]
259
+ expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false)
260
+ })
261
+
262
+ it('should return true when the hash differs', () => {
263
+ expect(hasQueryOrHashChanged([makeEntry(null, 'a')], [makeEntry(null, 'b')])).toBe(true)
264
+ })
265
+
266
+ it('should return true when the query differs', () => {
267
+ expect(hasQueryOrHashChanged([makeEntry({ page: 1 }, undefined)], [makeEntry({ page: 2 }, undefined)])).toBe(true)
268
+ })
269
+
270
+ it('should treat equal query objects as unchanged regardless of key order', () => {
271
+ const oldChain = [makeEntry({ a: 1, b: 2 }, undefined)]
272
+ const newChain = [makeEntry({ b: 2, a: 1 }, undefined)]
273
+ expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false)
274
+ })
275
+
276
+ it('should detect query diffs on arrays', () => {
277
+ expect(hasQueryOrHashChanged([makeEntry([1, 2], undefined)], [makeEntry([1, 3], undefined)])).toBe(true)
278
+ })
279
+
280
+ it('should ignore chain entries beyond the shorter length', () => {
281
+ const oldChain = [makeEntry(null, 'a')]
282
+ const newChain = [makeEntry(null, 'a'), makeEntry({ x: 1 }, 'b')]
283
+ expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false)
284
+ })
169
285
  })
170
286
 
171
287
  describe('renderMatchChain', () => {
@@ -177,6 +293,8 @@ describe('renderMatchChain', () => {
177
293
  {
178
294
  route: { component: componentFn },
179
295
  match: { path: '/leaf', params: {} },
296
+ query: null,
297
+ hash: undefined,
180
298
  },
181
299
  ]
182
300
 
@@ -186,6 +304,8 @@ describe('renderMatchChain', () => {
186
304
  expect(componentFn).toHaveBeenCalledWith({
187
305
  currentUrl: '/leaf',
188
306
  match: { path: '/leaf', params: {} },
307
+ query: null,
308
+ hash: undefined,
189
309
  outlet: undefined,
190
310
  })
191
311
  expect(result.chainElements).toHaveLength(1)
@@ -208,10 +328,14 @@ describe('renderMatchChain', () => {
208
328
  {
209
329
  route: { component: parentComponent },
210
330
  match: { path: '/parent', params: {} },
331
+ query: null,
332
+ hash: undefined,
211
333
  },
212
334
  {
213
335
  route: { component: childComponent },
214
336
  match: { path: '/child', params: {} },
337
+ query: null,
338
+ hash: undefined,
215
339
  },
216
340
  ]
217
341
 
@@ -240,9 +364,9 @@ describe('renderMatchChain', () => {
240
364
  const parent = makeComponent()
241
365
 
242
366
  const chain: MatchChainEntry[] = [
243
- { route: { component: parent }, match: { path: '/a', params: {} } },
244
- { route: { component: child }, match: { path: '/b', params: {} } },
245
- { route: { component: grandchild }, match: { path: '/c', params: {} } },
367
+ { route: { component: parent }, match: { path: '/a', params: {} }, query: null, hash: undefined },
368
+ { route: { component: child }, match: { path: '/b', params: {} }, query: null, hash: undefined },
369
+ { route: { component: grandchild }, match: { path: '/c', params: {} }, query: null, hash: undefined },
246
370
  ]
247
371
 
248
372
  renderMatchChain(chain, '/a/b/c')
@@ -256,9 +380,9 @@ describe('renderMatchChain', () => {
256
380
  const parentComponent = vi.fn(({ outlet }: { outlet?: JSX.Element }) => <main>parent{outlet}</main>)
257
381
 
258
382
  const chain: MatchChainEntry[] = [
259
- { route: { component: parentComponent }, match: { path: '/a', params: {} } },
260
- { route: { component: childComponent }, match: { path: '/b', params: {} } },
261
- { route: { component: () => grandchildEl }, match: { path: '/c', params: {} } },
383
+ { route: { component: parentComponent }, match: { path: '/a', params: {} }, query: null, hash: undefined },
384
+ { route: { component: childComponent }, match: { path: '/b', params: {} }, query: null, hash: undefined },
385
+ { route: { component: () => grandchildEl }, match: { path: '/c', params: {} }, query: null, hash: undefined },
262
386
  ]
263
387
 
264
388
  const result = renderMatchChain(chain, '/a/b/c')
@@ -314,7 +438,7 @@ describe('NestedRouter lifecycle hooks', () => {
314
438
  callOrder.push('leave-other')
315
439
  })
316
440
 
317
- await usingAsync(new Injector(), async (injector) => {
441
+ await usingAsync(createInjector(), async (injector) => {
318
442
  const rootElement = document.getElementById('root') as HTMLDivElement
319
443
 
320
444
  initializeShadeRoot({
@@ -322,16 +446,16 @@ describe('NestedRouter lifecycle hooks', () => {
322
446
  rootElement,
323
447
  jsxElement: (
324
448
  <div>
325
- <NestedRouteLink id="child-a" href="/parent/child-a">
449
+ <NestedRouteLink id="child-a" path="/parent/child-a">
326
450
  child-a
327
451
  </NestedRouteLink>
328
- <NestedRouteLink id="child-b" href="/parent/child-b">
452
+ <NestedRouteLink id="child-b" path="/parent/child-b">
329
453
  child-b
330
454
  </NestedRouteLink>
331
- <NestedRouteLink id="other" href="/other">
455
+ <NestedRouteLink id="other" path="/other">
332
456
  other
333
457
  </NestedRouteLink>
334
- <NestedRouteLink id="nowhere" href="/nowhere">
458
+ <NestedRouteLink id="nowhere" path="/nowhere">
335
459
  nowhere
336
460
  </NestedRouteLink>
337
461
  <NestedRouter
@@ -417,6 +541,163 @@ describe('NestedRouter lifecycle hooks', () => {
417
541
  })
418
542
  })
419
543
 
544
+ describe('NestedRouter query / hash re-render', () => {
545
+ beforeEach(() => {
546
+ document.body.innerHTML = '<div id="root"></div>'
547
+ })
548
+ afterEach(() => {
549
+ document.body.innerHTML = ''
550
+ })
551
+
552
+ it('should re-render the chain on a hash-only change without firing lifecycle hooks', async () => {
553
+ history.pushState(null, '', '/tabs')
554
+
555
+ const onVisit = vi.fn(async () => {})
556
+ const onLeave = vi.fn(async () => {})
557
+ const componentFn = vi.fn(({ hash }: { hash: string | undefined }) => <div id="content">hash={hash ?? 'none'}</div>)
558
+
559
+ await usingAsync(createInjector(), async (injector) => {
560
+ const rootElement = document.getElementById('root') as HTMLDivElement
561
+
562
+ initializeShadeRoot({
563
+ injector,
564
+ rootElement,
565
+ jsxElement: (
566
+ <NestedRouter
567
+ routes={{
568
+ '/tabs': {
569
+ component: componentFn,
570
+ onVisit,
571
+ onLeave,
572
+ hash: ['a', 'b'] as const,
573
+ },
574
+ }}
575
+ />
576
+ ),
577
+ })
578
+
579
+ await flushUpdates()
580
+ expect(document.getElementById('content')?.innerHTML).toBe('hash=none')
581
+ expect(onVisit).toBeCalledTimes(1)
582
+ expect(onLeave).not.toBeCalled()
583
+
584
+ const locationService = injector.get(LocationService)
585
+ locationService.navigate('/tabs#a')
586
+ await flushUpdates()
587
+ await flushUpdates()
588
+
589
+ expect(document.getElementById('content')?.innerHTML).toBe('hash=a')
590
+ expect(onVisit).toBeCalledTimes(1)
591
+ expect(onLeave).not.toBeCalled()
592
+
593
+ locationService.navigate('/tabs#b')
594
+ await flushUpdates()
595
+ await flushUpdates()
596
+
597
+ expect(document.getElementById('content')?.innerHTML).toBe('hash=b')
598
+ expect(onVisit).toBeCalledTimes(1)
599
+ expect(onLeave).not.toBeCalled()
600
+ })
601
+ })
602
+
603
+ it('should re-render the chain on a query-only change without firing lifecycle hooks', async () => {
604
+ history.pushState(null, '', '/list')
605
+
606
+ const onVisit = vi.fn(async () => {})
607
+ const onLeave = vi.fn(async () => {})
608
+ const componentFn = vi.fn(({ query }: { query: { page: number } | null }) => (
609
+ <div id="content">page={query?.page ?? 'none'}</div>
610
+ ))
611
+
612
+ await usingAsync(createInjector(), async (injector) => {
613
+ const rootElement = document.getElementById('root') as HTMLDivElement
614
+
615
+ initializeShadeRoot({
616
+ injector,
617
+ rootElement,
618
+ jsxElement: (
619
+ <NestedRouter
620
+ routes={{
621
+ '/list': {
622
+ component: componentFn,
623
+ onVisit,
624
+ onLeave,
625
+ query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
626
+ },
627
+ }}
628
+ />
629
+ ),
630
+ })
631
+
632
+ await flushUpdates()
633
+ expect(document.getElementById('content')?.innerHTML).toBe('page=none')
634
+ expect(onVisit).toBeCalledTimes(1)
635
+
636
+ const locationService = injector.get(LocationService)
637
+ locationService.navigate(`/list?page=${serializeValue(2)}`)
638
+ await flushUpdates()
639
+ await flushUpdates()
640
+
641
+ expect(document.getElementById('content')?.innerHTML).toBe('page=2')
642
+ expect(onVisit).toBeCalledTimes(1)
643
+ expect(onLeave).not.toBeCalled()
644
+
645
+ locationService.navigate(`/list?page=${serializeValue(5)}`)
646
+ await flushUpdates()
647
+ await flushUpdates()
648
+
649
+ expect(document.getElementById('content')?.innerHTML).toBe('page=5')
650
+ expect(onVisit).toBeCalledTimes(1)
651
+ expect(onLeave).not.toBeCalled()
652
+ })
653
+ })
654
+
655
+ it('should coalesce path + query + hash bursts into a single updateUrl', async () => {
656
+ history.pushState(null, '', '/a')
657
+
658
+ const aComponent = vi.fn(() => <div id="content">a</div>)
659
+ const bComponent = vi.fn(({ query, hash }: { query: { page: number } | null; hash: string | undefined }) => (
660
+ <div id="content">
661
+ b-page={query?.page ?? 'none'}-hash={hash ?? 'none'}
662
+ </div>
663
+ ))
664
+
665
+ await usingAsync(createInjector(), async (injector) => {
666
+ const rootElement = document.getElementById('root') as HTMLDivElement
667
+
668
+ initializeShadeRoot({
669
+ injector,
670
+ rootElement,
671
+ jsxElement: (
672
+ <NestedRouter
673
+ routes={{
674
+ '/a': { component: aComponent },
675
+ '/b': {
676
+ component: bComponent,
677
+ hash: ['x'] as const,
678
+ query: (raw): { page: number } | null => (typeof raw.page === 'number' ? { page: raw.page } : null),
679
+ },
680
+ }}
681
+ />
682
+ ),
683
+ })
684
+
685
+ await flushUpdates()
686
+ expect(document.getElementById('content')?.innerHTML).toBe('a')
687
+ bComponent.mockClear()
688
+
689
+ injector.get(LocationService).navigate(`/b?page=${serializeValue(7)}#x`)
690
+ await flushUpdates()
691
+ await flushUpdates()
692
+
693
+ expect(document.getElementById('content')?.innerHTML).toBe('b-page=7-hash=x')
694
+ // A single logical navigation should render the /b component exactly once,
695
+ // not three times (once per observable subscription).
696
+ expect(bComponent).toHaveBeenCalledTimes(1)
697
+ })
698
+ })
699
+ })
700
+
420
701
  describe('NestedRouter latest-wins on rapid navigation', () => {
421
702
  beforeEach(() => {
422
703
  document.body.innerHTML = '<div id="root"></div>'
@@ -450,7 +731,7 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
450
731
  callOrder.push('leave-c')
451
732
  })
452
733
 
453
- await usingAsync(new Injector(), async (injector) => {
734
+ await usingAsync(createInjector(), async (injector) => {
454
735
  const rootElement = document.getElementById('root') as HTMLDivElement
455
736
 
456
737
  initializeShadeRoot({
@@ -458,13 +739,13 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
458
739
  rootElement,
459
740
  jsxElement: (
460
741
  <div>
461
- <NestedRouteLink id="go-a" href="/route-a">
742
+ <NestedRouteLink id="go-a" path="/route-a">
462
743
  a
463
744
  </NestedRouteLink>
464
- <NestedRouteLink id="go-b" href="/route-b">
745
+ <NestedRouteLink id="go-b" path="/route-b">
465
746
  b
466
747
  </NestedRouteLink>
467
- <NestedRouteLink id="go-c" href="/route-c">
748
+ <NestedRouteLink id="go-c" path="/route-c">
468
749
  c
469
750
  </NestedRouteLink>
470
751
  <NestedRouter
@@ -532,7 +813,7 @@ describe('NestedRouter lifecycle element scope', () => {
532
813
  const visitElements: Array<{ route: string; element: JSX.Element }> = []
533
814
  const leaveElements: Array<{ route: string; element: JSX.Element }> = []
534
815
 
535
- await usingAsync(new Injector(), async (injector) => {
816
+ await usingAsync(createInjector(), async (injector) => {
536
817
  const rootElement = document.getElementById('root') as HTMLDivElement
537
818
 
538
819
  initializeShadeRoot({
@@ -540,10 +821,10 @@ describe('NestedRouter lifecycle element scope', () => {
540
821
  rootElement,
541
822
  jsxElement: (
542
823
  <div>
543
- <NestedRouteLink id="child-a" href="/parent/child-a">
824
+ <NestedRouteLink id="child-a" path="/parent/child-a">
544
825
  child-a
545
826
  </NestedRouteLink>
546
- <NestedRouteLink id="child-b" href="/parent/child-b">
827
+ <NestedRouteLink id="child-b" path="/parent/child-b">
547
828
  child-b
548
829
  </NestedRouteLink>
549
830
  <NestedRouter
@@ -625,7 +906,7 @@ describe('NestedRouter flat routes', () => {
625
906
  it('should render and navigate between flat (non-nested) Record routes', async () => {
626
907
  history.pushState(null, '', '/')
627
908
 
628
- await usingAsync(new Injector(), async (injector) => {
909
+ await usingAsync(createInjector(), async (injector) => {
629
910
  const rootElement = document.getElementById('root') as HTMLDivElement
630
911
 
631
912
  initializeShadeRoot({
@@ -633,13 +914,13 @@ describe('NestedRouter flat routes', () => {
633
914
  rootElement,
634
915
  jsxElement: (
635
916
  <div>
636
- <NestedRouteLink id="home" href="/">
917
+ <NestedRouteLink id="home" path="/">
637
918
  home
638
919
  </NestedRouteLink>
639
- <NestedRouteLink id="about" href="/about">
920
+ <NestedRouteLink id="about" path="/about">
640
921
  about
641
922
  </NestedRouteLink>
642
- <NestedRouteLink id="contact" href="/contact">
923
+ <NestedRouteLink id="contact" path="/contact">
643
924
  contact
644
925
  </NestedRouteLink>
645
926
  <NestedRouter
@@ -686,7 +967,7 @@ describe('NestedRouter outlet composition', () => {
686
967
  it('should compose parent layout wrapping child content via outlet', async () => {
687
968
  history.pushState(null, '', '/dashboard/settings')
688
969
 
689
- await usingAsync(new Injector(), async (injector) => {
970
+ await usingAsync(createInjector(), async (injector) => {
690
971
  const rootElement = document.getElementById('root') as HTMLDivElement
691
972
 
692
973
  initializeShadeRoot({
@@ -731,7 +1012,7 @@ describe('NestedRouter outlet composition', () => {
731
1012
  it('should render parent with fallback when navigating to parent URL without a child match', async () => {
732
1013
  history.pushState(null, '', '/dashboard')
733
1014
 
734
- await usingAsync(new Injector(), async (injector) => {
1015
+ await usingAsync(createInjector(), async (injector) => {
735
1016
  const rootElement = document.getElementById('root') as HTMLDivElement
736
1017
 
737
1018
  initializeShadeRoot({
@@ -784,7 +1065,7 @@ describe('NestedRouter route param changes', () => {
784
1065
  callOrder.push('leave-user')
785
1066
  })
786
1067
 
787
- await usingAsync(new Injector(), async (injector) => {
1068
+ await usingAsync(createInjector(), async (injector) => {
788
1069
  const rootElement = document.getElementById('root') as HTMLDivElement
789
1070
 
790
1071
  initializeShadeRoot({
@@ -792,13 +1073,13 @@ describe('NestedRouter route param changes', () => {
792
1073
  rootElement,
793
1074
  jsxElement: (
794
1075
  <div>
795
- <NestedRouteLink id="user-1" href="/users/1">
1076
+ <NestedRouteLink id="user-1" path="/users/1">
796
1077
  User 1
797
1078
  </NestedRouteLink>
798
- <NestedRouteLink id="user-2" href="/users/2">
1079
+ <NestedRouteLink id="user-2" path="/users/2">
799
1080
  User 2
800
1081
  </NestedRouteLink>
801
- <NestedRouteLink id="user-3" href="/users/3">
1082
+ <NestedRouteLink id="user-3" path="/users/3">
802
1083
  User 3
803
1084
  </NestedRouteLink>
804
1085
  <NestedRouter
@@ -860,7 +1141,7 @@ describe('NestedRouter route param changes', () => {
860
1141
  const onVisitDash = vi.fn()
861
1142
  const onLeaveDash = vi.fn()
862
1143
 
863
- await usingAsync(new Injector(), async (injector) => {
1144
+ await usingAsync(createInjector(), async (injector) => {
864
1145
  const rootElement = document.getElementById('root') as HTMLDivElement
865
1146
 
866
1147
  initializeShadeRoot({
@@ -868,10 +1149,10 @@ describe('NestedRouter route param changes', () => {
868
1149
  rootElement,
869
1150
  jsxElement: (
870
1151
  <div>
871
- <NestedRouteLink id="alpha-dash" href="/org/alpha/dashboard">
1152
+ <NestedRouteLink id="alpha-dash" path="/org/alpha/dashboard">
872
1153
  Alpha Dashboard
873
1154
  </NestedRouteLink>
874
- <NestedRouteLink id="beta-dash" href="/org/beta/dashboard">
1155
+ <NestedRouteLink id="beta-dash" path="/org/beta/dashboard">
875
1156
  Beta Dashboard
876
1157
  </NestedRouteLink>
877
1158
  <NestedRouter
@@ -933,9 +1214,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
933
1214
  it('should update RouteMatchService with the current match chain on navigation', async () => {
934
1215
  history.pushState(null, '', '/parent/child-a')
935
1216
 
936
- await usingAsync(new Injector(), async (injector) => {
1217
+ await usingAsync(createInjector(), async (injector) => {
937
1218
  const rootElement = document.getElementById('root') as HTMLDivElement
938
- const routeMatchService = injector.getInstance(RouteMatchService)
1219
+ const routeMatchService = injector.get(RouteMatchService)
939
1220
 
940
1221
  const parentRoute: NestedRoute = {
941
1222
  meta: { title: 'Parent' },
@@ -957,13 +1238,13 @@ describe('NestedRouter + RouteMatchService integration', () => {
957
1238
  rootElement,
958
1239
  jsxElement: (
959
1240
  <div>
960
- <NestedRouteLink id="child-a" href="/parent/child-a">
1241
+ <NestedRouteLink id="child-a" path="/parent/child-a">
961
1242
  child-a
962
1243
  </NestedRouteLink>
963
- <NestedRouteLink id="child-b" href="/parent/child-b">
1244
+ <NestedRouteLink id="child-b" path="/parent/child-b">
964
1245
  child-b
965
1246
  </NestedRouteLink>
966
- <NestedRouteLink id="nowhere" href="/nowhere">
1247
+ <NestedRouteLink id="nowhere" path="/nowhere">
967
1248
  nowhere
968
1249
  </NestedRouteLink>
969
1250
  <NestedRouter routes={{ '/parent': parentRoute }} notFound={<div id="content">not found</div>} />
@@ -997,9 +1278,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
997
1278
  it('should expose match params through RouteMatchService', async () => {
998
1279
  history.pushState(null, '', '/users/42')
999
1280
 
1000
- await usingAsync(new Injector(), async (injector) => {
1281
+ await usingAsync(createInjector(), async (injector) => {
1001
1282
  const rootElement = document.getElementById('root') as HTMLDivElement
1002
- const routeMatchService = injector.getInstance(RouteMatchService)
1283
+ const routeMatchService = injector.get(RouteMatchService)
1003
1284
 
1004
1285
  initializeShadeRoot({
1005
1286
  injector,
@@ -1030,6 +1311,8 @@ describe('resolveViewTransition', () => {
1030
1311
  const makeEntry = (viewTransition?: boolean | { types?: string[] }): MatchChainEntry => ({
1031
1312
  route: { component: () => <div />, viewTransition },
1032
1313
  match: { path: '/', params: {} },
1314
+ query: null,
1315
+ hash: undefined,
1033
1316
  })
1034
1317
 
1035
1318
  it('should return false when router config is undefined and route has no override', () => {
@@ -1095,7 +1378,7 @@ describe('NestedRouter view transitions', () => {
1095
1378
  it('should call startViewTransition when viewTransition is enabled', async () => {
1096
1379
  history.pushState(null, '', '/')
1097
1380
 
1098
- await usingAsync(new Injector(), async (injector) => {
1381
+ await usingAsync(createInjector(), async (injector) => {
1099
1382
  const rootElement = document.getElementById('root') as HTMLDivElement
1100
1383
 
1101
1384
  initializeShadeRoot({
@@ -1103,7 +1386,7 @@ describe('NestedRouter view transitions', () => {
1103
1386
  rootElement,
1104
1387
  jsxElement: (
1105
1388
  <div>
1106
- <NestedRouteLink id="go-about" href="/about">
1389
+ <NestedRouteLink id="go-about" path="/about">
1107
1390
  about
1108
1391
  </NestedRouteLink>
1109
1392
  <NestedRouter
@@ -1131,7 +1414,7 @@ describe('NestedRouter view transitions', () => {
1131
1414
  it('should not call startViewTransition when viewTransition is not set', async () => {
1132
1415
  history.pushState(null, '', '/')
1133
1416
 
1134
- await usingAsync(new Injector(), async (injector) => {
1417
+ await usingAsync(createInjector(), async (injector) => {
1135
1418
  const rootElement = document.getElementById('root') as HTMLDivElement
1136
1419
 
1137
1420
  initializeShadeRoot({
@@ -1139,7 +1422,7 @@ describe('NestedRouter view transitions', () => {
1139
1422
  rootElement,
1140
1423
  jsxElement: (
1141
1424
  <div>
1142
- <NestedRouteLink id="go-about" href="/about">
1425
+ <NestedRouteLink id="go-about" path="/about">
1143
1426
  about
1144
1427
  </NestedRouteLink>
1145
1428
  <NestedRouter
@@ -1166,7 +1449,7 @@ describe('NestedRouter view transitions', () => {
1166
1449
  it('should pass types to startViewTransition when configured', async () => {
1167
1450
  history.pushState(null, '', '/')
1168
1451
 
1169
- await usingAsync(new Injector(), async (injector) => {
1452
+ await usingAsync(createInjector(), async (injector) => {
1170
1453
  const rootElement = document.getElementById('root') as HTMLDivElement
1171
1454
 
1172
1455
  initializeShadeRoot({
@@ -1174,7 +1457,7 @@ describe('NestedRouter view transitions', () => {
1174
1457
  rootElement,
1175
1458
  jsxElement: (
1176
1459
  <div>
1177
- <NestedRouteLink id="go-about" href="/about">
1460
+ <NestedRouteLink id="go-about" path="/about">
1178
1461
  about
1179
1462
  </NestedRouteLink>
1180
1463
  <NestedRouter
@@ -1201,7 +1484,7 @@ describe('NestedRouter view transitions', () => {
1201
1484
  it('should respect per-route viewTransition: false override', async () => {
1202
1485
  history.pushState(null, '', '/')
1203
1486
 
1204
- await usingAsync(new Injector(), async (injector) => {
1487
+ await usingAsync(createInjector(), async (injector) => {
1205
1488
  const rootElement = document.getElementById('root') as HTMLDivElement
1206
1489
 
1207
1490
  initializeShadeRoot({
@@ -1209,7 +1492,7 @@ describe('NestedRouter view transitions', () => {
1209
1492
  rootElement,
1210
1493
  jsxElement: (
1211
1494
  <div>
1212
- <NestedRouteLink id="go-about" href="/about">
1495
+ <NestedRouteLink id="go-about" path="/about">
1213
1496
  about
1214
1497
  </NestedRouteLink>
1215
1498
  <NestedRouter
@@ -1242,7 +1525,7 @@ describe('NestedRouter view transitions', () => {
1242
1525
 
1243
1526
  history.pushState(null, '', '/')
1244
1527
 
1245
- await usingAsync(new Injector(), async (injector) => {
1528
+ await usingAsync(createInjector(), async (injector) => {
1246
1529
  const rootElement = document.getElementById('root') as HTMLDivElement
1247
1530
 
1248
1531
  initializeShadeRoot({
@@ -1250,7 +1533,7 @@ describe('NestedRouter view transitions', () => {
1250
1533
  rootElement,
1251
1534
  jsxElement: (
1252
1535
  <div>
1253
- <NestedRouteLink id="go-about" href="/about">
1536
+ <NestedRouteLink id="go-about" path="/about">
1254
1537
  about
1255
1538
  </NestedRouteLink>
1256
1539
  <NestedRouter