@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,11 +1,13 @@
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';
8
- import { buildMatchChain, findDivergenceIndex, NestedRouter, renderMatchChain, resolveViewTransition, } from './nested-router.js';
9
+ import { LocationService } from '../services/location-service.js';
10
+ import { buildMatchChain, enrichMatchChain, findDivergenceIndex, hasQueryOrHashChanged, NestedRouter, renderMatchChain, resolveViewTransition, } from './nested-router.js';
9
11
  import { NestedRouteLink } from './nested-route-link.js';
10
12
  describe('buildMatchChain', () => {
11
13
  it('should match a simple leaf route', () => {
@@ -122,6 +124,8 @@ describe('findDivergenceIndex', () => {
122
124
  const makeEntry = (id, params = {}) => ({
123
125
  route: { component: () => createComponent("div", null, id) },
124
126
  match: { path: '/', params },
127
+ query: null,
128
+ hash: undefined,
125
129
  });
126
130
  it('should return 0 for completely different chains', () => {
127
131
  const oldChain = [makeEntry(1)];
@@ -142,10 +146,106 @@ describe('findDivergenceIndex', () => {
142
146
  });
143
147
  it('should detect divergence from changed params', () => {
144
148
  const route = { component: () => createComponent("div", null) };
145
- const oldChain = [{ route, match: { path: '/', params: { id: '1' } } }];
146
- const newChain = [{ route, match: { path: '/', params: { id: '2' } } }];
149
+ const oldChain = [
150
+ { route, match: { path: '/', params: { id: '1' } }, query: null, hash: undefined },
151
+ ];
152
+ const newChain = [
153
+ { route, match: { path: '/', params: { id: '2' } }, query: null, hash: undefined },
154
+ ];
147
155
  expect(findDivergenceIndex(oldChain, newChain)).toBe(0);
148
156
  });
157
+ it('should ignore query and hash differences when deciding divergence', () => {
158
+ const route = { component: () => createComponent("div", null) };
159
+ const oldChain = [{ route, match: { path: '/', params: {} }, query: { page: 1 }, hash: 'a' }];
160
+ const newChain = [{ route, match: { path: '/', params: {} }, query: { page: 2 }, hash: 'b' }];
161
+ expect(findDivergenceIndex(oldChain, newChain)).toBe(1);
162
+ });
163
+ });
164
+ describe('enrichMatchChain', () => {
165
+ it('should return the input chain unchanged when no entry declares query or hash', () => {
166
+ const route = { component: () => createComponent("div", null) };
167
+ const chain = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }];
168
+ const result = enrichMatchChain(chain, {}, '');
169
+ expect(result).toBe(chain);
170
+ });
171
+ it('should populate query from the route validator', () => {
172
+ const route = {
173
+ component: () => createComponent("div", null),
174
+ query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
175
+ };
176
+ const chain = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }];
177
+ const result = enrichMatchChain(chain, { page: 3 }, '');
178
+ expect(result).not.toBe(chain);
179
+ expect(result[0].query).toEqual({ page: 3 });
180
+ });
181
+ it('should set query to null when the validator rejects the current search', () => {
182
+ const route = {
183
+ component: () => createComponent("div", null),
184
+ query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
185
+ };
186
+ const chain = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }];
187
+ const result = enrichMatchChain(chain, { page: 'nope' }, '');
188
+ expect(result[0].query).toBeNull();
189
+ });
190
+ it('should populate hash only when the current hash is listed in the declared tuple', () => {
191
+ const route = {
192
+ component: () => createComponent("div", null),
193
+ hash: ['a', 'b'],
194
+ };
195
+ const chain = [{ route, match: { path: '/', params: {} }, query: null, hash: undefined }];
196
+ expect(enrichMatchChain(chain, {}, 'a')[0].hash).toBe('a');
197
+ expect(enrichMatchChain(chain, {}, 'unknown')[0].hash).toBeUndefined();
198
+ });
199
+ it('should leave entries without a declared schema as null/undefined', () => {
200
+ const bareRoute = { component: () => createComponent("div", null) };
201
+ const declaringRoute = {
202
+ component: () => createComponent("div", null),
203
+ hash: ['a'],
204
+ };
205
+ const chain = [
206
+ { route: bareRoute, match: { path: '/', params: {} }, query: null, hash: undefined },
207
+ { route: declaringRoute, match: { path: '/', params: {} }, query: null, hash: undefined },
208
+ ];
209
+ const result = enrichMatchChain(chain, {}, 'a');
210
+ expect(result[0].query).toBeNull();
211
+ expect(result[0].hash).toBeUndefined();
212
+ expect(result[1].hash).toBe('a');
213
+ });
214
+ });
215
+ describe('hasQueryOrHashChanged', () => {
216
+ const makeEntry = (query, hash) => ({
217
+ route: { component: () => createComponent("div", null) },
218
+ match: { path: '/', params: {} },
219
+ query,
220
+ hash,
221
+ });
222
+ it('should return false when both chains are empty', () => {
223
+ expect(hasQueryOrHashChanged([], [])).toBe(false);
224
+ });
225
+ it('should return false for identical chains', () => {
226
+ const oldChain = [makeEntry({ page: 1 }, 'a')];
227
+ const newChain = [makeEntry({ page: 1 }, 'a')];
228
+ expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false);
229
+ });
230
+ it('should return true when the hash differs', () => {
231
+ expect(hasQueryOrHashChanged([makeEntry(null, 'a')], [makeEntry(null, 'b')])).toBe(true);
232
+ });
233
+ it('should return true when the query differs', () => {
234
+ expect(hasQueryOrHashChanged([makeEntry({ page: 1 }, undefined)], [makeEntry({ page: 2 }, undefined)])).toBe(true);
235
+ });
236
+ it('should treat equal query objects as unchanged regardless of key order', () => {
237
+ const oldChain = [makeEntry({ a: 1, b: 2 }, undefined)];
238
+ const newChain = [makeEntry({ b: 2, a: 1 }, undefined)];
239
+ expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false);
240
+ });
241
+ it('should detect query diffs on arrays', () => {
242
+ expect(hasQueryOrHashChanged([makeEntry([1, 2], undefined)], [makeEntry([1, 3], undefined)])).toBe(true);
243
+ });
244
+ it('should ignore chain entries beyond the shorter length', () => {
245
+ const oldChain = [makeEntry(null, 'a')];
246
+ const newChain = [makeEntry(null, 'a'), makeEntry({ x: 1 }, 'b')];
247
+ expect(hasQueryOrHashChanged(oldChain, newChain)).toBe(false);
248
+ });
149
249
  });
