@excom/spa-route 0.1.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 (38) hide show
  1. package/.rush/temp/chunked-rush-logs/spa-route.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/spa-route.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/spa-route.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +5 -0
  15. package/index.css +5 -0
  16. package/index.ts +29 -0
  17. package/package.json +48 -0
  18. package/rush-logs/spa-route.apply-exports.cache.log +1 -0
  19. package/rush-logs/spa-route.apply-exports.log +1 -0
  20. package/rush-logs/spa-route.build_docs.cache.log +1 -0
  21. package/rush-logs/spa-route.build_docs.log +1 -0
  22. package/rush-logs/spa-route.build_package-metas.cache.log +1 -0
  23. package/rush-logs/spa-route.build_package-metas.log +1 -0
  24. package/spa-a.ts +124 -0
  25. package/spa-manager.ts +605 -0
  26. package/spa-route.ts +322 -0
  27. package/src/spa-route.css +24 -0
  28. package/src/utils.ts +31 -0
  29. package/support/custom-elements.json +953 -0
  30. package/support/dist-docs/spa-a.md +40 -0
  31. package/support/dist-docs/spa-manager.md +66 -0
  32. package/support/dist-docs/spa-route.md +408 -0
  33. package/support/docs/README.md +314 -0
  34. package/support/package-meta.json +789 -0
  35. package/support/tests/spa-navigation.test.ts +1076 -0
  36. package/support/tests/spa-route.test.ts +766 -0
  37. package/support/tests/spa-title.test.ts +225 -0
  38. package/tsconfig.json +5 -0
