@furystack/shades 11.1.0 → 12.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 (166) hide show
  1. package/CHANGELOG.md +291 -0
  2. package/README.md +13 -13
  3. package/esm/component-factory.spec.js +13 -5
  4. package/esm/component-factory.spec.js.map +1 -1
  5. package/esm/components/index.d.ts +4 -1
  6. package/esm/components/index.d.ts.map +1 -1
  7. package/esm/components/index.js +4 -1
  8. package/esm/components/index.js.map +1 -1
  9. package/esm/components/lazy-load.d.ts +2 -4
  10. package/esm/components/lazy-load.d.ts.map +1 -1
  11. package/esm/components/lazy-load.js +40 -24
  12. package/esm/components/lazy-load.js.map +1 -1
  13. package/esm/components/lazy-load.spec.js +57 -50
  14. package/esm/components/lazy-load.spec.js.map +1 -1
  15. package/esm/components/link-to-route.d.ts +2 -0
  16. package/esm/components/link-to-route.d.ts.map +1 -1
  17. package/esm/components/link-to-route.js +3 -2
  18. package/esm/components/link-to-route.js.map +1 -1
  19. package/esm/components/link-to-route.spec.js +13 -9
  20. package/esm/components/link-to-route.spec.js.map +1 -1
  21. package/esm/components/nested-route-link.d.ts +62 -0
  22. package/esm/components/nested-route-link.d.ts.map +1 -0
  23. package/esm/components/nested-route-link.js +66 -0
  24. package/esm/components/nested-route-link.js.map +1 -0
  25. package/esm/components/nested-route-link.spec.d.ts +2 -0
  26. package/esm/components/nested-route-link.spec.d.ts.map +1 -0
  27. package/esm/components/nested-route-link.spec.js +179 -0
  28. package/esm/components/nested-route-link.spec.js.map +1 -0
  29. package/esm/components/nested-route-types.d.ts +37 -0
  30. package/esm/components/nested-route-types.d.ts.map +1 -0
  31. package/esm/components/nested-route-types.js +2 -0
  32. package/esm/components/nested-route-types.js.map +1 -0
  33. package/esm/components/nested-router.d.ts +103 -0
  34. package/esm/components/nested-router.d.ts.map +1 -0
  35. package/esm/components/nested-router.js +178 -0
  36. package/esm/components/nested-router.js.map +1 -0
  37. package/esm/components/nested-router.spec.d.ts +2 -0
  38. package/esm/components/nested-router.spec.d.ts.map +1 -0
  39. package/esm/components/nested-router.spec.js +659 -0
  40. package/esm/components/nested-router.spec.js.map +1 -0
  41. package/esm/components/route-link.d.ts +4 -0
  42. package/esm/components/route-link.d.ts.map +1 -1
  43. package/esm/components/route-link.js +5 -5
  44. package/esm/components/route-link.js.map +1 -1
  45. package/esm/components/route-link.spec.js +16 -12
  46. package/esm/components/route-link.spec.js.map +1 -1
  47. package/esm/components/router.d.ts +20 -2
  48. package/esm/components/router.d.ts.map +1 -1
  49. package/esm/components/router.js +3 -0
  50. package/esm/components/router.js.map +1 -1
  51. package/esm/components/router.spec.js +75 -74
  52. package/esm/components/router.spec.js.map +1 -1
  53. package/esm/initialize.d.ts +11 -0
  54. package/esm/initialize.d.ts.map +1 -1
  55. package/esm/initialize.js +5 -0
  56. package/esm/initialize.js.map +1 -1
  57. package/esm/jsx.d.ts +83 -2
  58. package/esm/jsx.d.ts.map +1 -1
  59. package/esm/models/children-list.d.ts +5 -1
  60. package/esm/models/children-list.d.ts.map +1 -1
  61. package/esm/models/partial-element.d.ts +12 -2
  62. package/esm/models/partial-element.d.ts.map +1 -1
  63. package/esm/models/render-options.d.ts +89 -3
  64. package/esm/models/render-options.d.ts.map +1 -1
  65. package/esm/models/selection-state.d.ts +4 -0
  66. package/esm/models/selection-state.d.ts.map +1 -1
  67. package/esm/services/location-service.d.ts +11 -0
  68. package/esm/services/location-service.d.ts.map +1 -1
  69. package/esm/services/location-service.js +11 -0
  70. package/esm/services/location-service.js.map +1 -1
  71. package/esm/services/resource-manager.d.ts +24 -0
  72. package/esm/services/resource-manager.d.ts.map +1 -1
  73. package/esm/services/resource-manager.js +30 -0
  74. package/esm/services/resource-manager.js.map +1 -1
  75. package/esm/services/resource-manager.spec.js +93 -0
  76. package/esm/services/resource-manager.spec.js.map +1 -1
  77. package/esm/services/screen-service.d.ts +81 -4
  78. package/esm/services/screen-service.d.ts.map +1 -1
  79. package/esm/services/screen-service.js +75 -4
  80. package/esm/services/screen-service.js.map +1 -1
  81. package/esm/services/screen-service.spec.js +91 -7
  82. package/esm/services/screen-service.spec.js.map +1 -1
  83. package/esm/shade-component.d.ts +17 -4
  84. package/esm/shade-component.d.ts.map +1 -1
  85. package/esm/shade-component.js +67 -5
  86. package/esm/shade-component.js.map +1 -1
  87. package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
  88. package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
  89. package/esm/shade-host-props-ref.integration.spec.js +381 -0
  90. package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
  91. package/esm/shade-resources.integration.spec.js +208 -39
  92. package/esm/shade-resources.integration.spec.js.map +1 -1
  93. package/esm/shade.d.ts +20 -17
  94. package/esm/shade.d.ts.map +1 -1
  95. package/esm/shade.js +172 -33
  96. package/esm/shade.js.map +1 -1
  97. package/esm/shade.spec.js +31 -30
  98. package/esm/shade.spec.js.map +1 -1
  99. package/esm/shades.integration.spec.js +135 -72
  100. package/esm/shades.integration.spec.js.map +1 -1
  101. package/esm/style-manager.d.ts +2 -2
  102. package/esm/style-manager.js +2 -2
  103. package/esm/svg-types.d.ts +389 -0
  104. package/esm/svg-types.d.ts.map +1 -0
  105. package/esm/svg-types.js +9 -0
  106. package/esm/svg-types.js.map +1 -0
  107. package/esm/svg.d.ts +15 -0
  108. package/esm/svg.d.ts.map +1 -0
  109. package/esm/svg.js +76 -0
  110. package/esm/svg.js.map +1 -0
  111. package/esm/svg.spec.d.ts +2 -0
  112. package/esm/svg.spec.d.ts.map +1 -0
  113. package/esm/svg.spec.js +80 -0
  114. package/esm/svg.spec.js.map +1 -0
  115. package/esm/vnode.d.ts +103 -0
  116. package/esm/vnode.d.ts.map +1 -0
  117. package/esm/vnode.integration.spec.d.ts +2 -0
  118. package/esm/vnode.integration.spec.d.ts.map +1 -0
  119. package/esm/vnode.integration.spec.js +494 -0
  120. package/esm/vnode.integration.spec.js.map +1 -0
  121. package/esm/vnode.js +453 -0
  122. package/esm/vnode.js.map +1 -0
  123. package/esm/vnode.spec.d.ts +2 -0
  124. package/esm/vnode.spec.d.ts.map +1 -0
  125. package/esm/vnode.spec.js +473 -0
  126. package/esm/vnode.spec.js.map +1 -0
  127. package/package.json +3 -3
  128. package/src/component-factory.spec.tsx +18 -5
  129. package/src/components/index.ts +4 -1
  130. package/src/components/lazy-load.spec.tsx +82 -75
  131. package/src/components/lazy-load.tsx +49 -27
  132. package/src/components/link-to-route.spec.tsx +25 -21
  133. package/src/components/link-to-route.tsx +4 -2
  134. package/src/components/nested-route-link.spec.tsx +303 -0
  135. package/src/components/nested-route-link.tsx +100 -0
  136. package/src/components/nested-route-types.ts +42 -0
  137. package/src/components/nested-router.spec.tsx +817 -0
  138. package/src/components/nested-router.tsx +256 -0
  139. package/src/components/route-link.spec.tsx +22 -18
  140. package/src/components/route-link.tsx +6 -5
  141. package/src/components/router.spec.tsx +109 -108
  142. package/src/components/router.tsx +15 -2
  143. package/src/initialize.ts +12 -0
  144. package/src/jsx.ts +129 -2
  145. package/src/models/children-list.ts +7 -1
  146. package/src/models/partial-element.ts +13 -2
  147. package/src/models/render-options.ts +90 -3
  148. package/src/models/selection-state.ts +4 -0
  149. package/src/services/location-service.tsx +11 -0
  150. package/src/services/resource-manager.spec.ts +116 -0
  151. package/src/services/resource-manager.ts +30 -0
  152. package/src/services/screen-service.spec.ts +109 -7
  153. package/src/services/screen-service.ts +81 -4
  154. package/src/shade-component.ts +72 -6
  155. package/src/shade-host-props-ref.integration.spec.tsx +460 -0
  156. package/src/shade-resources.integration.spec.tsx +276 -52
  157. package/src/shade.spec.tsx +40 -39
  158. package/src/shade.ts +186 -58
  159. package/src/shades.integration.spec.tsx +154 -80
  160. package/src/style-manager.ts +2 -2
  161. package/src/svg-types.ts +437 -0
  162. package/src/svg.spec.ts +89 -0
  163. package/src/svg.ts +78 -0
  164. package/src/vnode.integration.spec.tsx +657 -0
  165. package/src/vnode.spec.ts +579 -0
  166. package/src/vnode.ts +508 -0