150
250
  describe('renderMatchChain', () => {
151
251
  it('should render a single leaf route with outlet undefined', () => {
@@ -156,6 +256,8 @@ describe('renderMatchChain', () => {
156
256
  {
157
257
  route: { component: componentFn },
158
258
  match: { path: '/leaf', params: {} },
259
+ query: null,
260
+ hash: undefined,
159
261
  },
160
262
  ];
161
263
  const result = renderMatchChain(chain, '/leaf');
@@ -163,6 +265,8 @@ describe('renderMatchChain', () => {
163
265
  expect(componentFn).toHaveBeenCalledWith({
164
266
  currentUrl: '/leaf',
165
267
  match: { path: '/leaf', params: {} },
268
+ query: null,
269
+ hash: undefined,
166
270
  outlet: undefined,
167
271
  });
168
272
  expect(result.chainElements).toHaveLength(1);
@@ -186,10 +290,14 @@ describe('renderMatchChain', () => {
186
290
  {
187
291
  route: { component: parentComponent },
188
292
  match: { path: '/parent', params: {} },
293
+ query: null,
294
+ hash: undefined,
189
295
  },
190
296
  {
191
297
  route: { component: childComponent },
192
298
  match: { path: '/child', params: {} },
299
+ query: null,
300
+ hash: undefined,
193
301
  },
194
302
  ];
195
303
  const result = renderMatchChain(chain, '/parent/child');
@@ -210,9 +318,9 @@ describe('renderMatchChain', () => {
210
318
  const child = makeComponent();
211
319
  const parent = makeComponent();
212
320
  const chain = [
213
- { route: { component: parent }, match: { path: '/a', params: {} } },
214
- { route: { component: child }, match: { path: '/b', params: {} } },
215
- { route: { component: grandchild }, match: { path: '/c', params: {} } },
321
+ { route: { component: parent }, match: { path: '/a', params: {} }, query: null, hash: undefined },
322
+ { route: { component: child }, match: { path: '/b', params: {} }, query: null, hash: undefined },
323
+ { route: { component: grandchild }, match: { path: '/c', params: {} }, query: null, hash: undefined },
216
324
  ];
217
325
  renderMatchChain(chain, '/a/b/c');
218
326
  expect(urls).toEqual(['/a/b/c', '/a/b/c', '/a/b/c']);
@@ -226,9 +334,9 @@ describe('renderMatchChain', () => {
226
334
  "parent",
227
335
  outlet));
228
336
  const chain = [
229
- { route: { component: parentComponent }, match: { path: '/a', params: {} } },
230
- { route: { component: childComponent }, match: { path: '/b', params: {} } },
231
- { route: { component: () => grandchildEl }, match: { path: '/c', params: {} } },
337
+ { route: { component: parentComponent }, match: { path: '/a', params: {} }, query: null, hash: undefined },
338
+ { route: { component: childComponent }, match: { path: '/b', params: {} }, query: null, hash: undefined },
339
+ { route: { component: () => grandchildEl }, match: { path: '/c', params: {} }, query: null, hash: undefined },
232
340
  ];
233
341
  const result = renderMatchChain(chain, '/a/b/c');
234
342
  expect(result.chainElements).toHaveLength(3);
@@ -277,16 +385,16 @@ describe('NestedRouter lifecycle hooks', () => {
277
385
  const onLeaveOther = vi.fn(async () => {
278
386
  callOrder.push('leave-other');
279
387
  });
280
- await usingAsync(new Injector(), async (injector) => {
388
+ await usingAsync(createInjector(), async (injector) => {
281
389
  const rootElement = document.getElementById('root');
282
390
  initializeShadeRoot({
283
391
  injector,
284
392
  rootElement,
285
393
  jsxElement: (createComponent("div", null,
286
- createComponent(NestedRouteLink, { id: "child-a", href: "/parent/child-a" }, "child-a"),
287
- createComponent(NestedRouteLink, { id: "child-b", href: "/parent/child-b" }, "child-b"),
288
- createComponent(NestedRouteLink, { id: "other", href: "/other" }, "other"),
289
- createComponent(NestedRouteLink, { id: "nowhere", href: "/nowhere" }, "nowhere"),
394
+ createComponent(NestedRouteLink, { id: "child-a", path: "/parent/child-a" }, "child-a"),
395
+ createComponent(NestedRouteLink, { id: "child-b", path: "/parent/child-b" }, "child-b"),
396
+ createComponent(NestedRouteLink, { id: "other", path: "/other" }, "other"),
397
+ createComponent(NestedRouteLink, { id: "nowhere", path: "/nowhere" }, "nowhere"),
290
398
  createComponent(NestedRouter, { routes: {
291
399
  '/parent': {
292
400
  component: ({ outlet }) => createComponent("div", { id: "wrapper" }, outlet ?? createComponent("div", { id: "content" }, "parent-index")),
@@ -358,6 +466,127 @@ describe('NestedRouter lifecycle hooks', () => {
358
466
  });
359
467
  });
360
468
  });
469
+ describe('NestedRouter query / hash re-render', () => {
470
+ beforeEach(() => {
471
+ document.body.innerHTML = '<div id="root"></div>';
472
+ });
473
+ afterEach(() => {
474
+ document.body.innerHTML = '';
475
+ });
476
+ it('should re-render the chain on a hash-only change without firing lifecycle hooks', async () => {
477
+ history.pushState(null, '', '/tabs');
478
+ const onVisit = vi.fn(async () => { });
479
+ const onLeave = vi.fn(async () => { });
480
+ const componentFn = vi.fn(({ hash }) => createComponent("div", { id: "content" },
481
+ "hash=",
482
+ hash ?? 'none'));
483
+ await usingAsync(createInjector(), async (injector) => {
484
+ const rootElement = document.getElementById('root');
485
+ initializeShadeRoot({
486
+ injector,
487
+ rootElement,
488
+ jsxElement: (createComponent(NestedRouter, { routes: {
489
+ '/tabs': {
490
+ component: componentFn,
491
+ onVisit,
492
+ onLeave,
493
+ hash: ['a', 'b'],
494
+ },
495
+ } })),
496
+ });
497
+ await flushUpdates();
498
+ expect(document.getElementById('content')?.innerHTML).toBe('hash=none');
499
+ expect(onVisit).toBeCalledTimes(1);
500
+ expect(onLeave).not.toBeCalled();
501
+ const locationService = injector.get(LocationService);
502
+ locationService.navigate('/tabs#a');
503
+ await flushUpdates();
504
+ await flushUpdates();
505
+ expect(document.getElementById('content')?.innerHTML).toBe('hash=a');
506
+ expect(onVisit).toBeCalledTimes(1);
507
+ expect(onLeave).not.toBeCalled();
508
+ locationService.navigate('/tabs#b');
509
+ await flushUpdates();
510
+ await flushUpdates();
511
+ expect(document.getElementById('content')?.innerHTML).toBe('hash=b');
512
+ expect(onVisit).toBeCalledTimes(1);
513
+ expect(onLeave).not.toBeCalled();
514
+ });
515
+ });
516
+ it('should re-render the chain on a query-only change without firing lifecycle hooks', async () => {
517
+ history.pushState(null, '', '/list');
518
+ const onVisit = vi.fn(async () => { });
519
+ const onLeave = vi.fn(async () => { });
520
+ const componentFn = vi.fn(({ query }) => (createComponent("div", { id: "content" },
521
+ "page=",
522
+ query?.page ?? 'none')));
523
+ await usingAsync(createInjector(), async (injector) => {
524
+ const rootElement = document.getElementById('root');
525
+ initializeShadeRoot({
526
+ injector,
527
+ rootElement,
528
+ jsxElement: (createComponent(NestedRouter, { routes: {
529
+ '/list': {
530
+ component: componentFn,
531
+ onVisit,
532
+ onLeave,
533
+ query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
534
+ },
535
+ } })),
536
+ });
537
+ await flushUpdates();
538
+ expect(document.getElementById('content')?.innerHTML).toBe('page=none');
539
+ expect(onVisit).toBeCalledTimes(1);
540
+ const locationService = injector.get(LocationService);
541
+ locationService.navigate(`/list?page=${serializeValue(2)}`);
542
+ await flushUpdates();
543
+ await flushUpdates();
544
+ expect(document.getElementById('content')?.innerHTML).toBe('page=2');
545
+ expect(onVisit).toBeCalledTimes(1);
546
+ expect(onLeave).not.toBeCalled();
547
+ locationService.navigate(`/list?page=${serializeValue(5)}`);
548
+ await flushUpdates();
549
+ await flushUpdates();
550
+ expect(document.getElementById('content')?.innerHTML).toBe('page=5');
551
+ expect(onVisit).toBeCalledTimes(1);
552
+ expect(onLeave).not.toBeCalled();
553
+ });
554
+ });
555
+ it('should coalesce path + query + hash bursts into a single updateUrl', async () => {
556
+ history.pushState(null, '', '/a');
557
+ const aComponent = vi.fn(() => createComponent("div", { id: "content" }, "a"));
558
+ const bComponent = vi.fn(({ query, hash }) => (createComponent("div", { id: "content" },
559
+ "b-page=",
560
+ query?.page ?? 'none',
561
+ "-hash=",
562
+ hash ?? 'none')));
563
+ await usingAsync(createInjector(), async (injector) => {
564
+ const rootElement = document.getElementById('root');
565
+ initializeShadeRoot({
566
+ injector,
567
+ rootElement,
568
+ jsxElement: (createComponent(NestedRouter, { routes: {
569
+ '/a': { component: aComponent },
570
+ '/b': {
571
+ component: bComponent,
572
+ hash: ['x'],
573
+ query: (raw) => (typeof raw.page === 'number' ? { page: raw.page } : null),
574
+ },
575
+ } })),
576
+ });
577
+ await flushUpdates();
578
+ expect(document.getElementById('content')?.innerHTML).toBe('a');
579
+ bComponent.mockClear();
580
+ injector.get(LocationService).navigate(`/b?page=${serializeValue(7)}#x`);
581
+ await flushUpdates();
582
+ await flushUpdates();
583
+ expect(document.getElementById('content')?.innerHTML).toBe('b-page=7-hash=x');
584
+ // A single logical navigation should render the /b component exactly once,
585
+ // not three times (once per observable subscription).
586
+ expect(bComponent).toHaveBeenCalledTimes(1);
587
+ });
588
+ });
589
+ });
361
590
  describe('NestedRouter latest-wins on rapid navigation', () => {
362
591
  beforeEach(() => {
363
592
  document.body.innerHTML = '<div id="root"></div>';
@@ -387,15 +616,15 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
387
616
  const onLeaveC = vi.fn(async () => {
388
617
  callOrder.push('leave-c');
389
618
  });
390
- await usingAsync(new Injector(), async (injector) => {
619
+ await usingAsync(createInjector(), async (injector) => {
391
620
  const rootElement = document.getElementById('root');
392
621
  initializeShadeRoot({
393
622
  injector,
394
623
  rootElement,
395
624
  jsxElement: (createComponent("div", null,
396
- createComponent(NestedRouteLink, { id: "go-a", href: "/route-a" }, "a"),
397
- createComponent(NestedRouteLink, { id: "go-b", href: "/route-b" }, "b"),
398
- createComponent(NestedRouteLink, { id: "go-c", href: "/route-c" }, "c"),
625
+ createComponent(NestedRouteLink, { id: "go-a", path: "/route-a" }, "a"),
626
+ createComponent(NestedRouteLink, { id: "go-b", path: "/route-b" }, "b"),
627
+ createComponent(NestedRouteLink, { id: "go-c", path: "/route-c" }, "c"),
399
628
  createComponent(NestedRouter, { routes: {
400
629
  '/route-a': {
401
630
  component: () => createComponent("div", { id: "content" }, "route-a"),
@@ -447,14 +676,14 @@ describe('NestedRouter lifecycle element scope', () => {
447
676
  history.pushState(null, '', '/parent/child-a');
448
677
  const visitElements = [];
449
678
  const leaveElements = [];
450
- await usingAsync(new Injector(), async (injector) => {
679
+ await usingAsync(createInjector(), async (injector) => {
451
680
  const rootElement = document.getElementById('root');
452
681
  initializeShadeRoot({
453
682
  injector,
454
683
  rootElement,
455
684
  jsxElement: (createComponent("div", null,
456
- createComponent(NestedRouteLink, { id: "child-a", href: "/parent/child-a" }, "child-a"),
457
- createComponent(NestedRouteLink, { id: "child-b", href: "/parent/child-b" }, "child-b"),
685
+ createComponent(NestedRouteLink, { id: "child-a", path: "/parent/child-a" }, "child-a"),
686
+ createComponent(NestedRouteLink, { id: "child-b", path: "/parent/child-b" }, "child-b"),
458
687
  createComponent(NestedRouter, { routes: {
459
688
  '/parent': {
460
689
  component: ({ outlet }) => createComponent("div", { id: "wrapper" }, outlet ?? createComponent("span", null, "index")),
@@ -522,15 +751,15 @@ describe('NestedRouter flat routes', () => {
522
751
  });
523
752
  it('should render and navigate between flat (non-nested) Record routes', async () => {
524
753
  history.pushState(null, '', '/');
525
- await usingAsync(new Injector(), async (injector) => {
754
+ await usingAsync(createInjector(), async (injector) => {
526
755
  const rootElement = document.getElementById('root');
527
756
  initializeShadeRoot({
528
757
  injector,
529
758
  rootElement,
530
759
  jsxElement: (createComponent("div", null,
531
- createComponent(NestedRouteLink, { id: "home", href: "/" }, "home"),
532
- createComponent(NestedRouteLink, { id: "about", href: "/about" }, "about"),
533
- createComponent(NestedRouteLink, { id: "contact", href: "/contact" }, "contact"),
760
+ createComponent(NestedRouteLink, { id: "home", path: "/" }, "home"),
761
+ createComponent(NestedRouteLink, { id: "about", path: "/about" }, "about"),
762
+ createComponent(NestedRouteLink, { id: "contact", path: "/contact" }, "contact"),
534
763
  createComponent(NestedRouter, { routes: {
535
764
  '/about': { component: () => createComponent("div", { id: "content" }, "about-page") },
536
765
  '/contact': { component: () => createComponent("div", { id: "content" }, "contact-page") },
@@ -562,7 +791,7 @@ describe('NestedRouter outlet composition', () => {
562
791
  });
563
792
  it('should compose parent layout wrapping child content via outlet', async () => {
564
793
  history.pushState(null, '', '/dashboard/settings');
565
- await usingAsync(new Injector(), async (injector) => {
794
+ await usingAsync(createInjector(), async (injector) => {
566
795
  const rootElement = document.getElementById('root');
567
796
  initializeShadeRoot({
568
797
  injector,
@@ -595,7 +824,7 @@ describe('NestedRouter outlet composition', () => {
595
824
  });
596
825
  it('should render parent with fallback when navigating to parent URL without a child match', async () => {
597
826
  history.pushState(null, '', '/dashboard');
598
- await usingAsync(new Injector(), async (injector) => {
827
+ await usingAsync(createInjector(), async (injector) => {
599
828
  const rootElement = document.getElementById('root');
600
829
  initializeShadeRoot({
601
830
  injector,
@@ -634,15 +863,15 @@ describe('NestedRouter route param changes', () => {
634
863
  const onLeaveUser = vi.fn(async () => {
635
864
  callOrder.push('leave-user');
636
865
  });
637
- await usingAsync(new Injector(), async (injector) => {
866
+ await usingAsync(createInjector(), async (injector) => {
638
867
  const rootElement = document.getElementById('root');
639
868
  initializeShadeRoot({
640
869
  injector,
641
870
  rootElement,
642
871
  jsxElement: (createComponent("div", null,
643
- createComponent(NestedRouteLink, { id: "user-1", href: "/users/1" }, "User 1"),
644
- createComponent(NestedRouteLink, { id: "user-2", href: "/users/2" }, "User 2"),
645
- createComponent(NestedRouteLink, { id: "user-3", href: "/users/3" }, "User 3"),
872
+ createComponent(NestedRouteLink, { id: "user-1", path: "/users/1" }, "User 1"),
873
+ createComponent(NestedRouteLink, { id: "user-2", path: "/users/2" }, "User 2"),
874
+ createComponent(NestedRouteLink, { id: "user-3", path: "/users/3" }, "User 3"),
646
875
  createComponent(NestedRouter, { routes: {
647
876
  '/users/:id': {
648
877
  component: ({ match }) => createComponent("div", { id: "content" },
@@ -692,14 +921,14 @@ describe('NestedRouter route param changes', () => {
692
921
  const onLeaveOrg = vi.fn();
693
922
  const onVisitDash = vi.fn();
694
923
  const onLeaveDash = vi.fn();
695
- await usingAsync(new Injector(), async (injector) => {
924
+ await usingAsync(createInjector(), async (injector) => {
696
925
  const rootElement = document.getElementById('root');
697
926
  initializeShadeRoot({
698
927
  injector,
699
928
  rootElement,
700
929
  jsxElement: (createComponent("div", null,
701
- createComponent(NestedRouteLink, { id: "alpha-dash", href: "/org/alpha/dashboard" }, "Alpha Dashboard"),
702
- createComponent(NestedRouteLink, { id: "beta-dash", href: "/org/beta/dashboard" }, "Beta Dashboard"),
930
+ createComponent(NestedRouteLink, { id: "alpha-dash", path: "/org/alpha/dashboard" }, "Alpha Dashboard"),
931
+ createComponent(NestedRouteLink, { id: "beta-dash", path: "/org/beta/dashboard" }, "Beta Dashboard"),
703
932
  createComponent(NestedRouter, { routes: {
704
933
  '/org/:orgId': {
705
934
  component: ({ match, outlet }) => (createComponent("div", { id: "org" },
@@ -747,9 +976,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
747
976
  });
748
977
  it('should update RouteMatchService with the current match chain on navigation', async () => {
749
978
  history.pushState(null, '', '/parent/child-a');
750
- await usingAsync(new Injector(), async (injector) => {
979
+ await usingAsync(createInjector(), async (injector) => {
751
980
  const rootElement = document.getElementById('root');
752
- const routeMatchService = injector.getInstance(RouteMatchService);
981
+ const routeMatchService = injector.get(RouteMatchService);
753
982
  const parentRoute = {
754
983
  meta: { title: 'Parent' },
755
984
  component: ({ outlet }) => createComponent("div", null, outlet ?? createComponent("div", null, "parent-index")),
@@ -768,9 +997,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
768
997
  injector,
769
998
  rootElement,
770
999
  jsxElement: (createComponent("div", null,
771
- createComponent(NestedRouteLink, { id: "child-a", href: "/parent/child-a" }, "child-a"),
772
- createComponent(NestedRouteLink, { id: "child-b", href: "/parent/child-b" }, "child-b"),
773
- createComponent(NestedRouteLink, { id: "nowhere", href: "/nowhere" }, "nowhere"),
1000
+ createComponent(NestedRouteLink, { id: "child-a", path: "/parent/child-a" }, "child-a"),
1001
+ createComponent(NestedRouteLink, { id: "child-b", path: "/parent/child-b" }, "child-b"),
1002
+ createComponent(NestedRouteLink, { id: "nowhere", path: "/nowhere" }, "nowhere"),
774
1003
  createComponent(NestedRouter, { routes: { '/parent': parentRoute }, notFound: createComponent("div", { id: "content" }, "not found") }))),
775
1004
  });
776
1005
  // Initial load
@@ -795,9 +1024,9 @@ describe('NestedRouter + RouteMatchService integration', () => {
795
1024
  });
796
1025
  it('should expose match params through RouteMatchService', async () => {
797
1026
  history.pushState(null, '', '/users/42');
798
- await usingAsync(new Injector(), async (injector) => {
1027
+ await usingAsync(createInjector(), async (injector) => {
799
1028
  const rootElement = document.getElementById('root');
800
- const routeMatchService = injector.getInstance(RouteMatchService);
1029
+ const routeMatchService = injector.get(RouteMatchService);
801
1030
  initializeShadeRoot({
802
1031
  injector,
803
1032
  rootElement,
@@ -823,6 +1052,8 @@ describe('resolveViewTransition', () => {
823
1052
  const makeEntry = (viewTransition) => ({
824
1053
  route: { component: () => createComponent("div", null), viewTransition },
825
1054
  match: { path: '/', params: {} },
1055
+ query: null,
1056
+ hash: undefined,
826
1057
  });
827
1058
  it('should return false when router config is undefined and route has no override', () => {
828
1059
  expect(resolveViewTransition(undefined, [makeEntry()])).toBe(false);
@@ -875,13 +1106,13 @@ describe('NestedRouter view transitions', () => {
875
1106
  });
876
1107
  it('should call startViewTransition when viewTransition is enabled', async () => {
877
1108
  history.pushState(null, '', '/');
878
- await usingAsync(new Injector(), async (injector) => {
1109
+ await usingAsync(createInjector(), async (injector) => {
879
1110
  const rootElement = document.getElementById('root');
880
1111
  initializeShadeRoot({
881
1112
  injector,
882
1113
  rootElement,
883
1114
  jsxElement: (createComponent("div", null,
884
- createComponent(NestedRouteLink, { id: "go-about", href: "/about" }, "about"),
1115
+ createComponent(NestedRouteLink, { id: "go-about", path: "/about" }, "about"),
885
1116
  createComponent(NestedRouter, { viewTransition: true, routes: {
886
1117
  '/about': { component: () => createComponent("div", { id: "content" }, "about") },
887
1118
  '/': { component: () => createComponent("div", { id: "content" }, "home") },
@@ -897,13 +1128,13 @@ describe('NestedRouter view transitions', () => {
897
1128
  });
898
1129
  it('should not call startViewTransition when viewTransition is not set', async () => {
899
1130
  history.pushState(null, '', '/');
900
- await usingAsync(new Injector(), async (injector) => {
1131
+ await usingAsync(createInjector(), async (injector) => {
901
1132
  const rootElement = document.getElementById('root');
902
1133
  initializeShadeRoot({
903
1134
  injector,
904
1135
  rootElement,
905
1136
  jsxElement: (createComponent("div", null,
906
- createComponent(NestedRouteLink, { id: "go-about", href: "/about" }, "about"),
1137
+ createComponent(NestedRouteLink, { id: "go-about", path: "/about" }, "about"),
907
1138
  createComponent(NestedRouter, { routes: {
908
1139
  '/about': { component: () => createComponent("div", { id: "content" }, "about") },
909
1140
  '/': { component: () => createComponent("div", { id: "content" }, "home") },
@@ -919,13 +1150,13 @@ describe('NestedRouter view transitions', () => {
919
1150
  });
920
1151
  it('should pass types to startViewTransition when configured', async () => {
921
1152
  history.pushState(null, '', '/');
922
- await usingAsync(new Injector(), async (injector) => {
1153
+ await usingAsync(createInjector(), async (injector) => {
923
1154
  const rootElement = document.getElementById('root');
924
1155
  initializeShadeRoot({
925
1156
  injector,
926
1157
  rootElement,
927
1158
  jsxElement: (createComponent("div", null,
928
- createComponent(NestedRouteLink, { id: "go-about", href: "/about" }, "about"),
1159
+ createComponent(NestedRouteLink, { id: "go-about", path: "/about" }, "about"),
929
1160
  createComponent(NestedRouter, { viewTransition: { types: ['slide'] }, routes: {
930
1161
  '/about': { component: () => createComponent("div", { id: "content" }, "about") },
931
1162
  '/': { component: () => createComponent("div", { id: "content" }, "home") },
@@ -940,13 +1171,13 @@ describe('NestedRouter view transitions', () => {
940
1171
  });
941
1172
  it('should respect per-route viewTransition: false override', async () => {
942
1173
  history.pushState(null, '', '/');
943
- await usingAsync(new Injector(), async (injector) => {
1174
+ await usingAsync(createInjector(), async (injector) => {
944
1175
  const rootElement = document.getElementById('root');
945
1176
  initializeShadeRoot({
946
1177
  injector,
947
1178
  rootElement,
948
1179
  jsxElement: (createComponent("div", null,
949
- createComponent(NestedRouteLink, { id: "go-about", href: "/about" }, "about"),
1180
+ createComponent(NestedRouteLink, { id: "go-about", path: "/about" }, "about"),
950
1181
  createComponent(NestedRouter, { viewTransition: true, routes: {
951
1182
  '/about': {
952
1183
  component: () => createComponent("div", { id: "content" }, "about"),
@@ -966,13 +1197,13 @@ describe('NestedRouter view transitions', () => {
966
1197
  it('should fall back gracefully when startViewTransition is not available', async () => {
967
1198
  delete document.startViewTransition;
968
1199
  history.pushState(null, '', '/');
969
- await usingAsync(new Injector(), async (injector) => {
1200
+ await usingAsync(createInjector(), async (injector) => {
970
1201
  const rootElement = document.getElementById('root');
971
1202
  initializeShadeRoot({
972
1203
  injector,
973
1204
  rootElement,
974
1205
  jsxElement: (createComponent("div", null,
975
- createComponent(NestedRouteLink, { id: "go-about", href: "/about" }, "about"),
1206
+ createComponent(NestedRouteLink, { id: "go-about", path: "/about" }, "about"),
976
1207
  createComponent(NestedRouter, { viewTransition: true, routes: {
977
1208
  '/about': { component: () => createComponent("div", { id: "content" }, "about") },
978
1209
  '/': { component: () => createComponent("div", { id: "content" }, "home") },