@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.
Files changed (40) hide show
  1. package/.rush/temp/chunked-rush-logs/renderable-element.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/renderable-element.build_docs.chunks.jsonl +1 -0
  3. package/.rush/temp/chunked-rush-logs/renderable-element.build_package-metas.chunks.jsonl +1 -0
  4. package/.rush/temp/operation/apply-exports/all.log +1 -0
  5. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  6. package/.rush/temp/operation/apply-exports/state.json +3 -0
  7. package/.rush/temp/operation/build_docs/all.log +1 -0
  8. package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
  9. package/.rush/temp/operation/build_docs/state.json +3 -0
  10. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  11. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  12. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  13. package/.rush/temp/shrinkwrap-deps.json +3 -0
  14. package/config/rig.json +6 -0
  15. package/index.ts +638 -0
  16. package/package.json +48 -0
  17. package/rush-logs/renderable-element.apply-exports.cache.log +1 -0
  18. package/rush-logs/renderable-element.apply-exports.log +1 -0
  19. package/rush-logs/renderable-element.build_docs.cache.log +1 -0
  20. package/rush-logs/renderable-element.build_docs.log +1 -0
  21. package/rush-logs/renderable-element.build_package-metas.cache.log +1 -0
  22. package/rush-logs/renderable-element.build_package-metas.log +1 -0
  23. package/src/index.css +11 -0
  24. package/support/custom-elements.json +371 -0
  25. package/support/demos/host-iframe.html +26 -0
  26. package/support/demos/host-selector.html +29 -0
  27. package/support/demos/host-shadow.html +31 -0
  28. package/support/demos/persist-content.html +57 -0
  29. package/support/demos/render-event.html +66 -0
  30. package/support/dist-docs/renderable-element.md +499 -0
  31. package/support/docs/README.md +146 -0
  32. package/support/package-meta.json +206 -0
  33. package/support/tests/host-iframe.view.test.ts +22 -0
  34. package/support/tests/host-selector.view.test.ts +19 -0
  35. package/support/tests/host-shadow.view.test.ts +19 -0
  36. package/support/tests/persist-content.view.test.ts +31 -0
  37. package/support/tests/render-event.view.test.ts +24 -0
  38. package/support/tests/render-lifecycle.test.ts +961 -0
  39. package/support/tests/renderable-element.test.ts +222 -0
  40. package/tsconfig.json +5 -0
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1 @@
1
+ Invoking: cd "$RUSH_PROJECT_FOLDER" && node ../heft-rig/scripts/apply-exports.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: cd \"$RUSH_PROJECT_FOLDER\" && node ../heft-rig/scripts/apply-exports.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 35.91234200000008
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-docs.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 56.84112700000003
3
+ }
@@ -0,0 +1 @@
1
+ Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs
@@ -0,0 +1 @@
1
+ {"kind":"O","text":"Invoking: node node_modules/@excom/heft-rig/scripts/build-package-metas.mjs \n"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "nonCachedDurationMs": 122.831054
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "../../packages/renderable-element": "../../packages/renderable-element:pkOMwR6dapZctAlG5KqgGeRNloATmnzNngM/Rx9ZIsc=:"
3
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
3
+ "rigPackageName": "@excom/heft-rig",
4
+ "rigProfile": "default"
5
+ }
6
+
package/index.ts ADDED
@@ -0,0 +1,638 @@
1
+ import { AbortableElement } from "@excom/abortable-element";
2
+ import { KitLogger } from "@excom/kit-logger";
3
+ import { requestIdleCb } from "@excom/kit-shims";
4
+ import {
5
+ replaceNonTemplateChildren,
6
+ resolveTemplateContent,
7
+ selectOne,
8
+ } from "@excom/kit-utils";
9
+ import { ConstructorType, Neutron, TEvent } from "@excom/neutron";
10
+
11
+ export type RenderThunk = () => Promise<void>;
12
+ export type UnrenderThunk = () => void;
13
+
14
+ export type RenderableRenderEvent = TEvent & {
15
+ type: "{tag}-render";
16
+ detail: RenderThunk;
17
+ };
18
+
19
+ export type RenderableUnrenderEvent = TEvent & {
20
+ type: "{tag}-unrender";
21
+ detail: UnrenderThunk;
22
+ };
23
+
24
+ export type RenderableDidRenderEvent = TEvent & {
25
+ type: "{tag}-did-render";
26
+ detail: void;
27
+ };
28
+
29
+ export type RenderableDidUnrenderEvent = TEvent & {
30
+ type: "{tag}-did-unrender";
31
+ detail: void;
32
+ };
33
+
34
+ export type RenderableErrorEvent = TEvent & {
35
+ type: "{tag}-error";
36
+ detail: void;
37
+ };
38
+
39
+ export type RenderableAbortedEvent = TEvent & {
40
+ type: "{tag}-aborted";
41
+ detail: void;
42
+ };
43
+
44
+ const IFRAME_HOST_ATTR = "data-render-host";
45
+ /** Iframes waiting on `load` so their body can be `renderHost`. */
46
+ const pendingIframeLoads = new WeakSet<HTMLIFrameElement>();
47
+
48
+ // Must stay `async` even when `resolveTemplateContent` is sync.
49
+ const initTemplatePromise = async (
50
+ element,
51
+ opts: { forceBypassCache?: boolean } = {}
52
+ ) => {
53
+ const { templateRef, bypassCache, abortController } = element;
54
+ // Snapshot this signal: `doAbort` rotates a fresh controller onto the
55
+ // element, so don't read `element.abortController` after await.
56
+ const { signal } = abortController;
57
+ try {
58
+ return await resolveTemplateContent(templateRef, {
59
+ scope: element,
60
+ bypassCache: bypassCache || opts.forceBypassCache,
61
+ reqInit: { signal },
62
+ });
63
+ } catch (error) {
64
+ // Preserve aborts so disconnect / reload teardown is quiet
65
+ if (error?.name === "AbortError" || signal.aborted) {
66
+ throw error?.name === "AbortError"
67
+ ? error
68
+ : Object.assign(new Error("Aborted"), { name: "AbortError" });
69
+ }
70
+ throw new Error(`Failed to find template: ${templateRef}`);
71
+ }
72
+ };
73
+
74
+ export interface PromiseObject {
75
+ promise: Promise<void>;
76
+ resolve: (value: void) => void;
77
+ reject: (reason?: any) => void;
78
+ }
79
+
80
+ const makePromiseObject = () => {
81
+ const readyPromiseObject = {} as unknown as PromiseObject;
82
+ readyPromiseObject.promise = new Promise((resolve, reject) => {
83
+ readyPromiseObject.resolve = resolve;
84
+ readyPromiseObject.reject = reject;
85
+ });
86
+ return readyPromiseObject;
87
+ };
88
+
89
+ /**
90
+ * Composition base that resolves a `<template>` reference, fetches its
91
+ * content (eagerly, on idle, lazily, or on demand), and renders / unrenders
92
+ * those children into a configurable host (the element's own light DOM, an
93
+ * attached shadow root, a child `<iframe data-render-host>`, or any
94
+ * selector-resolved element). Not a registered element on its own (tag is
95
+ * intentionally `noop-tag`) — compose it via
96
+ * `Neutron.compose([RenderableElement, ...])` and the consumer element
97
+ * inherits every attribute, state, and event declared below.
98
+ *
99
+ * Subclasses only need to toggle `is-active` to drive the lifecycle:
100
+ * setting it triggers loading (if needed) and renders the template;
101
+ * unsetting it unrenders. Templates are resolved via
102
+ * `resolveTemplateContent`, so `template-ref` accepts in-document selectors
103
+ * (`":scope > template"`, the default) as well as URLs to remote
104
+ * templates. URL responses are memoized in kit-utils (`TEMPLATES`);
105
+ * `did-load` reflects that a resolve succeeded so consumers can treat
106
+ * later toggles as warm. `persist-content` is separate: it keeps the
107
+ * live rendered tree in `_persistedTree` across unrender / render.
108
+ *
109
+ * The `render` and `unrender` events are dispatched as cancelable defaults:
110
+ * listeners can `preventDefault()` to take over the actual DOM update by
111
+ * calling `event.detail()` themselves (handy for orchestrating view
112
+ * transitions or animations from a parent like `<spa-manager>`).
113
+ *
114
+ * @fires {tag}-render - Cancelable. Dispatched when the element becomes
115
+ * active and is about to place template content into the host.
116
+ * `event.detail` is a thunk that performs the load (if not already
117
+ * loaded) and renders the children, returning a Promise that resolves
118
+ * once the corresponding `ready-on` event fires (or immediately if
119
+ * `ready-on` is unset). The promise rejects if the element is torn
120
+ * down mid-flight (`startTeardown` while loading / `delaying-ready`).
121
+ * Call `preventDefault()` to defer rendering and invoke
122
+ * `event.detail()` later.
123
+ * @type RenderableRenderEvent
124
+ * @fires {tag}-unrender - Cancelable. Dispatched when the element becomes
125
+ * inactive and content is already painted. `event.detail` is a thunk
126
+ * that removes the rendered children. Call `preventDefault()` to defer
127
+ * the removal. Not fired when teardown cancels an in-flight load —
128
+ * that path emits `aborted` instead.
129
+ * @type RenderableUnrenderEvent
130
+ * @fires {tag}-did-render - Dispatched after the template content has
131
+ * actually been placed into the host.
132
+ * @type RenderableDidRenderEvent
133
+ * @fires {tag}-did-unrender - Dispatched after rendered children have been
134
+ * removed from the host.
135
+ * @type RenderableDidUnrenderEvent
136
+ * @fires {tag}-error - Dispatched when the template promise rejects with
137
+ * anything other than an `AbortError`.
138
+ * @type RenderableErrorEvent
139
+ * @fires {tag}-aborted - Dispatched when an in-flight load / ready wait is
140
+ * canceled because `is-active` was unset (via `startTeardown`).
141
+ * @type RenderableAbortedEvent
142
+ *
143
+ * @default-action {tag}-render - Invokes `event.detail()` to load (if
144
+ * needed) and render the template into the host.
145
+ * @default-action {tag}-unrender - Invokes `event.detail()` to remove
146
+ * rendered children from the host.
147
+ * @command --reload - Aborts any in-flight fetch and re-resolves the
148
+ * template, bypassing the cache for URL refs (useful after remote content
149
+ * changes).
150
+ *
151
+ * @child ?template - Optional immediate `<template>` child used when
152
+ * `template-ref` is the default `":scope > template"`. Not required when
153
+ * `template-ref` points at a selector or URL elsewhere.
154
+ * @child ?iframe[data-render-host] - Required when `host-ref="iframe"`.
155
+ * Content paints into `iframe.contentDocument.body`. Provide your own
156
+ * iframe (e.g. with `srcdoc`); the element will not create one.
157
+ */
158
+
159
+ export const RenderableElement = Neutron.compose([
160
+ AbortableElement,
161
+ Neutron({
162
+ tag: "noop-tag",
163
+ events: {
164
+ ["render"]: {
165
+ prefixWithTag: true,
166
+ },
167
+ ["unrender"]: {
168
+ prefixWithTag: true,
169
+ },
170
+ ["did-render"]: {
171
+ prefixWithTag: true,
172
+ },
173
+ ["did-unrender"]: {
174
+ prefixWithTag: true,
175
+ },
176
+ error: {
177
+ prefixWithTag: true,
178
+ },
179
+ },
180
+ props: {
181
+ /**
182
+ * @option
183
+ * Source `<template>` — in-document selector or remote URL.
184
+ * Changing mid-flight aborts and reloads. Can use `:scope` to
185
+ * relatively select elements: e.g. `main:has(:scope) > template`
186
+ * @default :scope > template
187
+ * @values <CSS Selector> | <URL>
188
+ */
189
+ templateRef: {
190
+ type: String,
191
+ defaultValue: () => ":scope > template",
192
+ },
193
+ /**
194
+ * @option
195
+ * Skip the in-memory response cache (URL `template-ref` only).
196
+ */
197
+ bypassCache: Boolean,
198
+ /**
199
+ * @option
200
+ * When to fetch the template, independent of when it renders.
201
+ * `""` aliases `eager`.
202
+ * @default lazy
203
+ */
204
+ preFetch: {
205
+ type: String,
206
+ isValid: (value: string) =>
207
+ // lazy (default): fetch only when needed.
208
+ // empty string aliases eager
209
+ ["", "eager", "idle", "lazy"].includes(value),
210
+ defaultValue: () => "lazy",
211
+ },
212
+ /**
213
+ * @option
214
+ * Reuse the same live nodes across unrender / render (held on
215
+ * `_persistedTree`) so form values, scroll position, and subtree
216
+ * state survive toggles.
217
+ */
218
+ persistContent: Boolean,
219
+ /**
220
+ * @option
221
+ * Where rendered children land. Unset = this element's light DOM.
222
+ * `shadow` attaches an open shadow root. `iframe` paints into a
223
+ * child `<iframe data-render-host>` body (you supply the iframe —
224
+ * useful for sandboxed / third-party document isolation). Any
225
+ * other value is a portal selector.
226
+ * @values shadow | iframe | <CSS Selector>
227
+ */
228
+ hostRef: String,
229
+ /**
230
+ * @option
231
+ * Event name that marks rendered children "ready". Until it fires,
232
+ * `delaying-ready` is set so CSS can hide the host for a
233
+ * coordinated paint / view transition.
234
+ * @values <Event Name>
235
+ */
236
+ readyOn: String,
237
+ /**
238
+ * @option
239
+ * @state
240
+ * Master switch. Set to load (if needed) and render; unset to
241
+ * unrender. Drive from visibility, route match, hover, etc.
242
+ */
243
+ isActive: Boolean,
244
+ /**
245
+ * @state
246
+ * Template fetch in flight.
247
+ */
248
+ isLoading: Boolean,
249
+ /**
250
+ * @state
251
+ * Template resolved at least once. Stays set across `is-active`
252
+ * toggles so consumers know later paints are warm (URL refs reuse
253
+ * the shared fetch cache in kit-utils). Cleared when
254
+ * `template-ref` changes or `--reload` forces a fresh resolve.
255
+ */
256
+ didLoad: Boolean,
257
+ /**
258
+ * @state
259
+ * Latest template fetch rejected (excluding abort). Fires with
260
+ * the `error` event.
261
+ */
262
+ isError: Boolean,
263
+ /**
264
+ * @state
265
+ * Between `render` and the matching `ready-on` event. Hook with
266
+ * CSS for coordinated paints / view transitions.
267
+ */
268
+ delayingReady: Boolean,
269
+ // private state
270
+ templatePromise: Promise as unknown as ConstructorType<
271
+ Promise<HTMLTemplateElement>
272
+ >,
273
+ /* Live tree kept only with `persist-content`. Cleared on template
274
+ change / reload. Without persist, each paint re-resolves (URL hits
275
+ `TEMPLATES`) and `importNode`s a fresh clone. */
276
+ _persistedTree: Object as unknown as ConstructorType<Node | null>,
277
+ /* Current render-host (null until set up, or while an iframe host
278
+ is still loading). Null falls back to the element. Not an attribute. */
279
+ renderHost: Object as unknown as ConstructorType<
280
+ Element | ShadowRoot | null
281
+ >,
282
+ readyPromiseObject: Object as unknown as ConstructorType<PromiseObject>,
283
+ },
284
+ }),
285
+ ])
286
+ .defineMethods({
287
+ setCanceledState: () => [
288
+ { doAbort: [] },
289
+ {
290
+ templatePromise: null,
291
+ isLoading: false,
292
+ didLoad: false,
293
+ isError: false,
294
+ delayingReady: false,
295
+ },
296
+ ],
297
+ /**
298
+ * Resolve `hostRef` to a render-host:
299
+ * - unset/empty -> null (caller uses the element)
300
+ * - "shadow" -> element.shadowRoot (attaches on first call)
301
+ * - "iframe" -> child `iframe[data-render-host]` body (null if
302
+ * missing or still loading; load sets `renderHost`
303
+ * and paints)
304
+ * - any other str -> selectOne(value, { scope: element })
305
+ */
306
+ resolveRenderHost: (element) => {
307
+ const ref = element.hostRef as string | undefined;
308
+ if (!ref) return { returns: null };
309
+ if (ref === "shadow") {
310
+ return {
311
+ returns: element.shadowRoot ?? element.attachShadow({ mode: "open" }),
312
+ };
313
+ }
314
+ if (ref === "iframe") {
315
+ const iframe = element.querySelector(
316
+ `:scope > iframe[${IFRAME_HOST_ATTR}]`
317
+ ) as HTMLIFrameElement | null;
318
+ if (!iframe) return { returns: null };
319
+ const takeBody = () => iframe.contentDocument?.body ?? null;
320
+ const body = takeBody();
321
+ if (body) return { returns: body };
322
+ /* Author iframe not ready: wait for load, then paint. Parser-created
323
+ `srcdoc` iframes may have already fired `load` before upgrade, so
324
+ re-probe on a microtask too. */
325
+ if (!pendingIframeLoads.has(iframe)) {
326
+ pendingIframeLoads.add(iframe);
327
+ let settled = false;
328
+ const onReady = () => {
329
+ const readyBody = takeBody();
330
+ if (!readyBody) return;
331
+ if (settled) return;
332
+ settled = true;
333
+ pendingIframeLoads.delete(iframe);
334
+ iframe.removeEventListener("load", onReady);
335
+ if (element.hostRef !== "iframe" || !iframe.isConnected) return;
336
+ element.renderHost = readyBody;
337
+ if (!element.isActive) return;
338
+ // @ts-expect-error TODO defineMethods
339
+ if (element._persistedTree) element.renderChildren();
340
+ // @ts-expect-error TODO defineMethods
341
+ else element.attemptLoad();
342
+ };
343
+ iframe.addEventListener("load", onReady);
344
+ queueMicrotask(onReady);
345
+ }
346
+ return { returns: null };
347
+ }
348
+ return { returns: selectOne(ref, { scope: element }) };
349
+ },
350
+ renderChildren: (element, resolved?: Node) => {
351
+ const source = resolved ?? element._persistedTree;
352
+ if (!source) return;
353
+ // Host not ready yet (e.g. iframe still loading). Its load handler
354
+ // retriggers this call.
355
+ if (element.hostRef && !element.renderHost) return;
356
+ const host = (element.renderHost ?? element) as Element;
357
+ const children = [
358
+ element.persistContent
359
+ ? source
360
+ : (document.importNode(source, true) as Element),
361
+ ];
362
+ if (replaceNonTemplateChildren(host, children as Node[])) {
363
+ return [
364
+ {
365
+ tryCompleteReady: ["renderChildren"],
366
+ },
367
+ {
368
+ emits: [["did-render"]],
369
+ },
370
+ ];
371
+ }
372
+ },
373
+ unrenderChildren: (element) => {
374
+ if (element.hostRef && !element.renderHost) return;
375
+ const host = (element.renderHost ?? element) as Element;
376
+ if (replaceNonTemplateChildren(host, [])) {
377
+ return [
378
+ /* Drop the non-persist source; `didLoad` stays so a warm
379
+ re-resolve is available. With `persist-content`, keep
380
+ `_persistedTree` (detached live nodes). */
381
+ !element.persistContent && { _persistedTree: null },
382
+ {
383
+ emits: [["did-unrender"]],
384
+ },
385
+ ];
386
+ }
387
+ },
388
+ // @ts-ignore TODO defineMethods
389
+ startTeardown: ({ isLoading, delayingReady, unrenderChildren }) => {
390
+ return isLoading || delayingReady
391
+ ? [
392
+ { setCanceledState: [] },
393
+ { emit: ["aborted"] },
394
+ { tryCompleteReady: ["startTeardown", "reject"] },
395
+ ]
396
+ : {
397
+ emit: [
398
+ /* Sync, but order still matters for listening parents. */
399
+ "unrender",
400
+ // Sync: do not return a promise
401
+ { detail: unrenderChildren },
402
+ ],
403
+ };
404
+ },
405
+ // @ts-ignore TODO defineMethods
406
+ setupRenderHost: ({ resolveRenderHost }) => ({
407
+ renderHost: resolveRenderHost(),
408
+ }),
409
+ // Clear the current host's children on a `hostRef` retarget so the
410
+ // next paint can retarget without a full teardown.
411
+ clearHostChildren: (element) => {
412
+ const host = (element.renderHost ?? element) as Element;
413
+ if (replaceNonTemplateChildren(host, [])) {
414
+ return {
415
+ emits: [["did-unrender"]],
416
+ };
417
+ }
418
+ },
419
+ attemptLoad: (element, opts: { forceBypassCache?: boolean } = {}) => {
420
+ const { templatePromise, isLoading, _persistedTree } = element;
421
+ return [
422
+ !templatePromise &&
423
+ !isLoading &&
424
+ !_persistedTree && {
425
+ isLoading: true,
426
+ isError: false,
427
+ templatePromise: initTemplatePromise(element, opts),
428
+ },
429
+ ];
430
+ },
431
+ attemptPreFetch: (element, opts: { forceBypassCache?: boolean } = {}) => {
432
+ // @ts-ignore TODO defineMethods
433
+ const { preFetch, isActive, attemptLoad } = element;
434
+ // Eager / empty `preFetch`, or the element is active.
435
+ if (["eager", ""].includes(preFetch as string) || isActive)
436
+ return { attemptLoad: [] };
437
+ else if (preFetch === "idle") {
438
+ const cb = () => {
439
+ /* Re-read `preFetch` off the element; the captured value may be
440
+ stale. `cancelIdleCallback` would be nicer, but Safari lacks it. */
441
+ if (element.preFetch === "idle") {
442
+ attemptLoad(opts);
443
+ }
444
+ };
445
+ // Fallback: wait until after the next paint
446
+ requestIdleCb(cb, 17);
447
+ }
448
+ },
449
+ changeTemplate: (
450
+ { templatePromise, isActive },
451
+ opts: { forceBypassCache?: boolean } = {}
452
+ ) => [
453
+ templatePromise && {
454
+ setCanceledState: [],
455
+ },
456
+ {
457
+ didLoad: false,
458
+ _persistedTree: null,
459
+ isError: false,
460
+ },
461
+ // Bypass the in-memory template cache so reload sees the latest
462
+ // source (matters when `templateRef` is a URL).
463
+ isActive ? { attemptLoad: [opts] } : { attemptPreFetch: [opts] },
464
+ ],
465
+ readyContent: () => ({
466
+ delayingReady: false,
467
+ }),
468
+ // @ts-ignore TODO defineMethods
469
+ startReady: ({ readyOn, readyContent, readyPromiseObject }) => {
470
+ if (readyPromiseObject) return false;
471
+ const newReadyPromiseObject = makePromiseObject();
472
+ if (readyOn) {
473
+ // Ignore abort rejects; `readyContent` only runs on success
474
+ newReadyPromiseObject!.promise.then(readyContent, () => {});
475
+ }
476
+ return [
477
+ readyOn && { delayingReady: true },
478
+ { readyPromiseObject: newReadyPromiseObject },
479
+ ];
480
+ },
481
+ isResponsibleForReady: ({ readyOn }, caller: string | Event) => {
482
+ if (caller === "startTeardown") {
483
+ return { returns: true };
484
+ } else if (readyOn && caller instanceof Event) {
485
+ return { returns: true };
486
+ } else if (!readyOn && caller === "renderChildren") {
487
+ return { returns: true };
488
+ }
489
+ return { returns: false };
490
+ },
491
+ tryCompleteReady: (
492
+ // @ts-ignore TODO defineMethods
493
+ { readyPromiseObject, isResponsibleForReady },
494
+ caller: string | Event,
495
+ method: "reject" | any
496
+ ) => {
497
+ if (!readyPromiseObject || !isResponsibleForReady(caller)) {
498
+ return;
499
+ }
500
+ /* `readyOn` can fire more than once. Pass `"reject"` on mid-flight
501
+ teardown so waiters (e.g. spa-manager) don't treat the abort as ready. */
502
+ if (method === "reject") {
503
+ // Attach before reject so abort isn't an unhandled rejection when
504
+ // no parent is awaiting the ready promise yet
505
+ readyPromiseObject.promise.catch(() => {});
506
+ readyPromiseObject.reject?.();
507
+ } else {
508
+ readyPromiseObject.resolve?.();
509
+ }
510
+ return {
511
+ readyPromiseObject: null,
512
+ };
513
+ },
514
+ // @ts-ignore TODO defineMethods
515
+ renderCallback: ({ isActive, _persistedTree, tryCompleteReady }) =>
516
+ /* Re-check `isActive`: the consumer may call this later, and the
517
+ load may have been canceled (`isActive` false) in the meantime. */
518
+ isActive
519
+ ? [_persistedTree ? { renderChildren: [] } : { attemptLoad: [] }]
520
+ : /* Torn down before a deferred render thunk ran (a parent such as
521
+ `<spa-manager>` batches these). Reject the ready promise rather
522
+ than dropping it: the thunk already handed it to the batch, and
523
+ an unsettled promise would hang the batch until its timeout. */
524
+ { tryCompleteReady: ["startTeardown", "reject"] },
525
+ firePromiseEvent: (
526
+ { readyPromiseObject },
527
+ eventName: string,
528
+ callback: () => void | Promise<void>
529
+ ) => ({
530
+ emit: [
531
+ eventName,
532
+ {
533
+ detail: () => {
534
+ queueMicrotask(() => {
535
+ callback();
536
+ });
537
+ return readyPromiseObject?.promise;
538
+ },
539
+ },
540
+ ],
541
+ }),
542
+ })
543
+ .onPropChanged("readyOn", ({ readyOn, tryCompleteReady }, previous) => [
544
+ previous.readyOn && {
545
+ removeListener: [previous.readyOn, tryCompleteReady],
546
+ },
547
+ readyOn && {
548
+ addListener: [readyOn, tryCompleteReady],
549
+ },
550
+ ])
551
+ .onPropSet("preFetch", () => ({
552
+ attemptPreFetch: [],
553
+ }))
554
+ // Reset when `templateRef` is removed or changed to a different truthy value
555
+ .onPropChanged("templateRef", () => ({
556
+ changeTemplate: [],
557
+ }))
558
+ .onCommand("--reload", () => ({
559
+ changeTemplate: [{ forceBypassCache: true }],
560
+ }))
561
+ /*
562
+ * Retarget when `hostRef` is set, changed, or unset. Order: clear the
563
+ * old host's children (so a kept host, element / shadow / iframe body /
564
+ * portal, isn't left stale), resolve the new host, then re-render.
565
+ *
566
+ * `persist-content` re-places `_persistedTree`. Otherwise `attemptLoad`
567
+ * re-resolves (URL refs hit the shared fetch cache).
568
+ *
569
+ * An unreadied author iframe: `resolveRenderHost` returns null; its
570
+ * load handler paints once the body exists.
571
+ */
572
+ .onPropChanged("hostRef", ({ isActive, _persistedTree }) => [
573
+ /* Clear the previous host (light DOM / shadow / iframe body / portal)
574
+ before resolving the new one. Author iframes stay; only their body
575
+ children are cleared via `renderHost`. */
576
+ isActive && { clearHostChildren: [] },
577
+ { setupRenderHost: [] },
578
+ isActive && (_persistedTree ? { renderChildren: [] } : { attemptLoad: [] }),
579
+ ])
580
+ .onPromiseResolved(
581
+ "templatePromise",
582
+ ({ isActive, persistContent }, result) => [
583
+ {
584
+ // Keep the live tree only when `persist-content` asks. Non-persist
585
+ // paints get `resolved` as a method arg below.
586
+ _persistedTree: persistContent ? result.templatePromise : null,
587
+ templatePromise: null,
588
+ isLoading: false,
589
+ didLoad: true,
590
+ isError: false,
591
+ },
592
+ isActive && { renderChildren: [result.templatePromise] },
593
+ ]
594
+ )
595
+ .onPromiseRejected("templatePromise", ({ localName }, result) => {
596
+ if (result.templatePromise?.name !== "AbortError") {
597
+ KitLogger.error(
598
+ `${localName}: template load failed`,
599
+ result.templatePromise
600
+ );
601
+ return {
602
+ templatePromise: null,
603
+ isLoading: false,
604
+ didLoad: false,
605
+ isError: true,
606
+ emit: ["error"],
607
+ };
608
+ } else {
609
+ KitLogger.debug("templatePromise was aborted");
610
+ }
611
+ })
612
+ .onPropUnset("isActive", () => ({ startTeardown: [] }))
613
+ .onPropSet("isActive", ({ renderCallback }) => [
614
+ // Re-resolve the host on activate so a late-ready author iframe is
615
+ // picked up even if `host-ref` was set before its body existed.
616
+ { setupRenderHost: [] },
617
+ { startReady: [] },
618
+ { firePromiseEvent: ["render", renderCallback] },
619
+ ])
620
+ /* Abort in-flight template work on disconnect so test teardown / SPA
621
+ unmounts don't throw "Failed to find template" after the light-DOM
622
+ source is gone. */
623
+ .onDisconnected(
624
+ ({ templatePromise, isMoving }) =>
625
+ !isMoving &&
626
+ templatePromise && {
627
+ setCanceledState: [],
628
+ }
629
+ )
630
+ .onEventDefault("render", (_, { detail }) => {
631
+ detail();
632
+ })
633
+ .onEventDefault("unrender", (_, { detail }) => {
634
+ detail();
635
+ })
636
+ .onError(({ localName }, error) => {
637
+ KitLogger.error(`${localName}:`, error);
638
+ });