@@ -0,0 +1,659 @@
1
+ import { Injector } from '@furystack/inject';
2
+ import { sleepAsync, usingAsync } from '@furystack/utils';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { initializeShadeRoot } from '../initialize.js';
5
+ import { createComponent } from '../shade-component.js';
6
+ import { buildMatchChain, findDivergenceIndex, NestedRouter, renderMatchChain, } from './nested-router.js';
7
+ import { RouteLink } from './route-link.js';
8
+ describe('buildMatchChain', () => {
9
+ it('should match a simple leaf route', () => {
10
+ const route = { component: () => createComponent("div", null) };
11
+ const chain = buildMatchChain({ '/about': route }, '/about');
12
+ expect(chain).toHaveLength(1);
13
+ expect(chain[0].route).toBe(route);
14
+ });
15
+ it('should return null when no route matches', () => {
16
+ const route = { component: () => createComponent("div", null) };
17
+ const chain = buildMatchChain({ '/about': route }, '/missing');
18
+ expect(chain).toBeNull();
19
+ });
20
+ it('should match a parent with a child route', () => {
21
+ const child = { component: () => createComponent("div", null, "child") };
22
+ const parent = {
23
+ component: ({ outlet }) => createComponent("div", null, outlet),
24
+ children: { '/sub': child },
25
+ };
26
+ const chain = buildMatchChain({ '/parent': parent }, '/parent/sub');
27
+ expect(chain).toHaveLength(2);
28
+ expect(chain[0].route).toBe(parent);
29
+ expect(chain[1].route).toBe(child);
30
+ });
31
+ it('should match parent alone when no child matches', () => {
32
+ const child = { component: () => createComponent("div", null, "child") };
33
+ const parent = {
34
+ component: ({ outlet }) => createComponent("div", null, outlet),
35
+ children: { '/sub': child },
36
+ };
37
+ const chain = buildMatchChain({ '/parent': parent }, '/parent');
38
+ expect(chain).toHaveLength(1);
39
+ expect(chain[0].route).toBe(parent);
40
+ });
41
+ it('should extract route parameters from the URL', () => {
42
+ const route = { component: () => createComponent("div", null) };
43
+ const chain = buildMatchChain({ '/users/:id': route }, '/users/42');
44
+ expect(chain).toHaveLength(1);
45
+ expect(chain[0].match.params).toEqual({ id: '42' });
46
+ });
47
+ it('should match deep nesting (3 levels)', () => {
48
+ const grandchild = { component: () => createComponent("div", null, "grandchild") };
49
+ const child = {
50
+ component: ({ outlet }) => createComponent("div", null, outlet),
51
+ children: { '/gc': grandchild },
52
+ };
53
+ const parent = {
54
+ component: ({ outlet }) => createComponent("div", null, outlet),
55
+ children: { '/child': child },
56
+ };
57
+ const chain = buildMatchChain({ '/root': parent }, '/root/child/gc');
58
+ expect(chain).toHaveLength(3);
59
+ expect(chain[0].route).toBe(parent);
60
+ expect(chain[1].route).toBe(child);
61
+ expect(chain[2].route).toBe(grandchild);
62
+ });
63
+ it('should return the first matching route in definition order', () => {
64
+ const specific = { component: () => createComponent("div", null, "specific") };
65
+ const catchAll = { component: () => createComponent("div", null, "catch-all") };
66
+ const chain = buildMatchChain({ '/:slug': specific, '/': catchAll }, '/hello');
67
+ expect(chain).toHaveLength(1);
68
+ expect(chain[0].route).toBe(specific);
69
+ expect(chain[0].match.params).toEqual({ slug: 'hello' });
70
+ });
71
+ it('should match root "/" parent with children against child URLs (path-to-regexp v8 workaround)', () => {
72
+ const child = { component: () => createComponent("div", null, "buttons") };
73
+ const parent = {
74
+ component: ({ outlet }) => createComponent("div", null,
75
+ "layout",
76
+ outlet),
77
+ children: { '/buttons': child },
78
+ };
79
+ const chain = buildMatchChain({ '/': parent }, '/buttons');
80
+ expect(chain).toHaveLength(2);
81
+ expect(chain[0].route).toBe(parent);
82
+ expect(chain[1].route).toBe(child);
83
+ });
84
+ it('should match root "/" parent alone when URL is exactly "/"', () => {
85
+ const child = { component: () => createComponent("div", null, "buttons") };
86
+ const parent = {
87
+ component: ({ outlet }) => createComponent("div", null,
88
+ "layout",
89
+ outlet),
90
+ children: { '/buttons': child },
91
+ };
92
+ const chain = buildMatchChain({ '/': parent }, '/');
93
+ expect(chain).toHaveLength(1);
94
+ expect(chain[0].route).toBe(parent);
95
+ });
96
+ it('should prefer a more specific route over the root "/" parent', () => {
97
+ const specificChild = { component: () => createComponent("div", null, "specific") };
98
+ const rootChild = { component: () => createComponent("div", null, "root-child") };
99
+ const rootParent = {
100
+ component: ({ outlet }) => createComponent("div", null, outlet),
101
+ children: { '/other': rootChild },
102
+ };
103
+ const chain = buildMatchChain({ '/specific': specificChild, '/': rootParent }, '/specific');
104
+ expect(chain).toHaveLength(1);
105
+ expect(chain[0].route).toBe(specificChild);
106
+ });
107
+ it('should extract parameters from both parent and child', () => {
108
+ const child = { component: () => createComponent("div", null) };
109
+ const parent = {
110
+ component: ({ outlet }) => createComponent("div", null, outlet),
111
+ children: { '/posts/:postId': child },
112
+ };
113
+ const chain = buildMatchChain({ '/users/:userId': parent }, '/users/5/posts/10');
114
+ expect(chain).toHaveLength(2);
115
+ expect(chain[0].match.params).toEqual({ userId: '5' });
116
+ expect(chain[1].match.params).toEqual({ postId: '10' });
117
+ });
118
+ });
119
+ describe('findDivergenceIndex', () => {
120
+ const makeEntry = (id, params = {}) => ({
121
+ route: { component: () => createComponent("div", null, id) },
122
+ match: { path: '/', params },
123
+ });
124
+ it('should return 0 for completely different chains', () => {
125
+ const oldChain = [makeEntry(1)];
126
+ const newChain = [makeEntry(2)];
127
+ expect(findDivergenceIndex(oldChain, newChain)).toBe(0);
128
+ });
129
+ it('should return minLength when one chain is a prefix of the other', () => {
130
+ const entry = makeEntry(1);
131
+ const oldChain = [entry];
132
+ const newChain = [entry, makeEntry(2)];
133
+ expect(findDivergenceIndex(oldChain, newChain)).toBe(1);
134
+ });
135
+ it('should return the length when chains are identical', () => {
136
+ const entry1 = makeEntry(1);
137
+ const entry2 = makeEntry(2);
138
+ const chain = [entry1, entry2];
139
+ expect(findDivergenceIndex(chain, chain)).toBe(2);
140
+ });
141
+ it('should detect divergence from changed params', () => {
142
+ const route = { component: () => createComponent("div", null) };
143
+ const oldChain = [{ route, match: { path: '/', params: { id: '1' } } }];
144
+ const newChain = [{ route, match: { path: '/', params: { id: '2' } } }];
145
+ expect(findDivergenceIndex(oldChain, newChain)).toBe(0);
146
+ });
147
+ });
148
+ describe('renderMatchChain', () => {
149
+ it('should render a single leaf route with outlet undefined', () => {
150
+ const componentFn = vi.fn(({ outlet }) => (createComponent("div", null,
151
+ "leaf",
152
+ outlet ? 'has-outlet' : 'no-outlet')));
153
+ const chain = [
154
+ {
155
+ route: { component: componentFn },
156
+ match: { path: '/leaf', params: {} },
157
+ },
158
+ ];
159
+ const result = renderMatchChain(chain, '/leaf');
160
+ expect(componentFn).toHaveBeenCalledTimes(1);
161
+ expect(componentFn).toHaveBeenCalledWith({
162
+ currentUrl: '/leaf',
163
+ match: { path: '/leaf', params: {} },
164
+ outlet: undefined,
165
+ });
166
+ expect(result.chainElements).toHaveLength(1);
167
+ expect(result.jsx).toBe(result.chainElements[0]);
168
+ });
169
+ it('should render inside-out: innermost first, then pass as outlet to parent', () => {
170
+ const callOrder = [];
171
+ const childComponent = vi.fn(({ outlet }) => {
172
+ callOrder.push('child');
173
+ return createComponent("span", null,
174
+ "child",
175
+ outlet);
176
+ });
177
+ const parentComponent = vi.fn(({ outlet }) => {
178
+ callOrder.push('parent');
179
+ return createComponent("div", null,
180
+ "parent",
181
+ outlet);
182
+ });
183
+ const chain = [
184
+ {
185
+ route: { component: parentComponent },
186
+ match: { path: '/parent', params: {} },
187
+ },
188
+ {
189
+ route: { component: childComponent },
190
+ match: { path: '/child', params: {} },
191
+ },
192
+ ];
193
+ const result = renderMatchChain(chain, '/parent/child');
194
+ expect(callOrder).toEqual(['child', 'parent']);
195
+ expect(childComponent).toHaveBeenCalledWith(expect.objectContaining({ outlet: undefined }));
196
+ expect(parentComponent).toHaveBeenCalledWith(expect.objectContaining({ outlet: expect.anything() }));
197
+ expect(result.chainElements).toHaveLength(2);
198
+ expect(result.jsx).toBe(result.chainElements[0]);
199
+ expect(result.chainElements[0]).not.toBe(result.chainElements[1]);
200
+ });
201
+ it('should pass currentUrl to every component in the chain', () => {
202
+ const urls = [];
203
+ const makeComponent = () => vi.fn(({ currentUrl }) => {
204
+ urls.push(currentUrl);
205
+ return createComponent("div", null);
206
+ });
207
+ const grandchild = makeComponent();
208
+ const child = makeComponent();
209
+ const parent = makeComponent();
210
+ const chain = [
211
+ { route: { component: parent }, match: { path: '/a', params: {} } },
212
+ { route: { component: child }, match: { path: '/b', params: {} } },
213
+ { route: { component: grandchild }, match: { path: '/c', params: {} } },
214
+ ];
215
+ renderMatchChain(chain, '/a/b/c');
216
+ expect(urls).toEqual(['/a/b/c', '/a/b/c', '/a/b/c']);
217
+ });
218
+ it('should return per-entry chainElements where each entry is the component output at that level', () => {
219
+ const grandchildEl = createComponent("div", null, "grandchild");
220
+ const childComponent = vi.fn(({ outlet }) => createComponent("section", null,
221
+ "child",
222
+ outlet));
223
+ const parentComponent = vi.fn(({ outlet }) => createComponent("main", null,
224
+ "parent",
225
+ outlet));
226
+ const chain = [
227
+ { route: { component: parentComponent }, match: { path: '/a', params: {} } },
228
+ { route: { component: childComponent }, match: { path: '/b', params: {} } },
229
+ { route: { component: () => grandchildEl }, match: { path: '/c', params: {} } },
230
+ ];
231
+ const result = renderMatchChain(chain, '/a/b/c');
232
+ expect(result.chainElements).toHaveLength(3);
233
+ // chainElements[2] is the leaf (grandchild output)
234
+ expect(result.chainElements[2]).toBe(grandchildEl);
235
+ // chainElements[1] is the child wrapping the grandchild
236
+ expect(result.chainElements[1]).not.toBe(grandchildEl);
237
+ // chainElements[0] is the outermost parent (same as jsx)
238
+ expect(result.chainElements[0]).toBe(result.jsx);
239
+ // Each level wraps the next, so they must all be different
240
+ expect(result.chainElements[0]).not.toBe(result.chainElements[1]);
241
+ expect(result.chainElements[1]).not.toBe(result.chainElements[2]);
242
+ });
243
+ });
244
+ describe('NestedRouter lifecycle hooks', () => {
245
+ beforeEach(() => {
246
+ document.body.innerHTML = '<div id="root"></div>';
247
+ });
248
+ afterEach(() => {
249
+ document.body.innerHTML = '';
250
+ });
251
+ it('should correctly fire onVisit/onLeave across nested child switches and notFound transitions', async () => {
252
+ history.pushState(null, '', '/parent/child-a');
253
+ const callOrder = [];
254
+ const onVisitParent = vi.fn(async () => {
255
+ callOrder.push('visit-parent');
256
+ });
257
+ const onLeaveParent = vi.fn(async () => {
258
+ callOrder.push('leave-parent');
259
+ });
260
+ const onVisitChildA = vi.fn(async () => {
261
+ callOrder.push('visit-child-a');
262
+ });
263
+ const onLeaveChildA = vi.fn(async () => {
264
+ callOrder.push('leave-child-a');
265
+ });
266
+ const onVisitChildB = vi.fn(async () => {
267
+ callOrder.push('visit-child-b');
268
+ });
269
+ const onLeaveChildB = vi.fn(async () => {
270
+ callOrder.push('leave-child-b');
271
+ });
272
+ const onVisitOther = vi.fn(async () => {
273
+ callOrder.push('visit-other');
274
+ });
275
+ const onLeaveOther = vi.fn(async () => {
276
+ callOrder.push('leave-other');
277
+ });
278
+ await usingAsync(new Injector(), async (injector) => {
279
+ const rootElement = document.getElementById('root');
280
+ initializeShadeRoot({
281
+ injector,
282
+ rootElement,
283
+ jsxElement: (createComponent("div", null,
284
+ createComponent(RouteLink, { id: "child-a", href: "/parent/child-a" }, "child-a"),
285
+ createComponent(RouteLink, { id: "child-b", href: "/parent/child-b" }, "child-b"),
286
+ createComponent(RouteLink, { id: "other", href: "/other" }, "other"),
287
+ createComponent(RouteLink, { id: "nowhere", href: "/nowhere" }, "nowhere"),
288
+ createComponent(NestedRouter, { routes: {
289
+ '/parent': {
290
+ component: ({ outlet }) => createComponent("div", { id: "wrapper" }, outlet ?? createComponent("div", { id: "content" }, "parent-index")),
291
+ onVisit: onVisitParent,
292
+ onLeave: onLeaveParent,
293
+ children: {
294
+ '/child-a': {
295
+ component: () => createComponent("div", { id: "content" }, "child-a"),
296
+ onVisit: onVisitChildA,
297
+ onLeave: onLeaveChildA,
298
+ },
299
+ '/child-b': {
300
+ component: () => createComponent("div", { id: "content" }, "child-b"),
301
+ onVisit: onVisitChildB,
302
+ onLeave: onLeaveChildB,
303
+ },
304
+ },
305
+ },
306
+ '/other': {
307
+ component: () => createComponent("div", { id: "content" }, "other"),
308
+ onVisit: onVisitOther,
309
+ onLeave: onLeaveOther,
310
+ },
311
+ }, notFound: createComponent("div", { id: "content" }, "not found") }))),
312
+ });
313
+ const getContent = () => document.getElementById('content')?.innerHTML;
314
+ const clickOn = (name) => document.getElementById(name)?.click();
315
+ // --- Initial load at /parent/child-a ---
316
+ await sleepAsync(100);
317
+ expect(getContent()).toBe('child-a');
318
+ expect(onVisitParent).toBeCalledTimes(1);
319
+ expect(onVisitChildA).toBeCalledTimes(1);
320
+ expect(callOrder).toEqual(['visit-parent', 'visit-child-a']);
321
+ // --- Click same route: no lifecycle hooks should fire ---
322
+ callOrder.length = 0;
323
+ clickOn('child-a');
324
+ await sleepAsync(100);
325
+ expect(onVisitParent).toBeCalledTimes(1);
326
+ expect(onVisitChildA).toBeCalledTimes(1);
327
+ expect(callOrder).toEqual([]);
328
+ // --- Switch child: only child lifecycle fires, parent stays ---
329
+ callOrder.length = 0;
330
+ clickOn('child-b');
331
+ await sleepAsync(100);
332
+ expect(getContent()).toBe('child-b');
333
+ expect(onLeaveChildA).toBeCalledTimes(1);
334
+ expect(onVisitChildB).toBeCalledTimes(1);
335
+ expect(onLeaveParent).not.toBeCalled();
336
+ expect(onVisitParent).toBeCalledTimes(1);
337
+ expect(callOrder).toEqual(['leave-child-a', 'visit-child-b']);
338
+ // --- Navigate to a completely different branch ---
339
+ callOrder.length = 0;
340
+ clickOn('other');
341
+ await sleepAsync(100);
342
+ expect(getContent()).toBe('other');
343
+ expect(onLeaveChildB).toBeCalledTimes(1);
344
+ expect(onLeaveParent).toBeCalledTimes(1);
345
+ expect(onVisitOther).toBeCalledTimes(1);
346
+ // onLeave fires innermost-first, then onVisit for the new branch
347
+ expect(callOrder).toEqual(['leave-child-b', 'leave-parent', 'visit-other']);
348
+ // --- Navigate to non-matching URL: onLeave for all active ---
349
+ callOrder.length = 0;
350
+ clickOn('nowhere');
351
+ await sleepAsync(100);
352
+ expect(getContent()).toBe('not found');
353
+ expect(onLeaveOther).toBeCalledTimes(1);
354
+ expect(callOrder).toEqual(['leave-other']);
355
+ });
356
+ });
357
+ });
358
+ describe('NestedRouter lifecycle element scope', () => {
359
+ beforeEach(() => {
360
+ document.body.innerHTML = '<div id="root"></div>';
361
+ });
362
+ afterEach(() => {
363
+ document.body.innerHTML = '';
364
+ });
365
+ it('should pass the child element (not the full tree) to onLeave/onVisit when switching between sibling children', async () => {
366
+ history.pushState(null, '', '/parent/child-a');
367
+ const visitElements = [];
368
+ const leaveElements = [];
369
+ await usingAsync(new Injector(), async (injector) => {
370
+ const rootElement = document.getElementById('root');
371
+ initializeShadeRoot({
372
+ injector,
373
+ rootElement,
374
+ jsxElement: (createComponent("div", null,
375
+ createComponent(RouteLink, { id: "child-a", href: "/parent/child-a" }, "child-a"),
376
+ createComponent(RouteLink, { id: "child-b", href: "/parent/child-b" }, "child-b"),
377
+ createComponent(NestedRouter, { routes: {
378
+ '/parent': {
379
+ component: ({ outlet }) => createComponent("div", { id: "wrapper" }, outlet ?? createComponent("span", null, "index")),
380
+ onVisit: async ({ element }) => {
381
+ visitElements.push({ route: 'parent', element });
382
+ },
383
+ onLeave: async ({ element }) => {
384
+ leaveElements.push({ route: 'parent', element });
385
+ },
386
+ children: {
387
+ '/child-a': {
388
+ component: () => createComponent("div", { id: "child-a-content" }, "child-a"),
389
+ onVisit: async ({ element }) => {
390
+ visitElements.push({ route: 'child-a', element });
391
+ },
392
+ onLeave: async ({ element }) => {
393
+ leaveElements.push({ route: 'child-a', element });
394
+ },
395
+ },
396
+ '/child-b': {
397
+ component: () => createComponent("div", { id: "child-b-content" }, "child-b"),
398
+ onVisit: async ({ element }) => {
399
+ visitElements.push({ route: 'child-b', element });
400
+ },
401
+ onLeave: async ({ element }) => {
402
+ leaveElements.push({ route: 'child-b', element });
403
+ },
404
+ },
405
+ },
406
+ },
407
+ } }))),
408
+ });
409
+ const clickOn = (name) => document.getElementById(name)?.click();
410
+ // --- Initial load at /parent/child-a ---
411
+ await sleepAsync(100);
412
+ expect(visitElements).toHaveLength(2);
413
+ // Parent's onVisit element should be the full tree (parent wrapping child)
414
+ expect(visitElements[0].route).toBe('parent');
415
+ expect(visitElements[0].element.getAttribute('id')).toBe('wrapper');
416
+ // Child-a's onVisit element should be just the child element, not the wrapper
417
+ expect(visitElements[1].route).toBe('child-a');
418
+ expect(visitElements[1].element.getAttribute('id')).toBe('child-a-content');
419
+ // --- Switch to child-b: parent stays, only child lifecycle fires ---
420
+ visitElements.length = 0;
421
+ leaveElements.length = 0;
422
+ clickOn('child-b');
423
+ await sleepAsync(100);
424
+ // onLeave should receive the child-a element, not the full wrapper
425
+ expect(leaveElements).toHaveLength(1);
426
+ expect(leaveElements[0].route).toBe('child-a');
427
+ expect(leaveElements[0].element.getAttribute('id')).toBe('child-a-content');
428
+ // onVisit should receive the child-b element, not the full wrapper
429
+ expect(visitElements).toHaveLength(1);
430
+ expect(visitElements[0].route).toBe('child-b');
431
+ expect(visitElements[0].element.getAttribute('id')).toBe('child-b-content');
432
+ });
433
+ });
434
+ });
435
+ describe('NestedRouter flat routes', () => {
436
+ beforeEach(() => {
437
+ document.body.innerHTML = '<div id="root"></div>';
438
+ });
439
+ afterEach(() => {
440
+ document.body.innerHTML = '';
441
+ });
442
+ it('should render and navigate between flat (non-nested) Record routes', async () => {
443
+ history.pushState(null, '', '/');
444
+ await usingAsync(new Injector(), async (injector) => {
445
+ const rootElement = document.getElementById('root');
446
+ initializeShadeRoot({
447
+ injector,
448
+ rootElement,
449
+ jsxElement: (createComponent("div", null,
450
+ createComponent(RouteLink, { id: "home", href: "/" }, "home"),
451
+ createComponent(RouteLink, { id: "about", href: "/about" }, "about"),
452
+ createComponent(RouteLink, { id: "contact", href: "/contact" }, "contact"),
453
+ createComponent(NestedRouter, { routes: {
454
+ '/about': { component: () => createComponent("div", { id: "content" }, "about-page") },
455
+ '/contact': { component: () => createComponent("div", { id: "content" }, "contact-page") },
456
+ '/': { component: () => createComponent("div", { id: "content" }, "home-page") },
457
+ }, notFound: createComponent("div", { id: "content" }, "not found") }))),
458
+ });
459
+ const getContent = () => document.getElementById('content')?.innerHTML;
460
+ const clickOn = (name) => document.getElementById(name)?.click();
461
+ await sleepAsync(100);
462
+ expect(getContent()).toBe('home-page');
463
+ clickOn('about');
464
+ await sleepAsync(100);
465
+ expect(getContent()).toBe('about-page');
466
+ clickOn('contact');
467
+ await sleepAsync(100);
468
+ expect(getContent()).toBe('contact-page');
469
+ clickOn('home');
470
+ await sleepAsync(100);
471
+ expect(getContent()).toBe('home-page');
472
+ });
473
+ });
474
+ });
475
+ describe('NestedRouter outlet composition', () => {
476
+ beforeEach(() => {
477
+ document.body.innerHTML = '<div id="root"></div>';
478
+ });
479
+ afterEach(() => {
480
+ document.body.innerHTML = '';
481
+ });
482
+ it('should compose parent layout wrapping child content via outlet', async () => {
483
+ history.pushState(null, '', '/dashboard/settings');
484
+ await usingAsync(new Injector(), async (injector) => {
485
+ const rootElement = document.getElementById('root');
486
+ initializeShadeRoot({
487
+ injector,
488
+ rootElement,
489
+ jsxElement: (createComponent(NestedRouter, { routes: {
490
+ '/dashboard': {
491
+ component: ({ outlet }) => (createComponent("div", { id: "layout" },
492
+ createComponent("header", { id: "header" }, "Dashboard Header"),
493
+ createComponent("main", { id: "main" }, outlet ?? createComponent("div", { id: "child" }, "index")))),
494
+ children: {
495
+ '/settings': {
496
+ component: () => createComponent("div", { id: "child" }, "settings-content"),
497
+ },
498
+ '/profile': {
499
+ component: () => createComponent("div", { id: "child" }, "profile-content"),
500
+ },
501
+ },
502
+ },
503
+ } })),
504
+ });
505
+ await sleepAsync(100);
506
+ // Parent layout should be rendered with child inside
507
+ expect(document.getElementById('header')?.innerHTML).toBe('Dashboard Header');
508
+ expect(document.getElementById('child')?.innerHTML).toBe('settings-content');
509
+ // Child is inside #main which is inside #layout
510
+ const layout = document.getElementById('layout');
511
+ expect(layout).toBeTruthy();
512
+ expect(layout.querySelector('#main #child')).toBeTruthy();
513
+ });
514
+ });
515
+ it('should render parent with fallback when navigating to parent URL without a child match', async () => {
516
+ history.pushState(null, '', '/dashboard');
517
+ await usingAsync(new Injector(), async (injector) => {
518
+ const rootElement = document.getElementById('root');
519
+ initializeShadeRoot({
520
+ injector,
521
+ rootElement,
522
+ jsxElement: (createComponent(NestedRouter, { routes: {
523
+ '/dashboard': {
524
+ component: ({ outlet }) => (createComponent("div", { id: "layout" },
525
+ createComponent("main", { id: "main" }, outlet ?? createComponent("div", { id: "child" }, "dashboard-index")))),
526
+ children: {
527
+ '/settings': {
528
+ component: () => createComponent("div", { id: "child" }, "settings"),
529
+ },
530
+ },
531
+ },
532
+ } })),
533
+ });
534
+ await sleepAsync(100);
535
+ // Parent matched alone, outlet is undefined, so the fallback renders
536
+ expect(document.getElementById('child')?.innerHTML).toBe('dashboard-index');
537
+ });
538
+ });
539
+ });
540
+ describe('NestedRouter route param changes', () => {
541
+ beforeEach(() => {
542
+ document.body.innerHTML = '<div id="root"></div>';
543
+ });
544
+ afterEach(() => {
545
+ document.body.innerHTML = '';
546
+ });
547
+ it('should re-render and fire lifecycle hooks when route params change', async () => {
548
+ history.pushState(null, '', '/users/1');
549
+ const callOrder = [];
550
+ const onVisitUser = vi.fn(async () => {
551
+ callOrder.push('visit-user');
552
+ });
553
+ const onLeaveUser = vi.fn(async () => {
554
+ callOrder.push('leave-user');
555
+ });
556
+ await usingAsync(new Injector(), async (injector) => {
557
+ const rootElement = document.getElementById('root');
558
+ initializeShadeRoot({
559
+ injector,
560
+ rootElement,
561
+ jsxElement: (createComponent("div", null,
562
+ createComponent(RouteLink, { id: "user-1", href: "/users/1" }, "User 1"),
563
+ createComponent(RouteLink, { id: "user-2", href: "/users/2" }, "User 2"),
564
+ createComponent(RouteLink, { id: "user-3", href: "/users/3" }, "User 3"),
565
+ createComponent(NestedRouter, { routes: {
566
+ '/users/:id': {
567
+ component: ({ match }) => createComponent("div", { id: "content" },
568
+ "user-",
569
+ match.params.id),
570
+ onVisit: onVisitUser,
571
+ onLeave: onLeaveUser,
572
+ },
573
+ } }))),
574
+ });
575
+ const getContent = () => document.getElementById('content')?.innerHTML;
576
+ const clickOn = (name) => document.getElementById(name)?.click();
577
+ // Initial load at /users/1
578
+ await sleepAsync(100);
579
+ expect(getContent()).toBe('user-1');
580
+ expect(onVisitUser).toHaveBeenCalledTimes(1);
581
+ expect(callOrder).toEqual(['visit-user']);
582
+ // Navigate to /users/2 — same route, different param → lifecycle should fire
583
+ callOrder.length = 0;
584
+ clickOn('user-2');
585
+ await sleepAsync(100);
586
+ expect(getContent()).toBe('user-2');
587
+ expect(onLeaveUser).toHaveBeenCalledTimes(1);
588
+ expect(onVisitUser).toHaveBeenCalledTimes(2);
589
+ expect(callOrder).toEqual(['leave-user', 'visit-user']);
590
+ // Navigate to /users/3
591
+ callOrder.length = 0;
592
+ clickOn('user-3');
593
+ await sleepAsync(100);
594
+ expect(getContent()).toBe('user-3');
595
+ expect(onLeaveUser).toHaveBeenCalledTimes(2);
596
+ expect(onVisitUser).toHaveBeenCalledTimes(3);
597
+ expect(callOrder).toEqual(['leave-user', 'visit-user']);
598
+ // Click same user — no lifecycle change
599
+ callOrder.length = 0;
600
+ clickOn('user-3');
601
+ await sleepAsync(100);
602
+ expect(getContent()).toBe('user-3');
603
+ expect(onLeaveUser).toHaveBeenCalledTimes(2);
604
+ expect(onVisitUser).toHaveBeenCalledTimes(3);
605
+ expect(callOrder).toEqual([]);
606
+ });
607
+ });
608
+ it('should re-render nested child when parent params change', async () => {
609
+ history.pushState(null, '', '/org/alpha/dashboard');
610
+ const onVisitOrg = vi.fn();
611
+ const onLeaveOrg = vi.fn();
612
+ const onVisitDash = vi.fn();
613
+ const onLeaveDash = vi.fn();
614
+ await usingAsync(new Injector(), async (injector) => {
615
+ const rootElement = document.getElementById('root');
616
+ initializeShadeRoot({
617
+ injector,
618
+ rootElement,
619
+ jsxElement: (createComponent("div", null,
620
+ createComponent(RouteLink, { id: "alpha-dash", href: "/org/alpha/dashboard" }, "Alpha Dashboard"),
621
+ createComponent(RouteLink, { id: "beta-dash", href: "/org/beta/dashboard" }, "Beta Dashboard"),
622
+ createComponent(NestedRouter, { routes: {
623
+ '/org/:orgId': {
624
+ component: ({ match, outlet }) => (createComponent("div", { id: "org" },
625
+ "org-",
626
+ match.params.orgId,
627
+ outlet)),
628
+ onVisit: onVisitOrg,
629
+ onLeave: onLeaveOrg,
630
+ children: {
631
+ '/dashboard': {
632
+ component: () => createComponent("div", { id: "child" }, "dashboard"),
633
+ onVisit: onVisitDash,
634
+ onLeave: onLeaveDash,
635
+ },
636
+ },
637
+ },
638
+ } }))),
639
+ });
640
+ const clickOn = (name) => document.getElementById(name)?.click();
641
+ await sleepAsync(100);
642
+ expect(document.getElementById('org')?.textContent).toContain('org-alpha');
643
+ expect(document.getElementById('child')?.innerHTML).toBe('dashboard');
644
+ expect(onVisitOrg).toHaveBeenCalledTimes(1);
645
+ expect(onVisitDash).toHaveBeenCalledTimes(1);
646
+ // Change parent param: org/alpha → org/beta, child stays /dashboard
647
+ // Both parent and child should get leave/visit since parent diverges
648
+ clickOn('beta-dash');
649
+ await sleepAsync(100);
650
+ expect(document.getElementById('org')?.textContent).toContain('org-beta');
651
+ expect(document.getElementById('child')?.innerHTML).toBe('dashboard');
652
+ expect(onLeaveOrg).toHaveBeenCalledTimes(1);
653
+ expect(onLeaveDash).toHaveBeenCalledTimes(1);
654
+ expect(onVisitOrg).toHaveBeenCalledTimes(2);
655
+ expect(onVisitDash).toHaveBeenCalledTimes(2);
656
+ });
657
+ });
658
+ });
659
+ //# sourceMappingURL=nested-router.spec.js.map