@excom/renderable-element 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/renderable-element.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/renderable-element.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/renderable-element.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 +6 -0
- package/index.ts +638 -0
- package/package.json +48 -0
- package/rush-logs/renderable-element.apply-exports.cache.log +1 -0
- package/rush-logs/renderable-element.apply-exports.log +1 -0
- package/rush-logs/renderable-element.build_docs.cache.log +1 -0
- package/rush-logs/renderable-element.build_docs.log +1 -0
- package/rush-logs/renderable-element.build_package-metas.cache.log +1 -0
- package/rush-logs/renderable-element.build_package-metas.log +1 -0
- package/src/index.css +11 -0
- package/support/custom-elements.json +371 -0
- package/support/demos/host-iframe.html +26 -0
- package/support/demos/host-selector.html +29 -0
- package/support/demos/host-shadow.html +31 -0
- package/support/demos/persist-content.html +57 -0
- package/support/demos/render-event.html +66 -0
- package/support/dist-docs/renderable-element.md +499 -0
- package/support/docs/README.md +146 -0
- package/support/package-meta.json +206 -0
- package/support/tests/host-iframe.view.test.ts +22 -0
- package/support/tests/host-selector.view.test.ts +19 -0
- package/support/tests/host-shadow.view.test.ts +19 -0
- package/support/tests/persist-content.view.test.ts +31 -0
- package/support/tests/render-event.view.test.ts +24 -0
- package/support/tests/render-lifecycle.test.ts +961 -0
- package/support/tests/renderable-element.test.ts +222 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import { invokeCommand } from "@excom/neutron";
|
|
2
|
+
import { RenderableElement } from "../../index";
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
fixture,
|
|
8
|
+
it,
|
|
9
|
+
spyFetch,
|
|
10
|
+
vi,
|
|
11
|
+
wait,
|
|
12
|
+
waitForEvent,
|
|
13
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
14
|
+
import { KitLogger } from "@excom/kit-logger";
|
|
15
|
+
|
|
16
|
+
const TAG = "renderable-lifecycle-test";
|
|
17
|
+
if (!customElements.get(TAG)) {
|
|
18
|
+
RenderableElement.define(TAG);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Lifecycle events use the base's config tag (`noop-tag`), not the
|
|
23
|
+
* defined tag. `aborted` is not `prefixWithTag`, so it stays bare.
|
|
24
|
+
*/
|
|
25
|
+
const EVT = (name: string) => `noop-tag-${name}`;
|
|
26
|
+
|
|
27
|
+
const html = (body: string) => ({
|
|
28
|
+
body,
|
|
29
|
+
headers: new Headers({ "content-type": "text/html" }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const abortError = () =>
|
|
33
|
+
new DOMException("The operation was aborted.", "AbortError");
|
|
34
|
+
|
|
35
|
+
/** Fetch stub that only settles when its signal aborts (rejects `reason`). */
|
|
36
|
+
const spyAbortableFetch = (reason: () => Error = abortError) =>
|
|
37
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
38
|
+
(_url, init) =>
|
|
39
|
+
new Promise((_resolve, reject) => {
|
|
40
|
+
init?.signal?.addEventListener("abort", () => reject(reason()));
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const reload = (el: Element) => invokeCommand(el, "--reload");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Iframe-hosted element whose author iframe has no document yet.
|
|
48
|
+
* `contentDocument` stays null until `load()` flips it and fires `load`.
|
|
49
|
+
*/
|
|
50
|
+
const buildPendingIframeHost = (extraAttrs = "", hostRef = "iframe") => {
|
|
51
|
+
const wrap = document.createElement("div");
|
|
52
|
+
wrap.innerHTML = `<${TAG} host-ref="${hostRef}" ${extraAttrs}>
|
|
53
|
+
<template><p>late</p></template>
|
|
54
|
+
<iframe data-render-host></iframe>
|
|
55
|
+
</${TAG}>`;
|
|
56
|
+
const el = wrap.firstElementChild as any;
|
|
57
|
+
const iframe = el.querySelector("iframe") as HTMLIFrameElement;
|
|
58
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
59
|
+
let ready = false;
|
|
60
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
61
|
+
configurable: true,
|
|
62
|
+
get: () => (ready ? doc : null),
|
|
63
|
+
});
|
|
64
|
+
document.body.appendChild(wrap);
|
|
65
|
+
return {
|
|
66
|
+
el,
|
|
67
|
+
iframe,
|
|
68
|
+
doc,
|
|
69
|
+
load: () => {
|
|
70
|
+
ready = true;
|
|
71
|
+
iframe.dispatchEvent(new Event("load"));
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("RenderableElement template resolution", () => {
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
document.body.innerHTML = "";
|
|
79
|
+
vi.restoreAllMocks();
|
|
80
|
+
vi.unstubAllGlobals();
|
|
81
|
+
KitLogger.unsuppress();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("resolves template-ref as an in-document selector", async () => {
|
|
85
|
+
document.body.innerHTML = `
|
|
86
|
+
<template id="rl-shared-tpl"><p>shared</p></template>
|
|
87
|
+
<${TAG} template-ref="#rl-shared-tpl"></${TAG}>
|
|
88
|
+
`;
|
|
89
|
+
const el = document.querySelector(TAG) as any;
|
|
90
|
+
|
|
91
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
92
|
+
el.isActive = true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(el.querySelector("p")?.textContent).toBe("shared");
|
|
96
|
+
expect(document.getElementById("rl-shared-tpl")).not.toBeNull();
|
|
97
|
+
expect(el).dom.to.equalTag(
|
|
98
|
+
`<${TAG} template-ref="#rl-shared-tpl" is-active did-load></${TAG}>`,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("fetches template-ref as a URL with the abort signal", async () => {
|
|
103
|
+
const fetchSpy = spyFetch(html("<p>remote</p>"));
|
|
104
|
+
const el = fixture<any>(
|
|
105
|
+
`<${TAG} template-ref="/rl-url-basic.html"></${TAG}>`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
109
|
+
el.isActive = true;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
113
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
114
|
+
expect(url).toBe("/rl-url-basic.html");
|
|
115
|
+
expect(init.signal).toBeDefined();
|
|
116
|
+
expect(el.querySelector("p")?.textContent).toBe("remote");
|
|
117
|
+
expect(el.didLoad).toBe(true);
|
|
118
|
+
expect(el.isLoading).toBe(false);
|
|
119
|
+
expect(el.templatePromise).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("re-activation reuses the cached URL fragment and emits did-unrender in between", async () => {
|
|
123
|
+
const fetchSpy = spyFetch(html("<p>warm</p>"));
|
|
124
|
+
const el = fixture<any>(
|
|
125
|
+
`<${TAG} template-ref="/rl-url-warm.html"></${TAG}>`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
129
|
+
el.isActive = true;
|
|
130
|
+
});
|
|
131
|
+
await waitForEvent(el, EVT("did-unrender"), () => {
|
|
132
|
+
el.isActive = false;
|
|
133
|
+
});
|
|
134
|
+
expect(el.querySelector("p")).toBeNull();
|
|
135
|
+
expect(el.didLoad).toBe(true);
|
|
136
|
+
|
|
137
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
138
|
+
el.isActive = true;
|
|
139
|
+
});
|
|
140
|
+
expect(el.querySelector("p")?.textContent).toBe("warm");
|
|
141
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("emits error and sets is-error when the template is missing", async () => {
|
|
145
|
+
KitLogger.suppress();
|
|
146
|
+
const el = fixture<any>(`<${TAG}></${TAG}>`);
|
|
147
|
+
|
|
148
|
+
await waitForEvent(el, EVT("error"), () => {
|
|
149
|
+
el.isActive = true;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(el).dom.to.equalTag(`<${TAG} is-active is-error></${TAG}>`);
|
|
153
|
+
expect(el.didLoad).toBe(false);
|
|
154
|
+
expect(el.isLoading).toBe(false);
|
|
155
|
+
expect(el.templatePromise).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("emits error when the URL fetch fails, and a reload recovers", async () => {
|
|
159
|
+
KitLogger.suppress();
|
|
160
|
+
let nthCall = 0;
|
|
161
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(() => {
|
|
162
|
+
nthCall++;
|
|
163
|
+
if (nthCall === 1) return Promise.reject(new TypeError("offline"));
|
|
164
|
+
return Promise.resolve(
|
|
165
|
+
new Response("<p>recovered</p>", {
|
|
166
|
+
headers: { "content-type": "text/html" },
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
const el = fixture<any>(
|
|
171
|
+
`<${TAG} template-ref="/rl-url-fail.html"></${TAG}>`,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await waitForEvent(el, EVT("error"), () => {
|
|
175
|
+
el.isActive = true;
|
|
176
|
+
});
|
|
177
|
+
expect(el.isError).toBe(true);
|
|
178
|
+
expect(el.querySelector("p")).toBeNull();
|
|
179
|
+
|
|
180
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
181
|
+
reload(el);
|
|
182
|
+
});
|
|
183
|
+
expect(el.isError).toBe(false);
|
|
184
|
+
expect(el.didLoad).toBe(true);
|
|
185
|
+
expect(el.querySelector("p")?.textContent).toBe("recovered");
|
|
186
|
+
expect(nthCall).toBe(2);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("changing template-ref clears did-load and renders the new template", async () => {
|
|
190
|
+
document.body.innerHTML = `
|
|
191
|
+
<template id="rl-tpl-a"><p>A</p></template>
|
|
192
|
+
<template id="rl-tpl-b"><p>B</p></template>
|
|
193
|
+
<${TAG} template-ref="#rl-tpl-a"></${TAG}>
|
|
194
|
+
`;
|
|
195
|
+
const el = document.querySelector(TAG) as any;
|
|
196
|
+
|
|
197
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
198
|
+
el.isActive = true;
|
|
199
|
+
});
|
|
200
|
+
expect(el.querySelector("p")?.textContent).toBe("A");
|
|
201
|
+
|
|
202
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
203
|
+
el.templateRef = "#rl-tpl-b";
|
|
204
|
+
});
|
|
205
|
+
expect(el.querySelector("p")?.textContent).toBe("B");
|
|
206
|
+
expect(el.didLoad).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("changing template-ref while loading aborts the in-flight fetch", async () => {
|
|
210
|
+
spyAbortableFetch();
|
|
211
|
+
document.body.innerHTML = `
|
|
212
|
+
<template id="rl-tpl-swap"><p>swapped</p></template>
|
|
213
|
+
<${TAG} template-ref="/rl-url-slow-swap.html"></${TAG}>
|
|
214
|
+
`;
|
|
215
|
+
const el = document.querySelector(TAG) as any;
|
|
216
|
+
const onError = vi.fn();
|
|
217
|
+
el.addEventListener(EVT("error"), onError);
|
|
218
|
+
|
|
219
|
+
el.isActive = true;
|
|
220
|
+
// the load starts on the render thunk's microtask
|
|
221
|
+
await wait(0);
|
|
222
|
+
expect(el.isLoading).toBe(true);
|
|
223
|
+
const { signal } = el.abortController;
|
|
224
|
+
|
|
225
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
226
|
+
el.templateRef = "#rl-tpl-swap";
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(signal.aborted).toBe(true);
|
|
230
|
+
expect(el.querySelector("p")?.textContent).toBe("swapped");
|
|
231
|
+
await wait(10);
|
|
232
|
+
expect(onError).not.toHaveBeenCalled();
|
|
233
|
+
expect(el.isError).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("changing template-ref while inactive only resets state", async () => {
|
|
237
|
+
const fetchSpy = spyFetch(html("<p>x</p>"));
|
|
238
|
+
const el = fixture<any>(
|
|
239
|
+
`<${TAG} template-ref="/rl-url-inactive-a.html"></${TAG}>`,
|
|
240
|
+
);
|
|
241
|
+
el.templateRef = "/rl-url-inactive-b.html";
|
|
242
|
+
await wait(10);
|
|
243
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(
|
|
244
|
+
"/rl-url-inactive-a.html",
|
|
245
|
+
expect.anything(),
|
|
246
|
+
);
|
|
247
|
+
expect(fetchSpy).not.toHaveBeenCalledWith(
|
|
248
|
+
"/rl-url-inactive-b.html",
|
|
249
|
+
expect.anything(),
|
|
250
|
+
);
|
|
251
|
+
expect(el.didLoad).toBe(false);
|
|
252
|
+
expect(el.isLoading).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("RenderableElement pre-fetch", () => {
|
|
257
|
+
afterEach(() => {
|
|
258
|
+
document.body.innerHTML = "";
|
|
259
|
+
vi.restoreAllMocks();
|
|
260
|
+
vi.unstubAllGlobals();
|
|
261
|
+
KitLogger.unsuppress();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("pre-fetch=eager fetches while inactive and renders warm on activate", async () => {
|
|
265
|
+
const fetchSpy = spyFetch(html("<p>eager</p>"));
|
|
266
|
+
const el = fixture<any>(
|
|
267
|
+
`<${TAG} template-ref="/rl-prefetch-eager.html" pre-fetch="eager"></${TAG}>`,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await wait(10);
|
|
271
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(el.didLoad).toBe(true);
|
|
273
|
+
expect(el.isActive).toBeFalsy();
|
|
274
|
+
expect(el.querySelector("p")).toBeNull();
|
|
275
|
+
|
|
276
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
277
|
+
el.isActive = true;
|
|
278
|
+
});
|
|
279
|
+
expect(el.querySelector("p")?.textContent).toBe("eager");
|
|
280
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("pre-fetch='' aliases eager", async () => {
|
|
284
|
+
const fetchSpy = spyFetch(html("<p>alias</p>"));
|
|
285
|
+
const el = fixture<any>(
|
|
286
|
+
`<${TAG} template-ref="/rl-prefetch-alias.html" pre-fetch=""></${TAG}>`,
|
|
287
|
+
);
|
|
288
|
+
await wait(10);
|
|
289
|
+
expect(el.preFetch).toBe("");
|
|
290
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
291
|
+
expect(el.didLoad).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("pre-fetch=idle schedules the fetch through requestIdleCallback", async () => {
|
|
295
|
+
const idleSpy = vi.fn((cb: () => void) => {
|
|
296
|
+
cb();
|
|
297
|
+
return 1;
|
|
298
|
+
});
|
|
299
|
+
vi.stubGlobal("requestIdleCallback", idleSpy);
|
|
300
|
+
const fetchSpy = spyFetch(html("<p>idle</p>"));
|
|
301
|
+
|
|
302
|
+
const el = fixture<any>(
|
|
303
|
+
`<${TAG} template-ref="/rl-prefetch-idle.html" pre-fetch="idle"></${TAG}>`,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(idleSpy).toHaveBeenCalledWith(expect.any(Function), {
|
|
307
|
+
timeout: 17,
|
|
308
|
+
});
|
|
309
|
+
await wait(10);
|
|
310
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
311
|
+
expect(el.didLoad).toBe(true);
|
|
312
|
+
expect(el.isActive).toBeFalsy();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("pre-fetch=idle skips the fetch when pre-fetch changed before idle", async () => {
|
|
316
|
+
let idleCb: (() => void) | undefined;
|
|
317
|
+
vi.stubGlobal(
|
|
318
|
+
"requestIdleCallback",
|
|
319
|
+
vi.fn((cb: () => void) => {
|
|
320
|
+
idleCb = cb;
|
|
321
|
+
return 1;
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
const fetchSpy = spyFetch(html("<p>never</p>"));
|
|
325
|
+
|
|
326
|
+
const el = fixture<any>(
|
|
327
|
+
`<${TAG} template-ref="/rl-prefetch-idle-changed.html" pre-fetch="idle"></${TAG}>`,
|
|
328
|
+
);
|
|
329
|
+
expect(idleCb).toBeTypeOf("function");
|
|
330
|
+
|
|
331
|
+
el.preFetch = "lazy";
|
|
332
|
+
idleCb!();
|
|
333
|
+
await wait(10);
|
|
334
|
+
|
|
335
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
336
|
+
expect(el.didLoad).toBeFalsy();
|
|
337
|
+
expect(el.isLoading).toBeFalsy();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("pre-fetch=lazy does not fetch until activated", async () => {
|
|
341
|
+
const fetchSpy = spyFetch(html("<p>lazy</p>"));
|
|
342
|
+
const el = fixture<any>(
|
|
343
|
+
`<${TAG} template-ref="/rl-prefetch-lazy.html" pre-fetch="lazy"></${TAG}>`,
|
|
344
|
+
);
|
|
345
|
+
await wait(10);
|
|
346
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
347
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
348
|
+
el.isActive = true;
|
|
349
|
+
});
|
|
350
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("setting pre-fetch while active re-resolves immediately", async () => {
|
|
354
|
+
const fetchSpy = spyFetch(html("<p>active</p>"));
|
|
355
|
+
const el = fixture<any>(
|
|
356
|
+
`<${TAG} template-ref="/rl-prefetch-active.html"></${TAG}>`,
|
|
357
|
+
);
|
|
358
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
359
|
+
el.isActive = true;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
363
|
+
el.preFetch = "idle";
|
|
364
|
+
});
|
|
365
|
+
// URL fragment is cached: no second network hit
|
|
366
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
367
|
+
expect(el.querySelector("p")?.textContent).toBe("active");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("reload while inactive with pre-fetch=eager resets and re-warms did-load", async () => {
|
|
371
|
+
const fetchSpy = spyFetch(html("<p>rewarm</p>"));
|
|
372
|
+
const el = fixture<any>(
|
|
373
|
+
`<${TAG} template-ref="/rl-prefetch-reload.html" pre-fetch="eager"></${TAG}>`,
|
|
374
|
+
);
|
|
375
|
+
await wait(10);
|
|
376
|
+
expect(el.didLoad).toBe(true);
|
|
377
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
378
|
+
|
|
379
|
+
reload(el);
|
|
380
|
+
await wait(10);
|
|
381
|
+
expect(el.didLoad).toBe(true);
|
|
382
|
+
expect(el.isActive).toBeFalsy();
|
|
383
|
+
|
|
384
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
385
|
+
el.isActive = true;
|
|
386
|
+
});
|
|
387
|
+
expect(el.querySelector("p")?.textContent).toBe("rewarm");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe("RenderableElement host-ref", () => {
|
|
392
|
+
afterEach(() => {
|
|
393
|
+
document.body.innerHTML = "";
|
|
394
|
+
vi.restoreAllMocks();
|
|
395
|
+
KitLogger.unsuppress();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("host-ref=shadow attaches an open shadow root and renders into it", async () => {
|
|
399
|
+
const el = fixture<any>(
|
|
400
|
+
`<${TAG} host-ref="shadow"><template><p>shadow</p></template></${TAG}>`,
|
|
401
|
+
);
|
|
402
|
+
expect(el.shadowRoot).not.toBeNull();
|
|
403
|
+
expect(el.renderHost).toBe(el.shadowRoot);
|
|
404
|
+
|
|
405
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
406
|
+
el.isActive = true;
|
|
407
|
+
});
|
|
408
|
+
// activation re-resolves the host and reuses the existing shadow root
|
|
409
|
+
expect(el.renderHost).toBe(el.shadowRoot);
|
|
410
|
+
expect(el.shadowRoot.querySelector("p")?.textContent).toBe("shadow");
|
|
411
|
+
expect(el.querySelector("p")).toBeNull();
|
|
412
|
+
expect(el.querySelector("template")).not.toBeNull();
|
|
413
|
+
|
|
414
|
+
await waitForEvent(el, EVT("did-unrender"), () => {
|
|
415
|
+
el.isActive = false;
|
|
416
|
+
});
|
|
417
|
+
expect(el.shadowRoot.querySelector("p")).toBeNull();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("host-ref=iframe renders into the author iframe body and unrenders from it", async () => {
|
|
421
|
+
const el = fixture<any>(
|
|
422
|
+
`<${TAG} host-ref="iframe">
|
|
423
|
+
<template><p>iframed</p></template>
|
|
424
|
+
<iframe data-render-host srcdoc="<!doctype html><html><body></body></html>"></iframe>
|
|
425
|
+
</${TAG}>`,
|
|
426
|
+
);
|
|
427
|
+
const iframe = el.querySelector("iframe[data-render-host]");
|
|
428
|
+
|
|
429
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
430
|
+
el.isActive = true;
|
|
431
|
+
});
|
|
432
|
+
expect(el.renderHost).toBe(iframe.contentDocument.body);
|
|
433
|
+
expect(iframe.contentDocument.body.querySelector("p")?.textContent).toBe(
|
|
434
|
+
"iframed",
|
|
435
|
+
);
|
|
436
|
+
expect(Array.from(el.children).some((c: any) => c.tagName === "P")).toBe(
|
|
437
|
+
false,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
await waitForEvent(el, EVT("did-unrender"), () => {
|
|
441
|
+
el.isActive = false;
|
|
442
|
+
});
|
|
443
|
+
expect(iframe.contentDocument.body.querySelector("p")).toBeNull();
|
|
444
|
+
expect(el.querySelector("iframe[data-render-host]")).toBe(iframe);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("host-ref=iframe without an author iframe renders nothing", async () => {
|
|
448
|
+
const el = fixture<any>(
|
|
449
|
+
`<${TAG} host-ref="iframe"><template><p>x</p></template></${TAG}>`,
|
|
450
|
+
);
|
|
451
|
+
expect(el.renderHost).toBeNull();
|
|
452
|
+
const onRender = vi.fn();
|
|
453
|
+
el.addEventListener(EVT("did-render"), onRender);
|
|
454
|
+
|
|
455
|
+
el.isActive = true;
|
|
456
|
+
await wait(20);
|
|
457
|
+
|
|
458
|
+
expect(el.didLoad).toBe(true);
|
|
459
|
+
expect(el.querySelector("iframe")).toBeNull();
|
|
460
|
+
expect(el.querySelector("p")).toBeNull();
|
|
461
|
+
expect(onRender).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("host-ref selector portals content into the matched element", async () => {
|
|
465
|
+
document.body.innerHTML = `
|
|
466
|
+
<div id="rl-portal-target"></div>
|
|
467
|
+
<${TAG} host-ref="#rl-portal-target"><template><p>portal</p></template></${TAG}>
|
|
468
|
+
`;
|
|
469
|
+
const el = document.querySelector(TAG) as any;
|
|
470
|
+
const target = document.getElementById("rl-portal-target")!;
|
|
471
|
+
expect(el.renderHost).toBe(target);
|
|
472
|
+
|
|
473
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
474
|
+
el.isActive = true;
|
|
475
|
+
});
|
|
476
|
+
expect(target.querySelector("p")?.textContent).toBe("portal");
|
|
477
|
+
expect(el.querySelector("p")).toBeNull();
|
|
478
|
+
|
|
479
|
+
await waitForEvent(el, EVT("did-unrender"), () => {
|
|
480
|
+
el.isActive = false;
|
|
481
|
+
});
|
|
482
|
+
expect(target.querySelector("p")).toBeNull();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("host-ref selector that matches nothing renders and unrenders nothing", async () => {
|
|
486
|
+
const el = fixture<any>(
|
|
487
|
+
`<${TAG} host-ref="#rl-nowhere"><template><p>x</p></template></${TAG}>`,
|
|
488
|
+
);
|
|
489
|
+
expect(el.renderHost).toBeNull();
|
|
490
|
+
const onRender = vi.fn();
|
|
491
|
+
const onUnrender = vi.fn();
|
|
492
|
+
el.addEventListener(EVT("did-render"), onRender);
|
|
493
|
+
el.addEventListener(EVT("did-unrender"), onUnrender);
|
|
494
|
+
|
|
495
|
+
el.isActive = true;
|
|
496
|
+
await wait(20);
|
|
497
|
+
expect(el.querySelector("p")).toBeNull();
|
|
498
|
+
expect(onRender).not.toHaveBeenCalled();
|
|
499
|
+
|
|
500
|
+
el.isActive = false;
|
|
501
|
+
await wait(10);
|
|
502
|
+
expect(onUnrender).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("re-targets from light DOM to shadow while active, clearing the old host", async () => {
|
|
506
|
+
const el = fixture<any>(
|
|
507
|
+
`<${TAG}><template><p>moves</p></template></${TAG}>`,
|
|
508
|
+
);
|
|
509
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
510
|
+
el.isActive = true;
|
|
511
|
+
});
|
|
512
|
+
expect(el.querySelector("p")?.textContent).toBe("moves");
|
|
513
|
+
|
|
514
|
+
const onUnrender = vi.fn();
|
|
515
|
+
el.addEventListener(EVT("did-unrender"), onUnrender);
|
|
516
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
517
|
+
el.hostRef = "shadow";
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(onUnrender).toHaveBeenCalledTimes(1);
|
|
521
|
+
expect(el.querySelector("p")).toBeNull();
|
|
522
|
+
expect(el.shadowRoot.querySelector("p")?.textContent).toBe("moves");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("re-targets a persisted tree without re-resolving the template", async () => {
|
|
526
|
+
const el = fixture<any>(
|
|
527
|
+
`<${TAG} persist-content><template><p>kept</p></template></${TAG}>`,
|
|
528
|
+
);
|
|
529
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
530
|
+
el.isActive = true;
|
|
531
|
+
});
|
|
532
|
+
const live = el.querySelector("p");
|
|
533
|
+
expect(el._persistedTree).toBeTruthy();
|
|
534
|
+
|
|
535
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
536
|
+
el.hostRef = "shadow";
|
|
537
|
+
});
|
|
538
|
+
expect(el.shadowRoot.querySelector("p")).toBe(live);
|
|
539
|
+
expect(el._persistedTree).toBeTruthy();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("routes an invalid host-ref selector to the error lifecycle", () => {
|
|
543
|
+
const errorSpy = vi.spyOn(KitLogger, "error").mockImplementation(
|
|
544
|
+
() => {},
|
|
545
|
+
);
|
|
546
|
+
const el = fixture<any>(`<${TAG}><template><p>x</p></template></${TAG}>`);
|
|
547
|
+
el.hostRef = "#[invalid";
|
|
548
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
549
|
+
`${TAG}:`,
|
|
550
|
+
expect.objectContaining({ message: expect.any(String) }),
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
describe("RenderableElement late-ready iframe host", () => {
|
|
556
|
+
afterEach(() => {
|
|
557
|
+
document.body.innerHTML = "";
|
|
558
|
+
vi.restoreAllMocks();
|
|
559
|
+
KitLogger.unsuppress();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("waits for the iframe to load, then paints into its body", async () => {
|
|
563
|
+
const { el, doc, load } = buildPendingIframeHost();
|
|
564
|
+
expect(el.renderHost).toBeNull();
|
|
565
|
+
|
|
566
|
+
el.isActive = true;
|
|
567
|
+
await wait(10);
|
|
568
|
+
expect(el.didLoad).toBe(true);
|
|
569
|
+
expect(doc.body.querySelector("p")).toBeNull();
|
|
570
|
+
|
|
571
|
+
await waitForEvent(el, EVT("did-render"), load);
|
|
572
|
+
expect(el.renderHost).toBe(doc.body);
|
|
573
|
+
expect(doc.body.querySelector("p")?.textContent).toBe("late");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("only records the host when the iframe loads while inactive", async () => {
|
|
577
|
+
const { el, doc, load } = buildPendingIframeHost();
|
|
578
|
+
load();
|
|
579
|
+
await wait(0);
|
|
580
|
+
expect(el.renderHost).toBe(doc.body);
|
|
581
|
+
expect(doc.body.querySelector("p")).toBeNull();
|
|
582
|
+
|
|
583
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
584
|
+
el.isActive = true;
|
|
585
|
+
});
|
|
586
|
+
expect(doc.body.querySelector("p")?.textContent).toBe("late");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("ignores the iframe load once host-ref has moved elsewhere", async () => {
|
|
590
|
+
const { el, doc, load } = buildPendingIframeHost();
|
|
591
|
+
el.isActive = true;
|
|
592
|
+
await wait(10);
|
|
593
|
+
|
|
594
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
595
|
+
el.hostRef = "shadow";
|
|
596
|
+
});
|
|
597
|
+
load();
|
|
598
|
+
await wait(10);
|
|
599
|
+
|
|
600
|
+
expect(doc.body.querySelector("p")).toBeNull();
|
|
601
|
+
expect(el.renderHost).toBe(el.shadowRoot);
|
|
602
|
+
expect(el.shadowRoot.querySelector("p")?.textContent).toBe("late");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("ignores the iframe load once the iframe has been removed", async () => {
|
|
606
|
+
const { el, iframe, doc, load } = buildPendingIframeHost();
|
|
607
|
+
el.isActive = true;
|
|
608
|
+
await wait(10);
|
|
609
|
+
|
|
610
|
+
iframe.remove();
|
|
611
|
+
load();
|
|
612
|
+
await wait(10);
|
|
613
|
+
|
|
614
|
+
expect(el.renderHost).toBeNull();
|
|
615
|
+
expect(doc.body.querySelector("p")).toBeNull();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("re-places a persisted tree once the iframe is ready", async () => {
|
|
619
|
+
// Start on a shadow host. A light-DOM host would clear the author
|
|
620
|
+
// iframe with the rendered content when `host-ref` retargets.
|
|
621
|
+
const { el, doc, load } = buildPendingIframeHost(
|
|
622
|
+
"persist-content",
|
|
623
|
+
"shadow",
|
|
624
|
+
);
|
|
625
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
626
|
+
el.isActive = true;
|
|
627
|
+
});
|
|
628
|
+
const live = el.shadowRoot.querySelector("p");
|
|
629
|
+
expect(live).not.toBeNull();
|
|
630
|
+
expect(el._persistedTree).toBe(live);
|
|
631
|
+
|
|
632
|
+
el.hostRef = "iframe";
|
|
633
|
+
await wait(0);
|
|
634
|
+
expect(el.renderHost).toBeNull();
|
|
635
|
+
expect(el.shadowRoot.querySelector("p")).toBeNull();
|
|
636
|
+
expect(el._persistedTree).toBe(live);
|
|
637
|
+
|
|
638
|
+
await waitForEvent(el, EVT("did-render"), load);
|
|
639
|
+
expect(el.renderHost).toBe(doc.body);
|
|
640
|
+
expect(doc.body.querySelector("p")).toBe(live);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
describe("RenderableElement reload", () => {
|
|
645
|
+
afterEach(() => {
|
|
646
|
+
document.body.innerHTML = "";
|
|
647
|
+
vi.restoreAllMocks();
|
|
648
|
+
KitLogger.unsuppress();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("re-fetches with bypassCache and re-renders while active", async () => {
|
|
652
|
+
let nthCall = 0;
|
|
653
|
+
const fetchSpy = spyFetch(() => html(`<p>render-${++nthCall}</p>`));
|
|
654
|
+
const el = fixture<any>(
|
|
655
|
+
`<${TAG} template-ref="/rl-reload-refetch.html"></${TAG}>`,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
659
|
+
el.isActive = true;
|
|
660
|
+
});
|
|
661
|
+
expect(el.querySelector("p")?.textContent).toBe("render-1");
|
|
662
|
+
|
|
663
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
664
|
+
reload(el);
|
|
665
|
+
});
|
|
666
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
667
|
+
expect(el.querySelector("p")?.textContent).toBe("render-2");
|
|
668
|
+
expect(el.didLoad).toBe(true);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("does nothing while inactive with lazy pre-fetch", async () => {
|
|
672
|
+
const fetchSpy = spyFetch(html("<p>x</p>"));
|
|
673
|
+
const el = fixture<any>(
|
|
674
|
+
`<${TAG} template-ref="/rl-reload-inactive.html"></${TAG}>`,
|
|
675
|
+
);
|
|
676
|
+
reload(el);
|
|
677
|
+
await wait(10);
|
|
678
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
679
|
+
expect(el.isLoading).toBeFalsy();
|
|
680
|
+
expect(el.didLoad).toBeFalsy();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("aborts an in-flight fetch before re-resolving", async () => {
|
|
684
|
+
let nthCall = 0;
|
|
685
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((_url, init) => {
|
|
686
|
+
nthCall++;
|
|
687
|
+
if (nthCall === 1) {
|
|
688
|
+
return new Promise((_resolve, reject) => {
|
|
689
|
+
init?.signal?.addEventListener("abort", () => reject(abortError()));
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return Promise.resolve(
|
|
693
|
+
new Response(`<p>reloaded-${nthCall}</p>`, {
|
|
694
|
+
headers: { "content-type": "text/html" },
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
});
|
|
698
|
+
const el = fixture<any>(
|
|
699
|
+
`<${TAG} template-ref="/rl-reload-inflight.html"></${TAG}>`,
|
|
700
|
+
);
|
|
701
|
+
const onError = vi.fn();
|
|
702
|
+
el.addEventListener(EVT("error"), onError);
|
|
703
|
+
|
|
704
|
+
el.isActive = true;
|
|
705
|
+
// the load starts on the render thunk's microtask
|
|
706
|
+
await wait(0);
|
|
707
|
+
expect(el.isLoading).toBe(true);
|
|
708
|
+
const { signal } = el.abortController;
|
|
709
|
+
|
|
710
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
711
|
+
reload(el);
|
|
712
|
+
});
|
|
713
|
+
expect(signal.aborted).toBe(true);
|
|
714
|
+
expect(nthCall).toBe(2);
|
|
715
|
+
expect(el.querySelector("p")?.textContent).toBe("reloaded-2");
|
|
716
|
+
expect(onError).not.toHaveBeenCalled();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("drops a persisted tree so the reload paints fresh nodes", async () => {
|
|
720
|
+
let nthCall = 0;
|
|
721
|
+
spyFetch(() => html(`<p>v${++nthCall}</p>`));
|
|
722
|
+
const el = fixture<any>(
|
|
723
|
+
`<${TAG} persist-content template-ref="/rl-reload-persist.html"></${TAG}>`,
|
|
724
|
+
);
|
|
725
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
726
|
+
el.isActive = true;
|
|
727
|
+
});
|
|
728
|
+
const first = el.querySelector("p");
|
|
729
|
+
expect(el._persistedTree).toBe(first);
|
|
730
|
+
|
|
731
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
732
|
+
reload(el);
|
|
733
|
+
});
|
|
734
|
+
const second = el.querySelector("p");
|
|
735
|
+
expect(second).not.toBe(first);
|
|
736
|
+
expect(second?.textContent).toBe("v2");
|
|
737
|
+
expect(el._persistedTree).toBe(second);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
describe("RenderableElement ready-on and render events", () => {
|
|
742
|
+
afterEach(() => {
|
|
743
|
+
document.body.innerHTML = "";
|
|
744
|
+
vi.restoreAllMocks();
|
|
745
|
+
KitLogger.unsuppress();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("keeps delaying-ready until the ready-on event fires", async () => {
|
|
749
|
+
const el = fixture<any>(
|
|
750
|
+
`<${TAG} ready-on="rl-ready"><template><p>r</p></template></${TAG}>`,
|
|
751
|
+
);
|
|
752
|
+
expect(el).toContainListeners({ "rl-ready": 1 });
|
|
753
|
+
|
|
754
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
755
|
+
el.isActive = true;
|
|
756
|
+
});
|
|
757
|
+
expect(el.delayingReady).toBe(true);
|
|
758
|
+
expect(el).dom.to.equalTag(
|
|
759
|
+
`<${TAG} ready-on="rl-ready" is-active did-load delaying-ready></${TAG}>`,
|
|
760
|
+
);
|
|
761
|
+
const readyPromise = el.readyPromiseObject.promise;
|
|
762
|
+
|
|
763
|
+
el.dispatchEvent(new Event("rl-ready"));
|
|
764
|
+
await readyPromise;
|
|
765
|
+
await wait(0);
|
|
766
|
+
expect(el.delayingReady).toBe(false);
|
|
767
|
+
expect(el.readyPromiseObject).toBeNull();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("render thunk resolves when ready-on fires", async () => {
|
|
771
|
+
const el = fixture<any>(
|
|
772
|
+
`<${TAG} ready-on="rl-ready-thunk"><template><p>t</p></template></${TAG}>`,
|
|
773
|
+
);
|
|
774
|
+
let thunkResult: Promise<void> | undefined;
|
|
775
|
+
el.addEventListener(EVT("render"), (e: CustomEvent) => {
|
|
776
|
+
e.preventDefault();
|
|
777
|
+
thunkResult = e.detail();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
781
|
+
el.isActive = true;
|
|
782
|
+
});
|
|
783
|
+
expect(thunkResult).toBeInstanceOf(Promise);
|
|
784
|
+
expect(el.delayingReady).toBe(true);
|
|
785
|
+
|
|
786
|
+
el.dispatchEvent(new Event("rl-ready-thunk"));
|
|
787
|
+
await thunkResult;
|
|
788
|
+
expect(el.delayingReady).toBe(false);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("swaps and removes the ready-on listener as the option changes", () => {
|
|
792
|
+
const el = fixture<any>(
|
|
793
|
+
`<${TAG} ready-on="rl-ready-a"><template><p>r</p></template></${TAG}>`,
|
|
794
|
+
);
|
|
795
|
+
expect(el).toContainListeners({ "rl-ready-a": 1 });
|
|
796
|
+
|
|
797
|
+
el.readyOn = "rl-ready-b";
|
|
798
|
+
expect(el).toContainListeners({ "rl-ready-a": 0, "rl-ready-b": 1 });
|
|
799
|
+
|
|
800
|
+
el.removeAttribute("ready-on");
|
|
801
|
+
expect(el).toContainListeners({ "rl-ready-a": 0, "rl-ready-b": 0 });
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("aborts a delaying-ready render when deactivated", async () => {
|
|
805
|
+
const el = fixture<any>(
|
|
806
|
+
`<${TAG} ready-on="rl-ready-abort"><template><p>r</p></template></${TAG}>`,
|
|
807
|
+
);
|
|
808
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
809
|
+
el.isActive = true;
|
|
810
|
+
});
|
|
811
|
+
const readyPromise = el.readyPromiseObject.promise;
|
|
812
|
+
const onUnrender = vi.fn();
|
|
813
|
+
el.addEventListener(EVT("unrender"), onUnrender);
|
|
814
|
+
|
|
815
|
+
await waitForEvent(el, "aborted", () => {
|
|
816
|
+
el.isActive = false;
|
|
817
|
+
});
|
|
818
|
+
await expect(readyPromise).rejects.toBeUndefined();
|
|
819
|
+
expect(el.delayingReady).toBe(false);
|
|
820
|
+
expect(onUnrender).not.toHaveBeenCalled();
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("a deferred render thunk is a no-op once the element is inactive", async () => {
|
|
824
|
+
const el = fixture<any>(`<${TAG}><template><p>d</p></template></${TAG}>`);
|
|
825
|
+
let thunk: (() => Promise<void> | undefined) | undefined;
|
|
826
|
+
el.addEventListener(EVT("render"), (e: CustomEvent) => {
|
|
827
|
+
e.preventDefault();
|
|
828
|
+
thunk = e.detail;
|
|
829
|
+
});
|
|
830
|
+
const onRender = vi.fn();
|
|
831
|
+
el.addEventListener(EVT("did-render"), onRender);
|
|
832
|
+
|
|
833
|
+
el.isActive = true;
|
|
834
|
+
expect(thunk).toBeTypeOf("function");
|
|
835
|
+
expect(el.readyPromiseObject).toBeTruthy();
|
|
836
|
+
|
|
837
|
+
el.isActive = false;
|
|
838
|
+
await wait(0);
|
|
839
|
+
thunk!();
|
|
840
|
+
await wait(10);
|
|
841
|
+
|
|
842
|
+
expect(onRender).not.toHaveBeenCalled();
|
|
843
|
+
expect(el.querySelector("p")).toBeNull();
|
|
844
|
+
expect(el.readyPromiseObject).toBeNull();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("a cancelled unrender leaves content in place until the thunk runs", async () => {
|
|
848
|
+
const el = fixture<any>(`<${TAG}><template><p>u</p></template></${TAG}>`);
|
|
849
|
+
await waitForEvent(el, EVT("did-render"), () => {
|
|
850
|
+
el.isActive = true;
|
|
851
|
+
});
|
|
852
|
+
let thunk: (() => void) | undefined;
|
|
853
|
+
el.addEventListener(EVT("unrender"), (e: CustomEvent) => {
|
|
854
|
+
e.preventDefault();
|
|
855
|
+
thunk = e.detail;
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
el.isActive = false;
|
|
859
|
+
await wait(0);
|
|
860
|
+
expect(el.querySelector("p")).not.toBeNull();
|
|
861
|
+
|
|
862
|
+
await waitForEvent(el, EVT("did-unrender"), () => thunk!());
|
|
863
|
+
expect(el.querySelector("p")).toBeNull();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe("RenderableElement abort handling", () => {
|
|
868
|
+
afterEach(() => {
|
|
869
|
+
document.body.innerHTML = "";
|
|
870
|
+
vi.restoreAllMocks();
|
|
871
|
+
KitLogger.unsuppress();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it("aborts the in-flight template fetch on disconnect without an error", async () => {
|
|
875
|
+
spyAbortableFetch();
|
|
876
|
+
const el = fixture<any>(
|
|
877
|
+
`<${TAG} template-ref="/rl-abort-disconnect.html"></${TAG}>`,
|
|
878
|
+
);
|
|
879
|
+
const onError = vi.fn();
|
|
880
|
+
el.addEventListener(EVT("error"), onError);
|
|
881
|
+
|
|
882
|
+
el.isActive = true;
|
|
883
|
+
// the load starts on the render thunk's microtask
|
|
884
|
+
await wait(0);
|
|
885
|
+
expect(el.isLoading).toBe(true);
|
|
886
|
+
const { signal } = el.abortController;
|
|
887
|
+
|
|
888
|
+
el.remove();
|
|
889
|
+
await wait(10);
|
|
890
|
+
|
|
891
|
+
expect(signal.aborted).toBe(true);
|
|
892
|
+
expect(el.isLoading).toBe(false);
|
|
893
|
+
expect(el.templatePromise).toBeNull();
|
|
894
|
+
expect(el.isError).toBe(false);
|
|
895
|
+
expect(onError).not.toHaveBeenCalled();
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("deactivating while loading emits aborted instead of unrender", async () => {
|
|
899
|
+
spyAbortableFetch();
|
|
900
|
+
const el = fixture<any>(
|
|
901
|
+
`<${TAG} template-ref="/rl-abort-deactivate.html"></${TAG}>`,
|
|
902
|
+
);
|
|
903
|
+
const onUnrender = vi.fn();
|
|
904
|
+
el.addEventListener(EVT("unrender"), onUnrender);
|
|
905
|
+
el.isActive = true;
|
|
906
|
+
await wait(0);
|
|
907
|
+
expect(el.isLoading).toBe(true);
|
|
908
|
+
const { signal } = el.abortController;
|
|
909
|
+
|
|
910
|
+
await waitForEvent(el, "aborted", () => {
|
|
911
|
+
el.isActive = false;
|
|
912
|
+
});
|
|
913
|
+
expect(signal.aborted).toBe(true);
|
|
914
|
+
expect(el.isLoading).toBe(false);
|
|
915
|
+
expect(onUnrender).not.toHaveBeenCalled();
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("an externally aborted fetch is logged as debug, not error", async () => {
|
|
919
|
+
spyAbortableFetch();
|
|
920
|
+
const debugSpy = vi.spyOn(KitLogger, "debug").mockImplementation(
|
|
921
|
+
() => {},
|
|
922
|
+
);
|
|
923
|
+
const el = fixture<any>(
|
|
924
|
+
`<${TAG} template-ref="/rl-abort-external.html"></${TAG}>`,
|
|
925
|
+
);
|
|
926
|
+
const onError = vi.fn();
|
|
927
|
+
el.addEventListener(EVT("error"), onError);
|
|
928
|
+
|
|
929
|
+
el.isActive = true;
|
|
930
|
+
await wait(0);
|
|
931
|
+
expect(el.isLoading).toBe(true);
|
|
932
|
+
el.doAbort();
|
|
933
|
+
await wait(10);
|
|
934
|
+
|
|
935
|
+
expect(onError).not.toHaveBeenCalled();
|
|
936
|
+
expect(el.isError).toBe(false);
|
|
937
|
+
expect(debugSpy).toHaveBeenCalledWith("templatePromise was aborted");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("wraps a non-abort rejection as AbortError when the signal was aborted", async () => {
|
|
941
|
+
spyAbortableFetch(() => new Error("socket closed"));
|
|
942
|
+
const debugSpy = vi.spyOn(KitLogger, "debug").mockImplementation(
|
|
943
|
+
() => {},
|
|
944
|
+
);
|
|
945
|
+
const el = fixture<any>(
|
|
946
|
+
`<${TAG} template-ref="/rl-abort-wrapped.html"></${TAG}>`,
|
|
947
|
+
);
|
|
948
|
+
const onError = vi.fn();
|
|
949
|
+
el.addEventListener(EVT("error"), onError);
|
|
950
|
+
|
|
951
|
+
el.isActive = true;
|
|
952
|
+
await wait(0);
|
|
953
|
+
expect(el.isLoading).toBe(true);
|
|
954
|
+
el.doAbort();
|
|
955
|
+
await wait(10);
|
|
956
|
+
|
|
957
|
+
expect(onError).not.toHaveBeenCalled();
|
|
958
|
+
expect(el.isError).toBe(false);
|
|
959
|
+
expect(debugSpy).toHaveBeenCalledWith("templatePromise was aborted");
|
|
960
|
+
});
|
|
961
|
+
});
|