@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
@@ -1,13 +1,17 @@
1
1
  import { Injector } 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')
@@ -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(new Injector(), 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.getInstance(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(new Injector(), 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.getInstance(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(new Injector(), 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.getInstance(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>'
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>} />
@@ -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', () => {
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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