@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,1076 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
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
|
+
import { KitLogger } from "@excom/kit-logger";
|
|
14
|
+
import { kitRouter } from "@excom/kit-router";
|
|
15
|
+
|
|
16
|
+
if (!document.startViewTransition) {
|
|
17
|
+
/*
|
|
18
|
+
* Shim: like a browser, `finished` settles only after the update
|
|
19
|
+
* callback (exercises spa-manager's render-timeout / allSettled).
|
|
20
|
+
* @ts-ignore shim
|
|
21
|
+
*/
|
|
22
|
+
document.startViewTransition = ({ update }) => {
|
|
23
|
+
const done = Promise.resolve(update()).then(() => {});
|
|
24
|
+
return {
|
|
25
|
+
ready: done,
|
|
26
|
+
updateCallbackDone: done,
|
|
27
|
+
finished: done,
|
|
28
|
+
skipTransition: () => {},
|
|
29
|
+
cancel: () => {},
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type RouterState = {
|
|
35
|
+
id: string;
|
|
36
|
+
url: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
ttypes?: string[];
|
|
39
|
+
isInit?: boolean;
|
|
40
|
+
};
|
|
41
|
+
type RouterInternals = {
|
|
42
|
+
states: RouterState[];
|
|
43
|
+
currentStateId: string | null;
|
|
44
|
+
currentTempData: { move: null | string; event?: unknown };
|
|
45
|
+
routes: unknown[];
|
|
46
|
+
};
|
|
47
|
+
const router = kitRouter as unknown as RouterInternals & typeof kitRouter;
|
|
48
|
+
|
|
49
|
+
/** Put the singleton router back to a cold-load state. */
|
|
50
|
+
const resetRouter = () => {
|
|
51
|
+
history.replaceState(null, "", "/");
|
|
52
|
+
router.states = [{ id: "init", url: "/", isInit: true }];
|
|
53
|
+
router.currentStateId = null;
|
|
54
|
+
router.currentTempData = { move: null };
|
|
55
|
+
router.MAX_STATES = router.DEFAULT_MAX_STATES;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Emulate the browser landing on a known history entry (back / forward). */
|
|
59
|
+
const popstate = (state: RouterState, hasUAVisualTransition = false) => {
|
|
60
|
+
history.replaceState({ id: state.id }, "", state.url);
|
|
61
|
+
const e = new PopStateEvent("popstate");
|
|
62
|
+
Object.defineProperty(e, "hasUAVisualTransition", {
|
|
63
|
+
value: hasUAVisualTransition,
|
|
64
|
+
});
|
|
65
|
+
window.dispatchEvent(e);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const click = (el: Element) => el.dispatchEvent(new Event("click"));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Navigate and wait for the manager to settle. A cold first paint
|
|
72
|
+
* renders synchronously, but `spa-route-provision` still batches once
|
|
73
|
+
* `has-rendered` flips, so `spa-manager-rendered` follows every nav.
|
|
74
|
+
*/
|
|
75
|
+
const navigate = (manager: Element, trigger: () => void) =>
|
|
76
|
+
waitForEvent(manager, "spa-manager-rendered", trigger);
|
|
77
|
+
|
|
78
|
+
const captureEvent = <T = unknown>(target: EventTarget, type: string) => {
|
|
79
|
+
const events: CustomEvent<T>[] = [];
|
|
80
|
+
target.addEventListener(type, (e) => events.push(e as CustomEvent<T>));
|
|
81
|
+
return events;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const hasRenderedContent = (route: Element) =>
|
|
85
|
+
Array.from(route.children).some((c) => c.tagName !== "TEMPLATE");
|
|
86
|
+
|
|
87
|
+
const q = <T extends Element>(selector: string) =>
|
|
88
|
+
document.querySelector(selector) as T;
|
|
89
|
+
const qa = <T extends Element>(selector: string) =>
|
|
90
|
+
Array.from(document.querySelectorAll(selector)) as T[];
|
|
91
|
+
|
|
92
|
+
const touch = (
|
|
93
|
+
type: "touchstart" | "touchmove" | "touchend" | "touchcancel",
|
|
94
|
+
touches: Array<{ identifier: number; clientX: number; clientY: number }>
|
|
95
|
+
) => {
|
|
96
|
+
const e = new Event(type, { cancelable: true, bubbles: true });
|
|
97
|
+
Object.defineProperty(e, "touches", { value: touches });
|
|
98
|
+
document.dispatchEvent(e);
|
|
99
|
+
return e;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
describe("spa-a actions", () => {
|
|
103
|
+
beforeEach(resetRouter);
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
document.body.innerHTML = "";
|
|
106
|
+
router.routes = [];
|
|
107
|
+
vi.restoreAllMocks();
|
|
108
|
+
resetRouter();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("walks history with route-action back / forward when possible", async () => {
|
|
112
|
+
document.body.innerHTML = `
|
|
113
|
+
<spa-manager>
|
|
114
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
115
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
116
|
+
</spa-manager>
|
|
117
|
+
<spa-a route-href="/a"></spa-a>
|
|
118
|
+
<spa-a route-href="/b"></spa-a>
|
|
119
|
+
<spa-a route-action="back"></spa-a>
|
|
120
|
+
<spa-a route-action="forward"></spa-a>
|
|
121
|
+
`;
|
|
122
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
123
|
+
const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
124
|
+
const [linkA, linkB, back, forward] = qa<HTMLSpaAElement>("spa-a");
|
|
125
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
126
|
+
const errorSpy = vi
|
|
127
|
+
.spyOn(KitLogger, "error")
|
|
128
|
+
.mockImplementation(() => {});
|
|
129
|
+
const backEvents = captureEvent(manager, "spa-manager-back");
|
|
130
|
+
const forwardEvents = captureEvent(manager, "spa-manager-forward");
|
|
131
|
+
|
|
132
|
+
await navigate(manager, () => click(linkA));
|
|
133
|
+
await navigate(manager, () => click(linkB));
|
|
134
|
+
expect(linkA.wasActive).toBe(true);
|
|
135
|
+
expect(linkB.wasActive).toBe(false);
|
|
136
|
+
|
|
137
|
+
// cannot go forward from the newest entry: logs, does nothing
|
|
138
|
+
click(forward);
|
|
139
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
140
|
+
expect(errorSpy).toHaveBeenLastCalledWith("spa-a", "Cannot go forward");
|
|
141
|
+
|
|
142
|
+
click(back);
|
|
143
|
+
expect(goSpy).toHaveBeenLastCalledWith(-1);
|
|
144
|
+
|
|
145
|
+
// the browser lands on /a
|
|
146
|
+
await navigate(manager, () => popstate(router.states[1]));
|
|
147
|
+
expect(backEvents).toHaveLength(1);
|
|
148
|
+
expect(manager.lastMove).toBe("back");
|
|
149
|
+
expect(manager.activeUrl).toBe("/a");
|
|
150
|
+
expect(routeA.isActive).toBe(true);
|
|
151
|
+
expect(routeB.isActive).toBe(false);
|
|
152
|
+
expect(routeB.wasActive).toBe(true);
|
|
153
|
+
expect(linkB.wasActive).toBe(true);
|
|
154
|
+
expect(linkA.isActive).toBe(true);
|
|
155
|
+
|
|
156
|
+
click(forward);
|
|
157
|
+
expect(goSpy).toHaveBeenLastCalledWith(1);
|
|
158
|
+
await navigate(manager, () => popstate(router.states[2]));
|
|
159
|
+
expect(forwardEvents).toHaveLength(1);
|
|
160
|
+
expect(manager.lastMove).toBe("forward");
|
|
161
|
+
expect(routeB.isActive).toBe(true);
|
|
162
|
+
expect(routeA.wasActive).toBe(true);
|
|
163
|
+
|
|
164
|
+
// back to the initial entry (no route matches), then back is refused
|
|
165
|
+
await navigate(manager, () => popstate(router.states[0]));
|
|
166
|
+
expect(routeB.isActive).toBe(false);
|
|
167
|
+
expect(hasRenderedContent(routeB)).toBe(false);
|
|
168
|
+
goSpy.mockClear();
|
|
169
|
+
click(back);
|
|
170
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
171
|
+
expect(errorSpy).toHaveBeenLastCalledWith("spa-a", "Cannot go back");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("falls back to push when back / forward is impossible but route-href is set", async () => {
|
|
175
|
+
document.body.innerHTML = `
|
|
176
|
+
<spa-manager>
|
|
177
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
178
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
179
|
+
</spa-manager>
|
|
180
|
+
<spa-a route-action="back" route-href="/a"></spa-a>
|
|
181
|
+
<spa-a route-action="forward" route-href="/b"></spa-a>
|
|
182
|
+
`;
|
|
183
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
184
|
+
const [, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
185
|
+
const [backLink, forwardLink] = qa<HTMLSpaAElement>("spa-a");
|
|
186
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
187
|
+
|
|
188
|
+
await navigate(manager, () => click(backLink));
|
|
189
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
190
|
+
expect(manager.activeUrl).toBe("/a");
|
|
191
|
+
expect(backLink.isActive).toBe(true);
|
|
192
|
+
|
|
193
|
+
await navigate(manager, () => click(forwardLink));
|
|
194
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
195
|
+
expect(manager.lastMove).toBe("push");
|
|
196
|
+
expect(routeB.isActive).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("logs when there is nothing to navigate to", () => {
|
|
200
|
+
document.body.innerHTML = `<spa-a></spa-a>`;
|
|
201
|
+
const errorSpy = vi
|
|
202
|
+
.spyOn(KitLogger, "error")
|
|
203
|
+
.mockImplementation(() => {});
|
|
204
|
+
const before = router.states.length;
|
|
205
|
+
click(q("spa-a"));
|
|
206
|
+
expect(errorSpy).toHaveBeenCalledWith("spa-a", "No route-href provided");
|
|
207
|
+
expect(router.states.length).toBe(before);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("replaces the current entry with route-action=replace", async () => {
|
|
211
|
+
document.body.innerHTML = `
|
|
212
|
+
<spa-manager>
|
|
213
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
214
|
+
<spa-route route-href="/r"><template>R</template></spa-route>
|
|
215
|
+
</spa-manager>
|
|
216
|
+
<spa-a route-href="/a"></spa-a>
|
|
217
|
+
<spa-a route-href="/r" route-action="replace"></spa-a>
|
|
218
|
+
`;
|
|
219
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
220
|
+
const [routeA, routeR] = qa<HTMLSpaRouteElement>("spa-route");
|
|
221
|
+
const [linkA, linkR] = qa<HTMLSpaAElement>("spa-a");
|
|
222
|
+
const replaceEvents = captureEvent(manager, "spa-manager-replace");
|
|
223
|
+
|
|
224
|
+
await navigate(manager, () => click(linkA));
|
|
225
|
+
expect(router.states.map((s) => s.url)).toEqual(["/", "/a"]);
|
|
226
|
+
|
|
227
|
+
await navigate(manager, () => click(linkR));
|
|
228
|
+
expect(replaceEvents).toHaveLength(1);
|
|
229
|
+
expect(router.states.map((s) => s.url)).toEqual(["/", "/r"]);
|
|
230
|
+
expect(manager.lastMove).toBe("replace");
|
|
231
|
+
expect(manager.activeUrl).toBe("/r");
|
|
232
|
+
expect(routeA.isActive).toBe(false);
|
|
233
|
+
expect(routeR.isActive).toBe(true);
|
|
234
|
+
// the replaced entry is gone: the previous entry is now `/`
|
|
235
|
+
expect(routeA.wasActive).toBe(false);
|
|
236
|
+
expect(linkA.wasActive).toBe(false);
|
|
237
|
+
expect(routeR.provision?.previous).toEqual(
|
|
238
|
+
expect.objectContaining({ url: "/" })
|
|
239
|
+
);
|
|
240
|
+
expect(kitRouter.canGoBack()).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("replace as the very first move leaves no previous entry", async () => {
|
|
244
|
+
document.body.innerHTML = `
|
|
245
|
+
<spa-manager>
|
|
246
|
+
<spa-route route-href="/"><template>Home</template></spa-route>
|
|
247
|
+
<spa-route route-href="/x"><template>X</template></spa-route>
|
|
248
|
+
</spa-manager>
|
|
249
|
+
<spa-a route-href="/x" route-action="replace"></spa-a>
|
|
250
|
+
`;
|
|
251
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
252
|
+
const [home, x] = qa<HTMLSpaRouteElement>("spa-route");
|
|
253
|
+
expect(home.isActive).toBe(true);
|
|
254
|
+
await waitForEvent(manager, "spa-manager-rendered");
|
|
255
|
+
expect(home.provision?.move).toBeNull();
|
|
256
|
+
expect(home.provision?.previous).toBeNull();
|
|
257
|
+
|
|
258
|
+
await navigate(manager, () => click(q("spa-a")));
|
|
259
|
+
expect(router.states.map((s) => s.url)).toEqual(["/x"]);
|
|
260
|
+
expect(home.isActive).toBe(false);
|
|
261
|
+
// no previous entry survives a first-move replace
|
|
262
|
+
expect(home.wasActive).toBe(false);
|
|
263
|
+
expect(x.isActive).toBe(true);
|
|
264
|
+
expect(x.provision?.previous).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("passes document-title and transition-types to the router / view transition", async () => {
|
|
268
|
+
document.body.innerHTML = `
|
|
269
|
+
<spa-manager>
|
|
270
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
271
|
+
<spa-route route-href="/t"><template>T</template></spa-route>
|
|
272
|
+
</spa-manager>
|
|
273
|
+
<spa-a route-href="/a"></spa-a>
|
|
274
|
+
<spa-a route-href="/t" document-title="Title T" transition-types="fade slide"></spa-a>
|
|
275
|
+
`;
|
|
276
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
277
|
+
const [linkA, linkT] = qa<HTMLSpaAElement>("spa-a");
|
|
278
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
279
|
+
const transitionEvents = captureEvent<{ transition: ViewTransition }>(
|
|
280
|
+
manager,
|
|
281
|
+
"spa-manager-transition"
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await navigate(manager, () => click(linkA));
|
|
285
|
+
transitionSpy.mockClear();
|
|
286
|
+
transitionEvents.length = 0;
|
|
287
|
+
|
|
288
|
+
await navigate(manager, () => click(linkT));
|
|
289
|
+
expect(transitionSpy).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(transitionEvents).toHaveLength(1);
|
|
291
|
+
expect(transitionEvents[0].detail.transition).toBe(
|
|
292
|
+
transitionSpy.mock.results[0].value
|
|
293
|
+
);
|
|
294
|
+
expect(transitionSpy.mock.calls[0][0]).toEqual(
|
|
295
|
+
expect.objectContaining({ types: ["route-push", "fade", "slide"] })
|
|
296
|
+
);
|
|
297
|
+
expect(manager.provision?.active).toEqual(
|
|
298
|
+
expect.objectContaining({
|
|
299
|
+
url: "/t",
|
|
300
|
+
title: "Title T",
|
|
301
|
+
ttypes: ["fade", "slide"],
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// going back uses the outgoing entry's types
|
|
306
|
+
await navigate(manager, () => popstate(router.states[1]));
|
|
307
|
+
expect(transitionSpy.mock.calls[1][0]).toEqual(
|
|
308
|
+
expect.objectContaining({ types: ["route-back", "fade", "slide"] })
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("honours match-hash for is-active and resets is-active when route-href is removed", async () => {
|
|
313
|
+
document.body.innerHTML = `
|
|
314
|
+
<spa-manager>
|
|
315
|
+
<spa-route route-href="/h"><template>H</template></spa-route>
|
|
316
|
+
</spa-manager>
|
|
317
|
+
<spa-a route-href="/h#x" match-hash></spa-a>
|
|
318
|
+
<spa-a route-href="/h#x"></spa-a>
|
|
319
|
+
<spa-a route-href="/h#y"></spa-a>
|
|
320
|
+
`;
|
|
321
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
322
|
+
const [strict, loose, other] = qa<HTMLSpaAElement>("spa-a");
|
|
323
|
+
|
|
324
|
+
await navigate(manager, () => click(other));
|
|
325
|
+
expect(location.hash).toBe("#y");
|
|
326
|
+
expect(strict.isActive).toBe(false);
|
|
327
|
+
expect(loose.isActive).toBe(true);
|
|
328
|
+
expect(other.isActive).toBe(true);
|
|
329
|
+
|
|
330
|
+
click(strict);
|
|
331
|
+
await wait(5);
|
|
332
|
+
expect(location.hash).toBe("#x");
|
|
333
|
+
expect(strict.isActive).toBe(true);
|
|
334
|
+
expect(loose.isActive).toBe(true);
|
|
335
|
+
expect(loose.wasActive).toBe(true);
|
|
336
|
+
expect(other.isActive).toBe(true);
|
|
337
|
+
|
|
338
|
+
loose.routeHref = null;
|
|
339
|
+
await wait(0);
|
|
340
|
+
expect(loose.isActive).toBe(false);
|
|
341
|
+
expect(loose.routeInstance).toBeNull();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("navigating to the current URL keeps the rendered screen", async () => {
|
|
345
|
+
document.body.innerHTML = `
|
|
346
|
+
<spa-manager>
|
|
347
|
+
<spa-route route-href="/a"><template><p>A</p></template></spa-route>
|
|
348
|
+
</spa-manager>
|
|
349
|
+
<spa-a route-href="/a"></spa-a>
|
|
350
|
+
`;
|
|
351
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
352
|
+
const route = q<HTMLSpaRouteElement>("spa-route");
|
|
353
|
+
const link = q<HTMLSpaAElement>("spa-a");
|
|
354
|
+
const renderSpy = vi.fn();
|
|
355
|
+
route.addEventListener("spa-route-did-render", renderSpy);
|
|
356
|
+
|
|
357
|
+
await navigate(manager, () => click(link));
|
|
358
|
+
const p = route.querySelector("p");
|
|
359
|
+
await waitForEvent(manager, "spa-manager-push", () => click(link));
|
|
360
|
+
await wait(5);
|
|
361
|
+
expect(router.states.map((s) => s.url)).toEqual(["/", "/a", "/a"]);
|
|
362
|
+
expect(route.isActive).toBe(true);
|
|
363
|
+
expect(link.isActive).toBe(true);
|
|
364
|
+
expect(route.querySelector("p")).toBe(p);
|
|
365
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe("spa-route", () => {
|
|
370
|
+
beforeEach(resetRouter);
|
|
371
|
+
afterEach(() => {
|
|
372
|
+
document.body.innerHTML = "";
|
|
373
|
+
router.routes = [];
|
|
374
|
+
vi.restoreAllMocks();
|
|
375
|
+
resetRouter();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("exposes the route payload as provision and announces it", async () => {
|
|
379
|
+
document.body.innerHTML = `
|
|
380
|
+
<spa-manager>
|
|
381
|
+
<spa-route route-href="/items/:id" scroll-reset-y="push" scroll-reset-behavior="smooth">
|
|
382
|
+
<template>Item</template>
|
|
383
|
+
</spa-route>
|
|
384
|
+
</spa-manager>
|
|
385
|
+
<spa-a route-href="/items/123"></spa-a>
|
|
386
|
+
`;
|
|
387
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
388
|
+
const route = q<HTMLSpaRouteElement>("spa-route");
|
|
389
|
+
const provisionEvents = captureEvent(manager, "neutron-provision");
|
|
390
|
+
const scrollToSpy = vi
|
|
391
|
+
.spyOn(window, "scrollTo")
|
|
392
|
+
.mockImplementation(() => {});
|
|
393
|
+
|
|
394
|
+
expect(route.provision ?? null).toBeNull();
|
|
395
|
+
await navigate(manager, () => click(q("spa-a")));
|
|
396
|
+
await wait(5);
|
|
397
|
+
|
|
398
|
+
expect(route.provision).toEqual(
|
|
399
|
+
expect.objectContaining({
|
|
400
|
+
routeHref: "/items/:id",
|
|
401
|
+
matchNested: false,
|
|
402
|
+
scrollResetY: ["push"],
|
|
403
|
+
scrollResetX: ["push", "replace"],
|
|
404
|
+
scrollResetBehavior: "smooth",
|
|
405
|
+
noTransition: false,
|
|
406
|
+
params: { id: "123" },
|
|
407
|
+
move: "push",
|
|
408
|
+
next: null,
|
|
409
|
+
previous: expect.objectContaining({ url: "/" }),
|
|
410
|
+
active: expect.objectContaining({ url: "/items/123" }),
|
|
411
|
+
})
|
|
412
|
+
);
|
|
413
|
+
expect(route.provision!.match![0]).toBe("/items/123");
|
|
414
|
+
// both the route and the manager announce their provision (bubbling)
|
|
415
|
+
expect(provisionEvents.filter((e) => e.target === route)).toHaveLength(1);
|
|
416
|
+
expect(provisionEvents.filter((e) => e.target === manager)).toHaveLength(1);
|
|
417
|
+
expect(manager.provision).toEqual(
|
|
418
|
+
expect.objectContaining({
|
|
419
|
+
move: "push",
|
|
420
|
+
params: {},
|
|
421
|
+
active: expect.objectContaining({ url: "/items/123" }),
|
|
422
|
+
})
|
|
423
|
+
);
|
|
424
|
+
expect(scrollToSpy).toHaveBeenCalledWith({
|
|
425
|
+
top: 0,
|
|
426
|
+
left: 0,
|
|
427
|
+
behavior: "smooth",
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("ignores routeChanged when no route is registered", () => {
|
|
432
|
+
document.body.innerHTML = `<spa-route><template>x</template></spa-route>`;
|
|
433
|
+
const route = q<HTMLSpaRouteElement>("spa-route");
|
|
434
|
+
expect(route.routeInstance ?? null).toBeNull();
|
|
435
|
+
expect(
|
|
436
|
+
route.routeChanged({ match: ["/"], params: {} } as never)
|
|
437
|
+
).toBeUndefined();
|
|
438
|
+
expect(route.isActive).toBe(false);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("works without a manager and lets spa-route-provision be deferred", async () => {
|
|
442
|
+
document.body.innerHTML = `
|
|
443
|
+
<spa-route route-href="/solo/:id"><template><p>solo</p></template></spa-route>
|
|
444
|
+
`;
|
|
445
|
+
const route = q<HTMLSpaRouteElement>("spa-route");
|
|
446
|
+
let thunk: (() => void) | null = null;
|
|
447
|
+
const defer = (e: Event) => {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
thunk = (e as CustomEvent).detail;
|
|
450
|
+
};
|
|
451
|
+
route.addEventListener("spa-route-provision", defer);
|
|
452
|
+
|
|
453
|
+
await waitForEvent(route, "spa-route-did-render", () =>
|
|
454
|
+
kitRouter.pushState({ url: "/solo/1" })
|
|
455
|
+
);
|
|
456
|
+
expect(route.isActive).toBe(true);
|
|
457
|
+
expect(route.querySelector("p")?.textContent).toBe("solo");
|
|
458
|
+
expect(route.provision ?? null).toBeNull();
|
|
459
|
+
expect(route.readyPromiseObject).not.toBeNull();
|
|
460
|
+
|
|
461
|
+
thunk!();
|
|
462
|
+
await wait(0);
|
|
463
|
+
expect(route.provision?.params).toEqual({ id: "1" });
|
|
464
|
+
expect(route.readyPromiseObject).toBeNull();
|
|
465
|
+
|
|
466
|
+
route.removeEventListener("spa-route-provision", defer);
|
|
467
|
+
kitRouter.pushState({ url: "/solo/2" });
|
|
468
|
+
await wait(0);
|
|
469
|
+
expect(route.provision?.params).toEqual({ id: "2" });
|
|
470
|
+
|
|
471
|
+
kitRouter.pushState({ url: "/elsewhere" });
|
|
472
|
+
await wait(0);
|
|
473
|
+
expect(route.isActive).toBe(false);
|
|
474
|
+
expect(route.provision ?? null).toBeNull();
|
|
475
|
+
expect(hasRenderedContent(route)).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("keeps a nested layout mounted while child routes swap", async () => {
|
|
479
|
+
document.body.innerHTML = `
|
|
480
|
+
<spa-manager>
|
|
481
|
+
<spa-route route-href="/users" match-nested>
|
|
482
|
+
<template>
|
|
483
|
+
<section id="layout">
|
|
484
|
+
<spa-manager>
|
|
485
|
+
<spa-route route-href="/users/:id"><template><p id="detail">detail</p></template></spa-route>
|
|
486
|
+
<spa-route route-href="/users/:id/edit"><template><p id="edit">edit</p></template></spa-route>
|
|
487
|
+
</spa-manager>
|
|
488
|
+
</section>
|
|
489
|
+
</template>
|
|
490
|
+
</spa-route>
|
|
491
|
+
</spa-manager>
|
|
492
|
+
<spa-a route-href="/users/42"></spa-a>
|
|
493
|
+
<spa-a route-href="/users/42/edit"></spa-a>
|
|
494
|
+
`;
|
|
495
|
+
const outerManager = q<HTMLSpaManagerElement>("spa-manager");
|
|
496
|
+
const layout = q<HTMLSpaRouteElement>("spa-route");
|
|
497
|
+
const [toDetail, toEdit] = qa<HTMLSpaAElement>("spa-a");
|
|
498
|
+
|
|
499
|
+
await navigate(outerManager, () => click(toDetail));
|
|
500
|
+
await wait(5);
|
|
501
|
+
const innerManager = q<HTMLSpaManagerElement>("#layout spa-manager");
|
|
502
|
+
const [detail, edit] = Array.from(
|
|
503
|
+
innerManager.querySelectorAll("spa-route")
|
|
504
|
+
) as HTMLSpaRouteElement[];
|
|
505
|
+
expect(layout.isActive).toBe(true);
|
|
506
|
+
expect(layout.provision?.params).toEqual({});
|
|
507
|
+
expect(detail.isActive).toBe(true);
|
|
508
|
+
expect(detail.provision?.params).toEqual({ id: "42" });
|
|
509
|
+
expect(edit.isActive).toBe(false);
|
|
510
|
+
expect(q("#detail")).not.toBeNull();
|
|
511
|
+
expect(q("#edit")).toBeNull();
|
|
512
|
+
|
|
513
|
+
const layoutRenders = captureEvent(layout, "spa-route-did-render");
|
|
514
|
+
await navigate(innerManager, () => click(toEdit));
|
|
515
|
+
expect(layout.isActive).toBe(true);
|
|
516
|
+
// did-render bubbles up from the inner routes; the layout itself stays put
|
|
517
|
+
expect(layoutRenders.filter((e) => e.target === layout)).toHaveLength(0);
|
|
518
|
+
expect(layoutRenders.filter((e) => e.target === edit)).toHaveLength(1);
|
|
519
|
+
expect(q("#layout")).not.toBeNull();
|
|
520
|
+
expect(edit.isActive).toBe(true);
|
|
521
|
+
expect(edit.provision?.params).toEqual({ id: "42" });
|
|
522
|
+
expect(detail.isActive).toBe(false);
|
|
523
|
+
expect(q("#edit")).not.toBeNull();
|
|
524
|
+
expect(q("#detail")).toBeNull();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe("ready-on", () => {
|
|
528
|
+
it("resolves ready when the ready-on event fires", async () => {
|
|
529
|
+
document.body.innerHTML = `
|
|
530
|
+
<spa-manager>
|
|
531
|
+
<spa-route route-href="/rd" ready-on="content-ready"><template>RD</template></spa-route>
|
|
532
|
+
</spa-manager>
|
|
533
|
+
<spa-a route-href="/rd"></spa-a>
|
|
534
|
+
`;
|
|
535
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
536
|
+
const route = q<HTMLSpaRouteElement>("spa-route");
|
|
537
|
+
const scrollToSpy = vi
|
|
538
|
+
.spyOn(window, "scrollTo")
|
|
539
|
+
.mockImplementation(() => {});
|
|
540
|
+
|
|
541
|
+
await waitForEvent(route, "spa-route-did-render", () =>
|
|
542
|
+
click(q("spa-a"))
|
|
543
|
+
);
|
|
544
|
+
await wait(5);
|
|
545
|
+
expect(route.delayingReady).toBe(true);
|
|
546
|
+
expect(route.readyPromiseObject).not.toBeNull();
|
|
547
|
+
expect(scrollToSpy).not.toHaveBeenCalled();
|
|
548
|
+
// the batched provision waits on ready (first paint: no View Transition)
|
|
549
|
+
expect(manager.executingTransition).toBe(true);
|
|
550
|
+
|
|
551
|
+
await navigate(manager, () =>
|
|
552
|
+
route.dispatchEvent(new Event("content-ready"))
|
|
553
|
+
);
|
|
554
|
+
await wait(5);
|
|
555
|
+
expect(route.delayingReady).toBe(false);
|
|
556
|
+
expect(route.readyPromiseObject).toBeNull();
|
|
557
|
+
expect(scrollToSpy).toHaveBeenCalledTimes(1);
|
|
558
|
+
expect(manager.isTransitioning).toBe(false);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("rejects ready when torn down mid-flight", async () => {
|
|
562
|
+
document.body.innerHTML = `
|
|
563
|
+
<spa-manager>
|
|
564
|
+
<spa-route route-href="/rd" ready-on="content-ready"><template>RD</template></spa-route>
|
|
565
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
566
|
+
</spa-manager>
|
|
567
|
+
<spa-a route-href="/rd"></spa-a>
|
|
568
|
+
<spa-a route-href="/a"></spa-a>
|
|
569
|
+
`;
|
|
570
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
571
|
+
const [routeRd, routeA] = qa<HTMLSpaRouteElement>("spa-route");
|
|
572
|
+
const [linkRd, linkA] = qa<HTMLSpaAElement>("spa-a");
|
|
573
|
+
const scrollToSpy = vi
|
|
574
|
+
.spyOn(window, "scrollTo")
|
|
575
|
+
.mockImplementation(() => {});
|
|
576
|
+
await waitForEvent(routeRd, "spa-route-did-render", () => click(linkRd));
|
|
577
|
+
await wait(5);
|
|
578
|
+
const settled = vi.fn();
|
|
579
|
+
routeRd.readyPromiseObject!.promise.then(
|
|
580
|
+
() => settled("resolved"),
|
|
581
|
+
() => settled("rejected")
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
await navigate(manager, () => click(linkA));
|
|
585
|
+
await wait(5);
|
|
586
|
+
expect(settled).toHaveBeenCalledWith("rejected");
|
|
587
|
+
expect(routeRd.isActive).toBe(false);
|
|
588
|
+
expect(routeRd.delayingReady).toBe(false);
|
|
589
|
+
expect(routeRd.readyPromiseObject).toBeNull();
|
|
590
|
+
expect(routeA.isActive).toBe(true);
|
|
591
|
+
// scroll only runs for a successful ready (route A)
|
|
592
|
+
expect(scrollToSpy).toHaveBeenCalledTimes(1);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
describe("spa-manager", () => {
|
|
598
|
+
beforeEach(resetRouter);
|
|
599
|
+
afterEach(() => {
|
|
600
|
+
document.body.innerHTML = "";
|
|
601
|
+
router.routes = [];
|
|
602
|
+
// one test below fakes timers; make sure a failure there cannot leak them
|
|
603
|
+
vi.useRealTimers();
|
|
604
|
+
vi.restoreAllMocks();
|
|
605
|
+
delete (window as unknown as { ontouchstart?: unknown }).ontouchstart;
|
|
606
|
+
delete (navigator as unknown as { maxTouchPoints?: unknown })
|
|
607
|
+
.maxTouchPoints;
|
|
608
|
+
resetRouter();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("transitions the first render with transition-first-render", async () => {
|
|
612
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
613
|
+
document.body.innerHTML = `
|
|
614
|
+
<spa-manager transition-first-render>
|
|
615
|
+
<spa-route route-href="/"><template>Home</template></spa-route>
|
|
616
|
+
</spa-manager>
|
|
617
|
+
`;
|
|
618
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
619
|
+
await waitForEvent(manager, "spa-manager-rendered");
|
|
620
|
+
expect(transitionSpy).toHaveBeenCalledTimes(1);
|
|
621
|
+
// cold load has no move, so no `route-*` type
|
|
622
|
+
expect(transitionSpy.mock.calls[0][0]).toEqual(
|
|
623
|
+
expect.objectContaining({ types: [] })
|
|
624
|
+
);
|
|
625
|
+
expect(manager.hasRendered).toBe(true);
|
|
626
|
+
expect(manager.lastMove ?? null).toBeNull();
|
|
627
|
+
expect(q("spa-route").textContent).toContain("Home");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("skips view transitions with no-transition on the manager or the route", async () => {
|
|
631
|
+
document.body.innerHTML = `
|
|
632
|
+
<spa-manager no-transition>
|
|
633
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
634
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
635
|
+
</spa-manager>
|
|
636
|
+
<spa-a route-href="/a"></spa-a>
|
|
637
|
+
<spa-a route-href="/b"></spa-a>
|
|
638
|
+
`;
|
|
639
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
640
|
+
const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
641
|
+
const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
|
|
642
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
643
|
+
|
|
644
|
+
await waitForEvent(routeA, "spa-route-did-render", () => click(linkA));
|
|
645
|
+
await waitForEvent(routeB, "spa-route-did-render", () => click(linkB));
|
|
646
|
+
await wait(5);
|
|
647
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
648
|
+
expect(manager.hasRendered).toBe(true);
|
|
649
|
+
|
|
650
|
+
manager.noTransition = false;
|
|
651
|
+
routeA.noTransition = true;
|
|
652
|
+
await wait(0);
|
|
653
|
+
await navigate(manager, () => click(linkA));
|
|
654
|
+
expect(routeA.isActive).toBe(true);
|
|
655
|
+
expect(routeA.textContent).toContain("A");
|
|
656
|
+
// route B (no opt-out) is still batched into a transition
|
|
657
|
+
expect(transitionSpy).toHaveBeenCalledTimes(1);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("settles a batched render whose route was deactivated before it ran", async () => {
|
|
661
|
+
document.body.innerHTML = `
|
|
662
|
+
<spa-manager transition-delay="20" render-timeout="5000">
|
|
663
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
664
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
665
|
+
</spa-manager>
|
|
666
|
+
<spa-a route-href="/a"></spa-a>
|
|
667
|
+
<spa-a route-href="/b"></spa-a>
|
|
668
|
+
`;
|
|
669
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
670
|
+
const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
671
|
+
const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
|
|
672
|
+
|
|
673
|
+
// Warm up: `has-rendered` flips, so the next nav is a real batch.
|
|
674
|
+
await navigate(manager, () => click(linkA));
|
|
675
|
+
expect(manager.hasRendered).toBe(true);
|
|
676
|
+
|
|
677
|
+
/* Queue A -> B, then flip back to A synchronously. Route B's render
|
|
678
|
+
callback is in the batch, but B is inactive by the time it runs.
|
|
679
|
+
`waitForEvent` rejects after 1s, well before `render-timeout`. */
|
|
680
|
+
const settled = vi.fn();
|
|
681
|
+
await navigate(manager, () => {
|
|
682
|
+
click(linkB);
|
|
683
|
+
expect(routeB.readyPromiseObject).not.toBeNull();
|
|
684
|
+
routeB.readyPromiseObject!.promise.then(
|
|
685
|
+
() => settled("resolved"),
|
|
686
|
+
() => settled("rejected")
|
|
687
|
+
);
|
|
688
|
+
click(linkA);
|
|
689
|
+
});
|
|
690
|
+
await wait(0);
|
|
691
|
+
|
|
692
|
+
// The batch awaited this promise; an abort must reject, never hang
|
|
693
|
+
expect(settled).toHaveBeenCalledWith("rejected");
|
|
694
|
+
expect(routeA.isActive).toBe(true);
|
|
695
|
+
expect(routeB.isActive).toBe(false);
|
|
696
|
+
// The stale render settled rather than leaking a pending promise
|
|
697
|
+
expect(routeB.readyPromiseObject).toBeNull();
|
|
698
|
+
expect(hasRenderedContent(routeB)).toBe(false);
|
|
699
|
+
expect(hasRenderedContent(routeA)).toBe(true);
|
|
700
|
+
expect(manager.isTransitioning).toBe(false);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("does not transition when the browser already animated the navigation", async () => {
|
|
704
|
+
document.body.innerHTML = `
|
|
705
|
+
<spa-manager>
|
|
706
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
707
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
708
|
+
</spa-manager>
|
|
709
|
+
<spa-a route-href="/a"></spa-a>
|
|
710
|
+
<spa-a route-href="/b"></spa-a>
|
|
711
|
+
`;
|
|
712
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
713
|
+
const [routeA] = qa<HTMLSpaRouteElement>("spa-route");
|
|
714
|
+
const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
|
|
715
|
+
await navigate(manager, () => click(linkA));
|
|
716
|
+
await navigate(manager, () => click(linkB));
|
|
717
|
+
|
|
718
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
719
|
+
await navigate(manager, () => popstate(router.states[1], true));
|
|
720
|
+
expect(manager.provision?.event).toEqual({ hasUAVisualTransition: true });
|
|
721
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
722
|
+
expect(manager.isTransitioning).toBe(false);
|
|
723
|
+
expect(routeA.isActive).toBe(true);
|
|
724
|
+
expect(routeA.textContent).toContain("A");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("lets spa-manager-will-transition be canceled and drained later", async () => {
|
|
728
|
+
document.body.innerHTML = `
|
|
729
|
+
<spa-manager>
|
|
730
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
731
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
732
|
+
</spa-manager>
|
|
733
|
+
<spa-a route-href="/a"></spa-a>
|
|
734
|
+
<spa-a route-href="/b"></spa-a>
|
|
735
|
+
`;
|
|
736
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
737
|
+
const [routeA, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
738
|
+
const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
|
|
739
|
+
await navigate(manager, () => click(linkA));
|
|
740
|
+
|
|
741
|
+
manager.addEventListener(
|
|
742
|
+
"spa-manager-will-transition",
|
|
743
|
+
(e) => e.preventDefault(),
|
|
744
|
+
{ once: true }
|
|
745
|
+
);
|
|
746
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
747
|
+
click(linkB);
|
|
748
|
+
await wait(5);
|
|
749
|
+
expect(routeB.isActive).toBe(true);
|
|
750
|
+
expect(manager.hasCallbacks()).toBe(true);
|
|
751
|
+
expect(hasRenderedContent(routeB)).toBe(false);
|
|
752
|
+
expect(hasRenderedContent(routeA)).toBe(true);
|
|
753
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
754
|
+
|
|
755
|
+
await navigate(manager, () => manager.updateRoutes(false));
|
|
756
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
757
|
+
expect(hasRenderedContent(routeB)).toBe(true);
|
|
758
|
+
expect(hasRenderedContent(routeA)).toBe(false);
|
|
759
|
+
expect(manager.hasCallbacks()).toBe(false);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("tolerates a partially initialised callback queue", async () => {
|
|
763
|
+
document.body.innerHTML = `
|
|
764
|
+
<spa-manager>
|
|
765
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
766
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
767
|
+
</spa-manager>
|
|
768
|
+
<spa-a route-href="/a"></spa-a>
|
|
769
|
+
<spa-a route-href="/b"></spa-a>
|
|
770
|
+
`;
|
|
771
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
772
|
+
const [, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
773
|
+
const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
|
|
774
|
+
await navigate(manager, () => click(linkA));
|
|
775
|
+
|
|
776
|
+
manager._routeCallbacks = {} as never;
|
|
777
|
+
await navigate(manager, () => click(linkB));
|
|
778
|
+
expect(routeB.textContent).toContain("B");
|
|
779
|
+
|
|
780
|
+
manager._routeCallbacks = {} as never;
|
|
781
|
+
await navigate(manager, () => manager._updateRoutes(false));
|
|
782
|
+
expect(manager._routeCallbacks).toEqual({
|
|
783
|
+
provision: [],
|
|
784
|
+
render: [],
|
|
785
|
+
unrender: [],
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("delays the transition with transition-delay and reschedules on repeat", async () => {
|
|
790
|
+
document.body.innerHTML = `
|
|
791
|
+
<spa-manager transition-delay="10">
|
|
792
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
793
|
+
<spa-route route-href="/b"><template>B</template></spa-route>
|
|
794
|
+
</spa-manager>
|
|
795
|
+
<spa-a route-href="/a"></spa-a>
|
|
796
|
+
<spa-a route-href="/b"></spa-a>
|
|
797
|
+
`;
|
|
798
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
799
|
+
const [, routeB] = qa<HTMLSpaRouteElement>("spa-route");
|
|
800
|
+
const [linkA, linkB] = qa<HTMLSpaAElement>("spa-a");
|
|
801
|
+
await navigate(manager, () => click(linkA));
|
|
802
|
+
|
|
803
|
+
// Fake timers from here: the assertions are that the 10ms delay is still
|
|
804
|
+
// pending, which a real `wait(0)` can outlive on a loaded machine.
|
|
805
|
+
vi.useFakeTimers();
|
|
806
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
807
|
+
const clearSpy = vi.spyOn(globalThis, "clearTimeout");
|
|
808
|
+
const rendered = waitForEvent(manager, "spa-manager-rendered");
|
|
809
|
+
click(linkB);
|
|
810
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
811
|
+
const firstId = manager.transitionDelayId;
|
|
812
|
+
expect(firstId).not.toBeNull();
|
|
813
|
+
expect(transitionSpy).not.toHaveBeenCalled();
|
|
814
|
+
// a second request while the delay is pending restarts the timer
|
|
815
|
+
manager.updateRoutes(true);
|
|
816
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
817
|
+
expect(clearSpy).toHaveBeenCalledWith(firstId);
|
|
818
|
+
expect(manager.transitionDelayId).not.toBe(firstId);
|
|
819
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
820
|
+
await rendered;
|
|
821
|
+
expect(transitionSpy).toHaveBeenCalledTimes(1);
|
|
822
|
+
expect(manager.transitionDelayId).toBeNull();
|
|
823
|
+
expect(routeB.textContent).toContain("B");
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("forces the transition to settle after render-timeout", async () => {
|
|
827
|
+
document.body.innerHTML = `
|
|
828
|
+
<spa-manager render-timeout="20">
|
|
829
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
830
|
+
<spa-route route-href="/slow" ready-on="content-ready"><template>Slow</template></spa-route>
|
|
831
|
+
</spa-manager>
|
|
832
|
+
<spa-a route-href="/a"></spa-a>
|
|
833
|
+
<spa-a route-href="/slow"></spa-a>
|
|
834
|
+
`;
|
|
835
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
836
|
+
const [, routeSlow] = qa<HTMLSpaRouteElement>("spa-route");
|
|
837
|
+
const [linkA, linkSlow] = qa<HTMLSpaAElement>("spa-a");
|
|
838
|
+
await navigate(manager, () => click(linkA));
|
|
839
|
+
|
|
840
|
+
const started = Date.now();
|
|
841
|
+
await navigate(manager, () => click(linkSlow));
|
|
842
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(15);
|
|
843
|
+
expect(routeSlow.delayingReady).toBe(true);
|
|
844
|
+
expect(routeSlow.textContent).toContain("Slow");
|
|
845
|
+
expect(manager.isTransitioning).toBe(false);
|
|
846
|
+
routeSlow.dispatchEvent(new Event("content-ready"));
|
|
847
|
+
await wait(0);
|
|
848
|
+
expect(routeSlow.delayingReady).toBe(false);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("re-emits child errors as spa-manager-error without a transition", async () => {
|
|
852
|
+
document.body.innerHTML = `
|
|
853
|
+
<spa-manager>
|
|
854
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
855
|
+
<spa-route route-href="/broken" template-ref="#does-not-exist"></spa-route>
|
|
856
|
+
</spa-manager>
|
|
857
|
+
<spa-a route-href="/a"></spa-a>
|
|
858
|
+
<spa-a route-href="/broken"></spa-a>
|
|
859
|
+
`;
|
|
860
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
861
|
+
const [, routeBroken] = qa<HTMLSpaRouteElement>("spa-route");
|
|
862
|
+
const [linkA, linkBroken] = qa<HTMLSpaAElement>("spa-a");
|
|
863
|
+
vi.spyOn(KitLogger, "error").mockImplementation(() => {});
|
|
864
|
+
const bodyErrorSpy = vi.fn();
|
|
865
|
+
document.body.addEventListener("spa-route-error", bodyErrorSpy);
|
|
866
|
+
const managerErrors = captureEvent(manager, "spa-manager-error");
|
|
867
|
+
await navigate(manager, () => click(linkA));
|
|
868
|
+
|
|
869
|
+
const transitionSpy = vi.spyOn(document, "startViewTransition");
|
|
870
|
+
await navigate(manager, () => click(linkBroken));
|
|
871
|
+
await wait(5);
|
|
872
|
+
expect(managerErrors).toHaveLength(1);
|
|
873
|
+
expect(managerErrors[0].detail).toBeDefined();
|
|
874
|
+
expect(routeBroken.isError).toBe(true);
|
|
875
|
+
expect(routeBroken.isActive).toBe(true);
|
|
876
|
+
// the child error is consumed by the manager
|
|
877
|
+
expect(bodyErrorSpy).not.toHaveBeenCalled();
|
|
878
|
+
expect(manager.isTransitioning).toBe(false);
|
|
879
|
+
expect(transitionSpy).toHaveBeenCalledTimes(1);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("caps router history with max-states", () => {
|
|
883
|
+
document.body.innerHTML = `<spa-manager max-states="2"></spa-manager>`;
|
|
884
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
885
|
+
expect(kitRouter.MAX_STATES).toBe(2);
|
|
886
|
+
kitRouter.pushState({ url: "/1" });
|
|
887
|
+
kitRouter.pushState({ url: "/2" });
|
|
888
|
+
kitRouter.pushState({ url: "/3" });
|
|
889
|
+
expect(router.states.map((s) => s.url)).toEqual(["/2", "/3"]);
|
|
890
|
+
manager.maxStates = null;
|
|
891
|
+
expect(kitRouter.MAX_STATES).toBe(kitRouter.DEFAULT_MAX_STATES);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("unregisters from the router on disconnect", async () => {
|
|
895
|
+
document.body.innerHTML = `
|
|
896
|
+
<spa-manager>
|
|
897
|
+
<spa-route route-href="/a"><template>A</template></spa-route>
|
|
898
|
+
</spa-manager>
|
|
899
|
+
`;
|
|
900
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
901
|
+
const route = q<HTMLSpaRouteElement>("spa-route");
|
|
902
|
+
const managerRoute = manager.routeInstance!;
|
|
903
|
+
const childRoute = route.routeInstance!;
|
|
904
|
+
expect(managerRoute.key).toBeInstanceOf(RegExp);
|
|
905
|
+
const offSpy = vi.spyOn(kitRouter, "off");
|
|
906
|
+
|
|
907
|
+
manager.remove();
|
|
908
|
+
await wait(0);
|
|
909
|
+
expect(offSpy).toHaveBeenCalledWith(managerRoute);
|
|
910
|
+
expect(offSpy).toHaveBeenCalledWith(childRoute);
|
|
911
|
+
expect(manager.routeInstance).toBeNull();
|
|
912
|
+
expect(route.routeInstance).toBeNull();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
describe("touch edge-swipe", () => {
|
|
916
|
+
const listenerCount = (type: string) =>
|
|
917
|
+
globalThis.getEventListeners(document)[type]?.length ?? 0;
|
|
918
|
+
|
|
919
|
+
it("ignores overscroll-behavior-x on non-touch devices", () => {
|
|
920
|
+
const before = listenerCount("touchstart");
|
|
921
|
+
document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate"></spa-manager>`;
|
|
922
|
+
expect(listenerCount("touchstart")).toBe(before);
|
|
923
|
+
touch("touchstart", [{ identifier: 1, clientX: 5, clientY: 50 }]);
|
|
924
|
+
expect(q<HTMLSpaManagerElement>("spa-manager").swipeTracker ?? null).toBe(
|
|
925
|
+
null
|
|
926
|
+
);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("navigates back / forward from the edges", () => {
|
|
930
|
+
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
|
931
|
+
const before = listenerCount("touchstart");
|
|
932
|
+
document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate"></spa-manager>`;
|
|
933
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
934
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
935
|
+
expect(manager.overscrollXThreshold).toBe(40);
|
|
936
|
+
expect(listenerCount("touchstart")).toBe(before + 1);
|
|
937
|
+
expect(listenerCount("touchmove")).toBeGreaterThan(0);
|
|
938
|
+
const width = window.innerWidth;
|
|
939
|
+
|
|
940
|
+
// left edge → right: back
|
|
941
|
+
touch("touchstart", [{ identifier: 1, clientX: 10, clientY: 50 }]);
|
|
942
|
+
expect(manager.swipeTracker).toEqual({
|
|
943
|
+
identifier: 1,
|
|
944
|
+
startX: 10,
|
|
945
|
+
startY: 50,
|
|
946
|
+
});
|
|
947
|
+
const move = touch("touchmove", [
|
|
948
|
+
{ identifier: 1, clientX: 80, clientY: 55 },
|
|
949
|
+
]);
|
|
950
|
+
expect(move.defaultPrevented).toBe(true);
|
|
951
|
+
expect(goSpy).toHaveBeenLastCalledWith(-1);
|
|
952
|
+
expect(manager.swipeTracker).toBeNull();
|
|
953
|
+
touch("touchend", []);
|
|
954
|
+
expect(manager.swipeTracker).toBeNull();
|
|
955
|
+
|
|
956
|
+
// right edge → left: forward
|
|
957
|
+
touch("touchstart", [{ identifier: 2, clientX: width - 5, clientY: 50 }]);
|
|
958
|
+
expect(manager.swipeTracker?.startX).toBe(width - 5);
|
|
959
|
+
touch("touchmove", [{ identifier: 2, clientX: width - 90, clientY: 50 }]);
|
|
960
|
+
expect(goSpy).toHaveBeenLastCalledWith(1);
|
|
961
|
+
expect(manager.swipeTracker).toBeNull();
|
|
962
|
+
touch("touchcancel", []);
|
|
963
|
+
|
|
964
|
+
// clamps out-of-viewport start coordinates
|
|
965
|
+
touch("touchstart", [{ identifier: 3, clientX: -20, clientY: -5 }]);
|
|
966
|
+
expect(manager.swipeTracker).toEqual({
|
|
967
|
+
identifier: 3,
|
|
968
|
+
startX: 0,
|
|
969
|
+
startY: 0,
|
|
970
|
+
});
|
|
971
|
+
touch("touchend", []);
|
|
972
|
+
|
|
973
|
+
// switching the mode off removes the document listeners
|
|
974
|
+
manager.overscrollBehaviorX = null;
|
|
975
|
+
expect(listenerCount("touchstart")).toBe(before);
|
|
976
|
+
touch("touchstart", [{ identifier: 4, clientX: 10, clientY: 50 }]);
|
|
977
|
+
expect(manager.swipeTracker).toBeNull();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("ignores multi-touch, mid-screen starts and unknown touches", () => {
|
|
981
|
+
Object.defineProperty(navigator, "maxTouchPoints", {
|
|
982
|
+
value: 1,
|
|
983
|
+
configurable: true,
|
|
984
|
+
});
|
|
985
|
+
document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate" overscroll-x-threshold="30"></spa-manager>`;
|
|
986
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
987
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
988
|
+
expect(manager.overscrollXThreshold).toBe(30);
|
|
989
|
+
|
|
990
|
+
// move without a tracker is ignored
|
|
991
|
+
const stray = touch("touchmove", [
|
|
992
|
+
{ identifier: 1, clientX: 80, clientY: 50 },
|
|
993
|
+
]);
|
|
994
|
+
expect(stray.defaultPrevented).toBe(false);
|
|
995
|
+
|
|
996
|
+
touch("touchstart", [
|
|
997
|
+
{ identifier: 1, clientX: 5, clientY: 50 },
|
|
998
|
+
{ identifier: 2, clientX: 50, clientY: 50 },
|
|
999
|
+
]);
|
|
1000
|
+
expect(manager.swipeTracker ?? null).toBeNull();
|
|
1001
|
+
|
|
1002
|
+
touch("touchstart", [{ identifier: 1, clientX: 200, clientY: 50 }]);
|
|
1003
|
+
expect(manager.swipeTracker ?? null).toBeNull();
|
|
1004
|
+
|
|
1005
|
+
touch("touchstart", [{ identifier: 1, clientX: 5, clientY: 50 }]);
|
|
1006
|
+
expect(manager.swipeTracker?.identifier).toBe(1);
|
|
1007
|
+
// a different finger moving does not count
|
|
1008
|
+
const other = touch("touchmove", [
|
|
1009
|
+
{ identifier: 9, clientX: 100, clientY: 50 },
|
|
1010
|
+
]);
|
|
1011
|
+
expect(other.defaultPrevented).toBe(false);
|
|
1012
|
+
expect(manager.swipeTracker?.identifier).toBe(1);
|
|
1013
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("only navigates past the threshold and mostly horizontally", () => {
|
|
1017
|
+
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
|
1018
|
+
document.body.innerHTML = `<spa-manager overscroll-behavior-x="navigate"></spa-manager>`;
|
|
1019
|
+
const manager = q<HTMLSpaManagerElement>("spa-manager");
|
|
1020
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
1021
|
+
const width = window.innerWidth;
|
|
1022
|
+
|
|
1023
|
+
// tiny horizontal delta: nothing prevented
|
|
1024
|
+
touch("touchstart", [{ identifier: 1, clientX: 10, clientY: 50 }]);
|
|
1025
|
+
let move = touch("touchmove", [
|
|
1026
|
+
{ identifier: 1, clientX: 11, clientY: 50 },
|
|
1027
|
+
]);
|
|
1028
|
+
expect(move.defaultPrevented).toBe(false);
|
|
1029
|
+
// mostly vertical: nothing prevented
|
|
1030
|
+
move = touch("touchmove", [{ identifier: 1, clientX: 15, clientY: 200 }]);
|
|
1031
|
+
expect(move.defaultPrevented).toBe(false);
|
|
1032
|
+
// horizontal but still inside the edge threshold: prevented, no nav
|
|
1033
|
+
move = touch("touchmove", [{ identifier: 1, clientX: 30, clientY: 50 }]);
|
|
1034
|
+
expect(move.defaultPrevented).toBe(true);
|
|
1035
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
1036
|
+
// dragging left from the left edge never navigates
|
|
1037
|
+
move = touch("touchmove", [{ identifier: 1, clientX: 0, clientY: 50 }]);
|
|
1038
|
+
expect(move.defaultPrevented).toBe(true);
|
|
1039
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
1040
|
+
touch("touchend", []);
|
|
1041
|
+
|
|
1042
|
+
// right edge: dragging further right, or left but not past threshold
|
|
1043
|
+
touch("touchstart", [{ identifier: 2, clientX: width - 5, clientY: 50 }]);
|
|
1044
|
+
touch("touchmove", [{ identifier: 2, clientX: width, clientY: 50 }]);
|
|
1045
|
+
touch("touchmove", [{ identifier: 2, clientX: width - 20, clientY: 50 }]);
|
|
1046
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
1047
|
+
expect(manager.swipeTracker?.identifier).toBe(2);
|
|
1048
|
+
touch("touchend", []);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it("blocks overscroll without navigating when set to none", () => {
|
|
1052
|
+
(window as unknown as { ontouchstart: unknown }).ontouchstart = null;
|
|
1053
|
+
document.body.innerHTML = `<spa-manager overscroll-behavior-x="none"></spa-manager>`;
|
|
1054
|
+
const goSpy = vi.spyOn(history, "go").mockImplementation(() => {});
|
|
1055
|
+
touch("touchstart", [{ identifier: 1, clientX: 10, clientY: 50 }]);
|
|
1056
|
+
const move = touch("touchmove", [
|
|
1057
|
+
{ identifier: 1, clientX: 90, clientY: 50 },
|
|
1058
|
+
]);
|
|
1059
|
+
expect(move.defaultPrevented).toBe(true);
|
|
1060
|
+
expect(goSpy).not.toHaveBeenCalled();
|
|
1061
|
+
expect(q<HTMLSpaManagerElement>("spa-manager").swipeTracker).toEqual(
|
|
1062
|
+
expect.objectContaining({ identifier: 1 })
|
|
1063
|
+
);
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
describe("utils search params", () => {
|
|
1069
|
+
it("compares query strings key by key", () => {
|
|
1070
|
+
expect(urlMatchesHref("/a?foo=1", "/a?foo=2")).toBe(false);
|
|
1071
|
+
expect(urlMatchesHref("/a?foo=1", "/a?foo=1&bar=2")).toBe(false);
|
|
1072
|
+
expect(urlMatchesHref("/a?foo=1&bar=2", "/a?foo=1&bar=3")).toBe(false);
|
|
1073
|
+
expect(urlMatchesHref("/a?foo=1&bar=2", "/a?bar=2&foo=1")).toBe(true);
|
|
1074
|
+
expect(urlMatchesHref("/a?foo=1", "/a")).toBe(false);
|
|
1075
|
+
});
|
|
1076
|
+
});
|