@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.
- package/.rush/temp/chunked-rush-logs/spa-route.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/spa-route.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/spa-route.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +5 -0
- package/index.css +5 -0
- package/index.ts +29 -0
- package/package.json +48 -0
- package/rush-logs/spa-route.apply-exports.cache.log +1 -0
- package/rush-logs/spa-route.apply-exports.log +1 -0
- package/rush-logs/spa-route.build_docs.cache.log +1 -0
- package/rush-logs/spa-route.build_docs.log +1 -0
- package/rush-logs/spa-route.build_package-metas.cache.log +1 -0
- package/rush-logs/spa-route.build_package-metas.log +1 -0
- package/spa-a.ts +124 -0
- package/spa-manager.ts +605 -0
- package/spa-route.ts +322 -0
- package/src/spa-route.css +24 -0
- package/src/utils.ts +31 -0
- package/support/custom-elements.json +953 -0
- package/support/dist-docs/spa-a.md +40 -0
- package/support/dist-docs/spa-manager.md +66 -0
- package/support/dist-docs/spa-route.md +408 -0
- package/support/docs/README.md +314 -0
- package/support/package-meta.json +789 -0
- package/support/tests/spa-navigation.test.ts +1076 -0
- package/support/tests/spa-route.test.ts +766 -0
- package/support/tests/spa-title.test.ts +225 -0
- 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
|
+
});
|