@@ -0,0 +1,766 @@
1
+ import {
2
+ afterEach,
3
+ describe,
4
+ expect,
5
+ it,
6
+ spyFetch,
7
+ vi,
8
+ wait,
9
+ waitForEvent,
10
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
11
+ import { urlMatchesHref } from "../../src/utils";
12
+ import "../../index";
13
+
14
+ const happyDomScopeHack = (element: Element | Document) => {
15
+ const _oldQS = element.querySelector;
16
+ vi.spyOn(element, "querySelector").mockImplementation((str, ...args) => {
17
+ return _oldQS.call(
18
+ element,
19
+ str.trim().startsWith(":scope") ? str.replace(":scope", "") : str,
20
+ ...args,
21
+ );
22
+ });
23
+ };
24
+
25
+ if (!document.startViewTransition) {
26
+ // @ts-ignore shim
27
+ document.startViewTransition = ({ update }) => {
28
+ update();
29
+ return {
30
+ ready: Promise.resolve(),
31
+ finished: Promise.resolve(),
32
+ skipTransition: () => {},
33
+ cancel: () => {},
34
+ };
35
+ };
36
+ }
37
+
38
+ describe("spa-route / spa-a", () => {
39
+ afterEach(() => {
40
+ window.location.href = "/";
41
+ document.body.innerHTML = "";
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it("changes routes", async () => {
46
+ document.body.innerHTML = `
47
+ <spa-manager>
48
+ <spa-route route-href="/a" template-ref="#rt"></spa-route>
49
+ <spa-route route-href="/b/:foo" template-ref="/b/detail.html"></spa-route>
50
+ <spa-route route-href="/b" match-nested>
51
+ <template>Test 2</template>
52
+ </spa-route>
53
+ </spa-manager>
54
+ <spa-a route-href="/a"></spa-a>
55
+ <spa-a route-href="/b/123"></spa-a>
56
+ <template id="rt"><p></p></template>
57
+ `;
58
+ const spaManager = document.querySelector(
59
+ "spa-manager",
60
+ ) as HTMLSpaManagerElement;
61
+ const spaRouteA = document.querySelector(
62
+ "spa-route:first-of-type",
63
+ ) as HTMLSpaRouteElement;
64
+ const spaRouteB = document.querySelector(
65
+ "spa-route:nth-of-type(2)",
66
+ ) as HTMLSpaRouteElement;
67
+ const spaRouteB2 = document.querySelector(
68
+ "spa-route:nth-of-type(3)",
69
+ ) as HTMLSpaRouteElement;
70
+ const spaAA = document.querySelector(
71
+ "spa-a:first-of-type",
72
+ ) as HTMLSpaAElement;
73
+ const spaAB = document.querySelector(
74
+ "spa-a:nth-of-type(2)",
75
+ ) as HTMLSpaAElement;
76
+ happyDomScopeHack(document);
77
+ happyDomScopeHack(spaRouteB2);
78
+ const mockListener = vi.fn();
79
+ document.body.addEventListener("spa-route-did-render", mockListener);
80
+ const fetchSpy = spyFetch(
81
+ {
82
+ status: 200,
83
+ body: "<span></span>",
84
+ headers: new Headers({ "content-type": "text/html" }),
85
+ } as any,
86
+ 5,
87
+ );
88
+ const runCallbacksSpy = vi.spyOn(spaManager, "updateRoutes");
89
+ const transitionSpy = vi.spyOn(document, "startViewTransition");
90
+
91
+ // load route A
92
+ expect(runCallbacksSpy).toHaveBeenCalledTimes(0);
93
+ expect(transitionSpy).toHaveBeenCalledTimes(0);
94
+ expect(spaManager.hasRendered).toBe(false);
95
+ expect(spaManager.lastMove).toBe(null);
96
+ expect(spaManager.activeUrl).toBe("/");
97
+ expect(spaRouteA.children.length).toBe(0);
98
+ spaAA.dispatchEvent(new Event("click"));
99
+ await waitForEvent(spaRouteA, "spa-route-did-render");
100
+ expect(mockListener).toHaveBeenCalled();
101
+ expect(spaManager.hasRendered).toBe(true);
102
+ expect(spaRouteA.isLoading).toBe(false);
103
+ expect(spaRouteA.isActive).toBe(true);
104
+ expect(spaRouteA.isError).toBe(false);
105
+ expect(spaRouteA.templatePromise).toBeNull();
106
+ expect(spaRouteA.children?.[0]?.nodeName).toBe("P");
107
+ expect(spaRouteB.isActive).toBe(false);
108
+ // First paint skips View Transitions, and so does the batch that
109
+ // follows it (`spa-route-provision` of the same activation).
110
+ expect(runCallbacksSpy).toHaveBeenCalledTimes(1);
111
+ expect(transitionSpy).toHaveBeenCalledTimes(0);
112
+ // anchor is-active should be correct
113
+ expect(spaAA.isActive).toBe(true);
114
+ expect(spaAB.isActive).toBe(false);
115
+
116
+ // switch to route B / B2
117
+ await waitForEvent(spaManager, "spa-manager-push", () => {
118
+ spaAB.dispatchEvent(new Event("click"));
119
+ });
120
+ await new Promise((resolve) => setTimeout(resolve, 2));
121
+ expect(fetchSpy).toHaveBeenCalledWith("/b/detail.html", expect.any(Object));
122
+ // check that transition manager was called with "render"
123
+ await waitForEvent(spaRouteB, "spa-route-did-render");
124
+ expect(runCallbacksSpy).toHaveBeenCalledTimes(2);
125
+ expect(transitionSpy).toHaveBeenCalledTimes(1);
126
+ expect(spaManager.lastMove).toBe("push");
127
+ expect(spaManager.activeUrl).toBe("/b/123");
128
+ expect(spaRouteB.isLoading).toBe(false);
129
+ expect(spaRouteB.isActive).toBe(true);
130
+ expect(spaRouteB.isError).toBe(false);
131
+ expect(spaRouteB2.isLoading).toBe(false);
132
+ expect(spaRouteB2.isActive).toBe(true);
133
+ expect(spaRouteB2.isError).toBe(false);
134
+ expect(spaRouteA.isActive).toBe(false);
135
+ // anchor is-active should be correct
136
+ expect(spaAA.isActive).toBe(false);
137
+ expect(spaAB.isActive).toBe(true);
138
+
139
+ // Simulate user back
140
+ history.back();
141
+ // happy-dom can't simulate back nav; these two lines stand in
142
+ // @ts-expect-error
143
+ history.state.id = spaManager.router?.states.slice(-2)[0].id;
144
+ window.dispatchEvent(new Event("popstate"));
145
+
146
+ await waitForEvent(spaRouteA, "spa-route-did-render");
147
+
148
+ expect(runCallbacksSpy).toHaveBeenCalledTimes(3);
149
+ expect(transitionSpy).toHaveBeenCalledTimes(2);
150
+ expect(spaManager.lastMove).toBe("back");
151
+ expect(spaManager.activeUrl).toBe("/a");
152
+ expect(spaRouteA.isActive).toBe(true);
153
+ expect(spaRouteA.isLoading).toBe(false);
154
+ expect(spaRouteA.isError).toBe(false);
155
+ expect(spaRouteB.isActive).toBe(false);
156
+ // anchor is-active should be correct
157
+ expect(spaAA.isActive).toBe(true);
158
+ expect(spaAB.isActive).toBe(false);
159
+
160
+ // Simulate user forward
161
+ history.forward();
162
+ // happy-dom can't simulate forward nav; these two lines stand in
163
+ // @ts-expect-error
164
+ history.state.id = spaManager.router?.states.slice(-1)[0].id;
165
+ window.dispatchEvent(new Event("popstate"));
166
+
167
+ await waitForEvent(spaRouteB, "spa-route-did-render");
168
+
169
+ expect(runCallbacksSpy).toHaveBeenCalledTimes(4);
170
+ expect(transitionSpy).toHaveBeenCalledTimes(3);
171
+ expect(spaManager.lastMove).toBe("forward");
172
+ expect(spaManager.activeUrl).toBe("/b/123");
173
+ expect(spaRouteA.isActive).toBe(false);
174
+ expect(spaRouteB.isActive).toBe(true);
175
+ expect(spaRouteB2.isActive).toBe(true);
176
+ // anchor is-active should be correct
177
+ expect(spaAA.isActive).toBe(false);
178
+ expect(spaAB.isActive).toBe(true);
179
+ });
180
+
181
+ it("does not activate fallback is previous route is active", async () => {
182
+ document.body.innerHTML = `
183
+ <spa-manager>
184
+ <spa-route route-href="/"><template>A</template></spa-route>
185
+ <spa-route route-href="/b"><template>B</template></spa-route>
186
+ <spa-route route-regex=".*" is-fallback><template>Fallback</template></spa-route>
187
+ <spa-route route-href="/"><template>C</template></spa-route>
188
+ </spa-manager>
189
+ <spa-a route-href="/b"></spa-a>
190
+ <spa-a route-href="/nothing"></spa-a>
191
+ <spa-a route-href="/nothing-else"></spa-a>
192
+ `;
193
+ const spaRouteA = document.querySelector(
194
+ "spa-route:first-of-type",
195
+ ) as HTMLSpaRouteElement;
196
+ const spaRouteB = document.querySelector(
197
+ "spa-route:nth-of-type(2)",
198
+ ) as HTMLSpaRouteElement;
199
+ const spaRouteFallback = document.querySelector(
200
+ "spa-route:nth-of-type(3)",
201
+ ) as HTMLSpaRouteElement;
202
+ const spaRouteC = document.querySelector(
203
+ "spa-route:nth-of-type(4)",
204
+ ) as HTMLSpaRouteElement;
205
+ expect(spaRouteA.isActive).toBe(true);
206
+ expect(spaRouteB.isActive).toBe(false);
207
+ expect(spaRouteFallback.isActive).toBe(false);
208
+ expect(spaRouteC.isActive).toBe(true);
209
+
210
+ const spaAB = document.querySelector(
211
+ "spa-a:first-of-type",
212
+ ) as HTMLSpaAElement;
213
+ const spaANothing = document.querySelector(
214
+ "spa-a:nth-of-type(2)",
215
+ ) as HTMLSpaAElement;
216
+ await waitForEvent(document.body, "spa-manager-rendered", () => {
217
+ spaAB.dispatchEvent(new Event("click"));
218
+ });
219
+ expect(spaRouteA.isActive).toBe(false);
220
+ expect(spaRouteB.isActive).toBe(true);
221
+ expect(spaRouteFallback.isActive).toBe(false);
222
+ expect(spaRouteC.isActive).toBe(false);
223
+ expect(spaAB.isActive).toBe(true);
224
+ expect(spaANothing.isActive).toBe(false);
225
+
226
+ await waitForEvent(document.body, "spa-manager-rendered", () => {
227
+ spaANothing.dispatchEvent(new Event("click"));
228
+ });
229
+ expect(spaRouteA.isActive).toBe(false);
230
+ expect(spaRouteB.isActive).toBe(false);
231
+ expect(spaRouteFallback.isActive).toBe(true);
232
+ expect(spaRouteC.isActive).toBe(false);
233
+ expect(spaAB.isActive).toBe(false);
234
+ expect(spaANothing.isActive).toBe(true);
235
+
236
+ // unmatched → unmatched: the active fallback must not count itself
237
+ const spaANothingElse = document.querySelector(
238
+ "spa-a:nth-of-type(3)",
239
+ ) as HTMLSpaAElement;
240
+ await waitForEvent(document.body, "spa-manager-rendered", () => {
241
+ spaANothingElse.dispatchEvent(new Event("click"));
242
+ });
243
+ expect(spaRouteFallback.isActive).toBe(true);
244
+ expect(spaRouteFallback.textContent).toContain("Fallback");
245
+ expect(spaANothingElse.isActive).toBe(true);
246
+ });
247
+ });
248
+
249
+ describe("spa-manager rapid navigation", () => {
250
+ afterEach(() => {
251
+ window.location.href = "/";
252
+ document.body.innerHTML = "";
253
+ vi.restoreAllMocks();
254
+ });
255
+
256
+ it("drains route callbacks queued while a view transition is in flight", async () => {
257
+ document.body.innerHTML = `
258
+ <spa-manager>
259
+ <spa-route route-href="/a"><template>A</template></spa-route>
260
+ <spa-route route-href="/b"><template>B</template></spa-route>
261
+ <spa-route route-href="/c"><template>C</template></spa-route>
262
+ </spa-manager>
263
+ <spa-a route-href="/a"></spa-a>
264
+ <spa-a route-href="/b"></spa-a>
265
+ <spa-a route-href="/c"></spa-a>
266
+ `;
267
+ const spaManager = document.querySelector(
268
+ "spa-manager",
269
+ ) as HTMLSpaManagerElement;
270
+ const routes = Array.from(
271
+ document.querySelectorAll("spa-route"),
272
+ ) as HTMLSpaRouteElement[];
273
+ const [spaRouteA, spaRouteB, spaRouteC] = routes;
274
+ const links = Array.from(
275
+ document.querySelectorAll("spa-a"),
276
+ ) as HTMLSpaAElement[];
277
+ const [spaAA, spaAB, spaAC] = links;
278
+
279
+ // Cold first nav: no transition (`hasRendered` still false), so the
280
+ // route renders synchronously and we land on /a.
281
+ await waitForEvent(spaRouteA, "spa-route-did-render", () => {
282
+ spaAA.dispatchEvent(new Event("click"));
283
+ });
284
+ expect(spaRouteA.isActive).toBe(true);
285
+ expect(spaManager.hasRendered).toBe(true);
286
+
287
+ /* Controllable transition shim: hold each in-flight `finished` so we
288
+ resolve it on our schedule. `skipTransition` completes it early
289
+ (mirrors the View Transitions API). */
290
+ const inFlight: Array<{
291
+ complete: () => void;
292
+ skipTransition: () => void;
293
+ }> = [];
294
+ vi.spyOn(document, "startViewTransition").mockImplementation(
295
+ // @ts-ignore happy-dom test shim signature
296
+ ({ update }: { update: () => Promise<unknown> }) => {
297
+ update();
298
+ let complete!: () => void;
299
+ let settled = false;
300
+ const finished = new Promise<void>((resolve) => {
301
+ complete = () => {
302
+ if (!settled) {
303
+ settled = true;
304
+ resolve();
305
+ }
306
+ };
307
+ });
308
+ const entry = {
309
+ complete,
310
+ skipTransition: () => complete(),
311
+ };
312
+ inFlight.push(entry);
313
+ return {
314
+ ready: Promise.resolve(),
315
+ finished,
316
+ skipTransition: entry.skipTransition,
317
+ } as unknown as ViewTransition;
318
+ },
319
+ );
320
+
321
+ // Click B. Transition 1 starts (shim update swaps the DOM sync), but
322
+ // `finished` is held open.
323
+ spaAB.dispatchEvent(new Event("click"));
324
+ await wait(0);
325
+ expect(inFlight.length).toBe(1);
326
+ expect(spaRouteA.isActive).toBe(false);
327
+ expect(spaRouteB.isActive).toBe(true);
328
+ expect(spaManager.isTransitioning).toBe(true);
329
+
330
+ // Click C while transition 1 is in flight. Manager should skip it so
331
+ // queued callbacks drain immediately.
332
+ const renderedPromise = waitForEvent(spaManager, "spa-manager-rendered");
333
+ spaAC.dispatchEvent(new Event("click"));
334
+ await wait(0);
335
+ expect(spaRouteB.isActive).toBe(false);
336
+ expect(spaRouteC.isActive).toBe(true);
337
+ // skipTransition completed transition 1 → doCleanup started transition 2
338
+ expect(inFlight.length).toBe(2);
339
+
340
+ // Resolve transition 2. Now we drain to empty and emit `rendered`.
341
+ inFlight[1].complete();
342
+ await renderedPromise;
343
+
344
+ expect(spaManager.isTransitioning).toBe(false);
345
+ expect(spaManager._routeCallbacks).toEqual({
346
+ provision: [],
347
+ render: [],
348
+ unrender: [],
349
+ });
350
+ // Final DOM: only C is rendered.
351
+ expect(spaRouteA.querySelector("template")).not.toBeNull();
352
+ expect(
353
+ Array.from(spaRouteA.children).some((c) => c.tagName !== "TEMPLATE"),
354
+ ).toBe(false);
355
+ expect(
356
+ Array.from(spaRouteB.children).some((c) => c.tagName !== "TEMPLATE"),
357
+ ).toBe(false);
358
+ expect(spaRouteC.textContent).toContain("C");
359
+ });
360
+
361
+ it("skips in-flight transition when back/forward is spammed", async () => {
362
+ document.body.innerHTML = `
363
+ <spa-manager>
364
+ <spa-route route-href="/a"><template>A</template></spa-route>
365
+ <spa-route route-href="/b"><template>B</template></spa-route>
366
+ </spa-manager>
367
+ <spa-a route-href="/a"></spa-a>
368
+ <spa-a route-href="/b"></spa-a>
369
+ `;
370
+ const spaManager = document.querySelector(
371
+ "spa-manager",
372
+ ) as HTMLSpaManagerElement;
373
+ const [spaRouteA, spaRouteB] = Array.from(
374
+ document.querySelectorAll("spa-route"),
375
+ ) as HTMLSpaRouteElement[];
376
+ const [spaAA, spaAB] = Array.from(
377
+ document.querySelectorAll("spa-a"),
378
+ ) as HTMLSpaAElement[];
379
+
380
+ await waitForEvent(spaRouteA, "spa-route-did-render", () => {
381
+ spaAA.dispatchEvent(new Event("click"));
382
+ });
383
+ await waitForEvent(spaManager, "spa-manager-rendered", () => {
384
+ spaAB.dispatchEvent(new Event("click"));
385
+ });
386
+ expect(spaRouteB.isActive).toBe(true);
387
+
388
+ const skipSpy = vi.fn();
389
+ const inFlight: Array<{ complete: () => void }> = [];
390
+ vi.spyOn(document, "startViewTransition").mockImplementation(
391
+ // @ts-ignore happy-dom test shim signature
392
+ ({ update }: { update: () => Promise<unknown> }) => {
393
+ update();
394
+ let complete!: () => void;
395
+ let settled = false;
396
+ const finished = new Promise<void>((resolve) => {
397
+ complete = () => {
398
+ if (!settled) {
399
+ settled = true;
400
+ resolve();
401
+ }
402
+ };
403
+ });
404
+ inFlight.push({ complete });
405
+ return {
406
+ ready: Promise.resolve(),
407
+ finished,
408
+ skipTransition: () => {
409
+ skipSpy();
410
+ complete();
411
+ },
412
+ } as unknown as ViewTransition;
413
+ },
414
+ );
415
+
416
+ // Start back navigation; hold the transition open.
417
+ history.back();
418
+ // @ts-expect-error happy-dom history shim
419
+ history.state.id = spaManager.router?.states.slice(-2)[0].id;
420
+ window.dispatchEvent(new Event("popstate"));
421
+ await wait(0);
422
+ expect(inFlight.length).toBe(1);
423
+ expect(spaManager.isTransitioning).toBe(true);
424
+
425
+ // Spam forward while transition 1 is still animating.
426
+ history.forward();
427
+ // @ts-expect-error happy-dom history shim
428
+ history.state.id = spaManager.router?.states.slice(-1)[0].id;
429
+ window.dispatchEvent(new Event("popstate"));
430
+ await wait(0);
431
+
432
+ expect(skipSpy).toHaveBeenCalled();
433
+ expect(inFlight.length).toBe(2);
434
+ expect(spaRouteB.isActive).toBe(true);
435
+
436
+ const renderedPromise = waitForEvent(spaManager, "spa-manager-rendered");
437
+ inFlight[1].complete();
438
+ await renderedPromise;
439
+ expect(spaManager.isTransitioning).toBe(false);
440
+ });
441
+ });
442
+
443
+ describe("spa-route same-route + scroll", () => {
444
+ afterEach(() => {
445
+ window.location.href = "/";
446
+ document.body.innerHTML = "";
447
+ vi.restoreAllMocks();
448
+ });
449
+
450
+ it("reuses content and updates params when same-route=reuse", async () => {
451
+ document.body.innerHTML = `
452
+ <spa-manager>
453
+ <spa-route route-href="/users/:id" same-route="reuse">
454
+ <template><input id="keep" /></template>
455
+ </spa-route>
456
+ </spa-manager>
457
+ <spa-a route-href="/users/1"></spa-a>
458
+ <spa-a route-href="/users/2"></spa-a>
459
+ `;
460
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
461
+ const [link1, link2] = Array.from(
462
+ document.querySelectorAll("spa-a"),
463
+ ) as HTMLSpaAElement[];
464
+
465
+ await waitForEvent(route, "spa-route-did-render", () => {
466
+ link1.dispatchEvent(new Event("click"));
467
+ });
468
+ const input = route.querySelector("#keep") as HTMLInputElement;
469
+ input.value = "typed";
470
+ expect(route.provision?.params).toEqual({ id: "1" });
471
+
472
+ await waitForEvent(document.body, "spa-manager-rendered", () => {
473
+ link2.dispatchEvent(new Event("click"));
474
+ });
475
+ expect(route.isActive).toBe(true);
476
+ expect(route.provision?.params).toEqual({ id: "2" });
477
+ // same node preserved
478
+ expect(route.querySelector("#keep")).toBe(input);
479
+ expect(input.value).toBe("typed");
480
+ });
481
+
482
+ it("tears down and re-renders when same-route=refresh", async () => {
483
+ document.body.innerHTML = `
484
+ <spa-manager>
485
+ <spa-route route-href="/users/:id" same-route="refresh">
486
+ <template><input id="fresh" /></template>
487
+ </spa-route>
488
+ </spa-manager>
489
+ <spa-a route-href="/users/1"></spa-a>
490
+ <spa-a route-href="/users/2"></spa-a>
491
+ `;
492
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
493
+ const [link1, link2] = Array.from(
494
+ document.querySelectorAll("spa-a"),
495
+ ) as HTMLSpaAElement[];
496
+
497
+ await waitForEvent(route, "spa-route-did-render", () => {
498
+ link1.dispatchEvent(new Event("click"));
499
+ });
500
+ const firstInput = route.querySelector("#fresh") as HTMLInputElement;
501
+ firstInput.value = "typed";
502
+
503
+ await waitForEvent(route, "spa-route-did-render", () => {
504
+ link2.dispatchEvent(new Event("click"));
505
+ });
506
+ expect(route.isActive).toBe(true);
507
+ expect(route.provision?.params).toEqual({ id: "2" });
508
+ const secondInput = route.querySelector("#fresh") as HTMLInputElement;
509
+ expect(secondInput).not.toBe(firstInput);
510
+ expect(secondInput.value).toBe("");
511
+ });
512
+
513
+ it("setScroll resets on push/replace and restores on back/forward", async () => {
514
+ document.body.innerHTML = `
515
+ <spa-manager>
516
+ <spa-route route-href="/a"><template>A</template></spa-route>
517
+ </spa-manager>
518
+ <spa-a route-href="/a"></spa-a>
519
+ `;
520
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
521
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
522
+ const scrollToSpy = vi
523
+ .spyOn(window, "scrollTo")
524
+ .mockImplementation(() => {});
525
+
526
+ await waitForEvent(route, "spa-route-did-render", () => {
527
+ link.dispatchEvent(new Event("click"));
528
+ });
529
+ await wait(0);
530
+ expect(scrollToSpy).toHaveBeenCalledWith(
531
+ expect.objectContaining({ top: 0, left: 0 }),
532
+ );
533
+ scrollToSpy.mockClear();
534
+
535
+ const active = {
536
+ ...(route.provision?.active || { url: "/a", id: "1" }),
537
+ scrollY: 240,
538
+ scrollX: 12,
539
+ };
540
+
541
+ route.provision = { ...route.provision!, move: "push", active } as any;
542
+ route.setScroll();
543
+ await wait(0);
544
+ expect(scrollToSpy).toHaveBeenCalledWith(
545
+ expect.objectContaining({ top: 0, left: 0 }),
546
+ );
547
+ scrollToSpy.mockClear();
548
+
549
+ route.provision = { ...route.provision!, move: "back", active } as any;
550
+ route.setScroll();
551
+ await wait(0);
552
+ expect(scrollToSpy).toHaveBeenCalledWith(
553
+ expect.objectContaining({ top: 240, left: 12 }),
554
+ );
555
+ scrollToSpy.mockClear();
556
+
557
+ route.scrollSetDisabled = true;
558
+ route.setScroll();
559
+ await wait(0);
560
+ expect(scrollToSpy).not.toHaveBeenCalled();
561
+ });
562
+ });
563
+
564
+ describe("spa-route host targeting (host-ref)", () => {
565
+ afterEach(() => {
566
+ window.location.href = "/";
567
+ document.body.innerHTML = "";
568
+ vi.restoreAllMocks();
569
+ });
570
+
571
+ it("attaches a shadow root and renders into it when host-ref='shadow'", async () => {
572
+ document.body.innerHTML = `
573
+ <spa-manager>
574
+ <spa-route route-href="/test" host-ref="shadow">
575
+ <template><p>shadow</p></template>
576
+ </spa-route>
577
+ </spa-manager>
578
+ <spa-a route-href="/test"></spa-a>
579
+ `;
580
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
581
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
582
+
583
+ // shadow root is attached as soon as host-ref is parsed
584
+ expect(route.shadowRoot).not.toBeNull();
585
+ expect(route.renderHost).toBe(route.shadowRoot);
586
+ expect(route.isActive).toBe(false);
587
+
588
+ await waitForEvent(route, "spa-route-did-render", () => {
589
+ link.dispatchEvent(new Event("click"));
590
+ });
591
+
592
+ expect(route.isActive).toBe(true);
593
+ expect(route.shadowRoot!.querySelector("p")?.textContent).toBe("shadow");
594
+ // light DOM only holds the source <template>; no rendered <p>
595
+ expect(route.querySelector("p")).toBeNull();
596
+ expect(route.querySelector("template")).not.toBeNull();
597
+ });
598
+
599
+ it("renders into an author-provided iframe when host-ref='iframe'", async () => {
600
+ document.body.innerHTML = `
601
+ <spa-manager>
602
+ <spa-route route-href="/test" host-ref="iframe">
603
+ <template><p>in frame</p></template>
604
+ <iframe data-render-host srcdoc="<!doctype html><html><body></body></html>"></iframe>
605
+ </spa-route>
606
+ </spa-manager>
607
+ <spa-a route-href="/test"></spa-a>
608
+ `;
609
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
610
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
611
+ const iframe = route.querySelector(
612
+ "iframe[data-render-host]",
613
+ ) as HTMLIFrameElement;
614
+
615
+ expect(iframe).not.toBeNull();
616
+
617
+ await waitForEvent(route, "spa-route-did-render", () => {
618
+ link.dispatchEvent(new Event("click"));
619
+ });
620
+
621
+ expect(route.renderHost).toBe(iframe.contentDocument!.body);
622
+ expect(
623
+ iframe.contentDocument!.body.querySelector("p")?.textContent,
624
+ ).toBe("in frame");
625
+ // light DOM only holds the iframe and the source template;
626
+ // no rendered <p> at the route's own level
627
+ expect(Array.from(route.children).some((c) => c.tagName === "P")).toBe(
628
+ false,
629
+ );
630
+ });
631
+
632
+ it("renders into a host found by CSS selector when host-ref is a selector", async () => {
633
+ document.body.innerHTML = `
634
+ <div id="render-target"></div>
635
+ <spa-manager>
636
+ <spa-route route-href="/test" host-ref="#render-target">
637
+ <template><p>elsewhere</p></template>
638
+ </spa-route>
639
+ </spa-manager>
640
+ <spa-a route-href="/test"></spa-a>
641
+ `;
642
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
643
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
644
+ const target = document.getElementById("render-target")!;
645
+
646
+ expect(route.renderHost).toBe(target);
647
+
648
+ await waitForEvent(route, "spa-route-did-render", () => {
649
+ link.dispatchEvent(new Event("click"));
650
+ });
651
+
652
+ expect(target.querySelector("p")?.textContent).toBe("elsewhere");
653
+ expect(route.querySelector("p")).toBeNull();
654
+ });
655
+
656
+ it("keeps the author iframe when host-ref switches away from 'iframe'", async () => {
657
+ document.body.innerHTML = `
658
+ <spa-manager>
659
+ <spa-route route-href="/test" host-ref="iframe">
660
+ <template><p>x</p></template>
661
+ <iframe data-render-host srcdoc="<!doctype html><html><body></body></html>"></iframe>
662
+ </spa-route>
663
+ </spa-manager>
664
+ <spa-a route-href="/test"></spa-a>
665
+ `;
666
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
667
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
668
+
669
+ await waitForEvent(route, "spa-route-did-render", () => {
670
+ link.dispatchEvent(new Event("click"));
671
+ });
672
+ const iframe = route.querySelector(
673
+ "iframe[data-render-host]",
674
+ ) as HTMLIFrameElement;
675
+ expect(iframe.contentDocument!.body.querySelector("p")).not.toBeNull();
676
+
677
+ await waitForEvent(route, "spa-route-did-render", () => {
678
+ route.hostRef = "shadow";
679
+ });
680
+
681
+ expect(route.querySelector("iframe[data-render-host]")).toBe(iframe);
682
+ expect(iframe.contentDocument!.body.querySelector("p")).toBeNull();
683
+ expect(route.shadowRoot!.querySelector("p")?.textContent).toBe("x");
684
+ });
685
+
686
+ it("re-targets rendered content when host-ref changes from light DOM to shadow", async () => {
687
+ document.body.innerHTML = `
688
+ <spa-manager>
689
+ <spa-route route-href="/test">
690
+ <template><p>moves</p></template>
691
+ </spa-route>
692
+ </spa-manager>
693
+ <spa-a route-href="/test"></spa-a>
694
+ `;
695
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
696
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
697
+
698
+ await waitForEvent(route, "spa-route-did-render", () => {
699
+ link.dispatchEvent(new Event("click"));
700
+ });
701
+ expect(route.querySelector("p")?.textContent).toBe("moves");
702
+ expect(route.shadowRoot).toBeNull();
703
+
704
+ await waitForEvent(route, "spa-route-did-render", () => {
705
+ route.hostRef = "shadow";
706
+ });
707
+
708
+ expect(route.shadowRoot).not.toBeNull();
709
+ expect(route.shadowRoot!.querySelector("p")?.textContent).toBe("moves");
710
+ expect(route.querySelector("p")).toBeNull();
711
+ });
712
+
713
+ it("clears the external selector host when route deactivates", async () => {
714
+ document.body.innerHTML = `
715
+ <div id="render-target"></div>
716
+ <spa-manager>
717
+ <spa-route route-href="/test" host-ref="#render-target">
718
+ <template><p>routed</p></template>
719
+ </spa-route>
720
+ </spa-manager>
721
+ <spa-a route-href="/test"></spa-a>
722
+ `;
723
+ const route = document.querySelector("spa-route") as HTMLSpaRouteElement;
724
+ const link = document.querySelector("spa-a") as HTMLSpaAElement;
725
+ const target = document.getElementById("render-target")!;
726
+
727
+ await waitForEvent(route, "spa-route-did-render", () => {
728
+ link.dispatchEvent(new Event("click"));
729
+ });
730
+ expect(target.querySelector("p")).not.toBeNull();
731
+
732
+ route.isActive = false;
733
+ await wait(0);
734
+
735
+ expect(target.querySelector("p")).toBeNull();
736
+ });
737
+ });
738
+
739
+ describe("utils", () => {
740
+ it("urlMatchesHref", () => {
741
+ expect(urlMatchesHref("/a", "/a")).toBe(true);
742
+ expect(urlMatchesHref("/a", "/b")).toBe(false);
743
+ expect(urlMatchesHref("/a", "/a/")).toBe(false);
744
+ expect(urlMatchesHref("/a/", "/a")).toBe(false);
745
+ expect(urlMatchesHref("/a", "/a/b")).toBe(false);
746
+ expect(urlMatchesHref("/a/b", "/a")).toBe(false);
747
+ expect(urlMatchesHref("/a/b", "/a/b")).toBe(true);
748
+ // search
749
+ expect(urlMatchesHref("/a?foo=1", "/a?foo=1")).toBe(true);
750
+ expect(urlMatchesHref("/a?foo=1", "/a/?foo=2")).toBe(false);
751
+ expect(urlMatchesHref("/a?foo=1", "/a/?foo=1&bar=2")).toBe(false);
752
+ expect(urlMatchesHref("/a?foo=1&bar=2", "/a?bar=2&foo=1")).toBe(true);
753
+ // hash
754
+ expect(urlMatchesHref("/a#foo", "/a#foo")).toBe(true);
755
+ expect(urlMatchesHref("/a#foo", "/a/#bar")).toBe(false);
756
+ expect(urlMatchesHref("/a#foo", "/a/#foo/bar")).toBe(false);
757
+ expect(urlMatchesHref("/a#foo/bar", "/a/#foo")).toBe(false);
758
+ expect(urlMatchesHref("/a/b", "/a/b#foo")).toBe(false);
759
+ // same tests with ignoreHash: true (pathname still must match)
760
+ expect(urlMatchesHref("/a", "/a#foo", { ignoreHash: true })).toBe(true);
761
+ expect(urlMatchesHref("/a", "/a/#bar", { ignoreHash: true })).toBe(false);
762
+ expect(urlMatchesHref("/a/", "/a/#bar", { ignoreHash: true })).toBe(true);
763
+ expect(urlMatchesHref("/a", "/a#foo/bar", { ignoreHash: true })).toBe(true);
764
+ expect(urlMatchesHref("/a/b", "/a/b#foo", { ignoreHash: true })).toBe(true);
765
+ });
766
+ });