@webjsdev/core 0.7.50 → 0.7.53

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 (64) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +3 -3
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/css.d.ts +9 -0
  16. package/src/escape.d.ts +2 -0
  17. package/src/html.d.ts +9 -0
  18. package/src/render-client/parts.js +2039 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2938
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +938 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2405
  30. package/src/repeat.d.ts +15 -0
  31. package/src/rich-fetch.d.ts +4 -0
  32. package/src/router-client/anchors.js +44 -0
  33. package/src/router-client/boundaries.js +221 -0
  34. package/src/router-client/constants.js +206 -0
  35. package/src/router-client/diagnostics.js +377 -0
  36. package/src/router-client/dom-differ.js +709 -0
  37. package/src/router-client/dom-parse.js +150 -0
  38. package/src/router-client/events.js +182 -0
  39. package/src/router-client/fetch-apply.js +432 -0
  40. package/src/router-client/form-encoder.js +135 -0
  41. package/src/router-client/frames.js +329 -0
  42. package/src/router-client/head-merge.js +344 -0
  43. package/src/router-client/nav-error.js +105 -0
  44. package/src/router-client/navigator.js +952 -0
  45. package/src/router-client/prefetch.js +831 -0
  46. package/src/router-client/scroll.js +308 -0
  47. package/src/router-client/snapshot-cache.js +113 -0
  48. package/src/router-client/state.js +155 -0
  49. package/src/router-client/stream.js +206 -0
  50. package/src/router-client/swap.js +454 -0
  51. package/src/router-client/upgrade.js +261 -0
  52. package/src/router-client/view-transition.js +223 -0
  53. package/src/router-client.d.ts +11 -2
  54. package/src/router-client.js +78 -5345
  55. package/src/slot/assignment.js +304 -0
  56. package/src/slot/interception.js +558 -0
  57. package/src/slot/polyfills.js +365 -0
  58. package/src/slot/project.js +432 -0
  59. package/src/slot/sensors.js +303 -0
  60. package/src/slot/state.js +245 -0
  61. package/src/slot/symbols.js +85 -0
  62. package/src/slot.js +7 -2199
  63. package/src/suspense.d.ts +9 -0
  64. package/src/websocket-client.d.ts +22 -0
@@ -0,0 +1,2039 @@
1
+ import { isTemplate, MARKER } from '../html.js';
2
+ import {
3
+ assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp, isBoundFormAction,
4
+ } from '../form-action.js';
5
+ import { isRepeat } from '../repeat.js';
6
+ import {
7
+ isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch
8
+ } from '../directives.js';
9
+ import { Signal } from '../signal.js';
10
+ import {
11
+ SLOT_FALLBACK_FRAG, SLOT_STATE, SLOT_OWNER, applySlotAssignments, withRendererWrites,
12
+ } from '../slot.js';
13
+ import { compile, templateCache } from './template-compiler.js';
14
+
15
+ /**
16
+ * The container the in-progress `render()` is committing into, or null
17
+ * outside one. Every part created or applied during that render belongs to
18
+ * THIS container's component, which is what an out-of-band commit needs to
19
+ * know to reach the right error boundary. A structural parent walk cannot
20
+ * work it out: `html`<child-el>${watch(sig)}</child-el>`` puts the part in the
21
+ * PARENT's template but inside the child's tag, so the walk meets the child
22
+ * first. Same reason `SLOT_OWNER` exists.
23
+ * @type {any}
24
+ */
25
+ export let currentRenderRoot = null;
26
+
27
+ export function setCurrentRenderRoot(root) {
28
+ currentRenderRoot = root;
29
+ }
30
+
31
+ /**
32
+ * Open the renderer-write window on a light-DOM host while `fn` commits into
33
+ * it, so the host's patched slot-interception methods delegate to native and
34
+ * a renderer commit is never mistaken for authored content. A no-op (just runs
35
+ * `fn`) when `node` is not a slot host, so nested and non-host commits pay
36
+ * nothing. Covers the ASYNC commit paths (async directives, streaming) that
37
+ * run outside a synchronous render() call.
38
+ */
39
+ export function commitInto(node, fn) {
40
+ const host = node && /** @type {any} */ (node)[SLOT_STATE] ? node : null;
41
+ if (!host) return fn();
42
+ return withRendererWrites(host, fn);
43
+ }
44
+
45
+ /**
46
+ * Client-side renderer with **fine-grained** updates.
47
+ *
48
+ * Each TemplateResult is compiled once (keyed by the tagged-template's
49
+ * `strings` array identity, so reuse is free across renders) into:
50
+ * - a `<template>` element with static HTML + marker comments/attributes
51
+ * at each dynamic hole
52
+ * - a list of `Part` descriptors (kind + DOM location + attr/event name)
53
+ *
54
+ * On first render the template is cloned into the container and each Part
55
+ * is bound to the freshly-created node. Subsequent renders compare the new
56
+ * values to the last-applied values and only touch parts that changed.
57
+ * Text-position holes containing nested TemplateResults reuse the existing
58
+ * child instance when the inner `strings` match; they only rebuild when the
59
+ * template shape changes.
60
+ *
61
+ * Consequences worth knowing:
62
+ * - Input focus, cursor position, selection, and scroll inside components
63
+ * survive re-renders triggered by property assignments, signal changes,
64
+ * or `requestUpdate()`.
65
+ * - Event listeners are attached once and retargeted when the handler
66
+ * reference changes (swap-in-place via a dispatch closure, so `addEventListener`
67
+ * isn't churned every render).
68
+ * - A plain `.map()` array reconciles POSITIONALLY (non-keyed), matching
69
+ * lit-html: each index updates its item instance in place when the
70
+ * template shape is unchanged, so DOM node identity (and the focus,
71
+ * selection, scroll, and in-progress native drag it carries) survives an
72
+ * item-level update. See `reconcileArray`. Keyed reordering still needs
73
+ * the `repeat()` directive.
74
+ */
75
+
76
+ /**
77
+ * One `method` / `enctype` hole on a candidate bound form. `statics` / `group`
78
+ * are carried for a mixed attribute, whose value is the concatenation of its
79
+ * static pieces and every one of its holes.
80
+ *
81
+ * @typedef {{ i: number, kind: string, statics?: string[], group?: number[] }} FormAttrPart
82
+ */
83
+
84
+ /**
85
+ * What one `<form action=${...}>` in a template needs at reconcile time (#1155).
86
+ * Every field is a property of the TEMPLATE, computed once per template literal
87
+ * call site, so it cannot drift out of step with the live DOM.
88
+ *
89
+ * @typedef {{
90
+ * actionIdxs: number[],
91
+ * duplicateAction: boolean,
92
+ * staticAction: boolean,
93
+ * authoredName: boolean,
94
+ * authoredValue: boolean,
95
+ * authoredForm: boolean,
96
+ * nameParts: {i: number, kind: string}[],
97
+ * valueParts: {i: number, kind: string}[],
98
+ * propAttrs: string[],
99
+ * staticMethod: string | null,
100
+ * staticEnctype: string | null,
101
+ * methodParts: FormAttrPart[],
102
+ * enctypeParts: FormAttrPart[],
103
+ * }} FormActionRecord
104
+ */
105
+
106
+ /**
107
+ * The element carrying the error boundary for a render root. A ShadowRoot has
108
+ * no boundary of its own; its `.host` is the component.
109
+ * @param {any} root
110
+ * @returns {any}
111
+ */
112
+ export function boundaryOwnerOf(root) {
113
+ if (!root) return null;
114
+ if (root.nodeType === 11 && root.host) return root.host;
115
+ return root;
116
+ }
117
+
118
+ /**
119
+ * Run an OUT-OF-BAND commit (a `watch` notify microtask, an `until`
120
+ * resolution) with the part's owner installed as the current render root.
121
+ *
122
+ * Without this the commit runs with no owner in scope, so any `watch` /
123
+ * `until` nested INSIDE the template it commits is installed unstamped and
124
+ * its own later throw escapes to the window. A directive nested in that
125
+ * template belongs to the same component as the part committing it, which is
126
+ * exactly the owner already recorded here.
127
+ *
128
+ * @param {Extract<BoundPart, {kind:'child'}>} part
129
+ * @param {() => void} fn
130
+ */
131
+ export function commitOutOfBand(part, fn) {
132
+ const owner = /** @type any */ (part).__commitOwner;
133
+ const prev = currentRenderRoot;
134
+ if (owner) currentRenderRoot = owner;
135
+ try {
136
+ fn();
137
+ } finally {
138
+ currentRenderRoot = prev;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * @param {PartDescriptor} p
144
+ * @param {DocumentFragment | Element} root
145
+ * @returns {BoundPart}
146
+ */
147
+ export function bindPart(p, root) {
148
+ if (p.kind === 'noop') return /** @type any */ ({ kind: 'noop', mixedAnchor: /** @type any */ (p).mixedAnchor });
149
+ let node = /** @type Node */ (root);
150
+ for (const i of p.path) node = node.childNodes[i];
151
+ if (p.kind === 'child') {
152
+ return { kind: 'child', marker: /** @type Comment */ (node) };
153
+ }
154
+ const el = /** @type Element */ (node);
155
+ if (p.kind === 'event') {
156
+ /** @type {BoundPart} */
157
+ const part = {
158
+ kind: 'event',
159
+ el,
160
+ name: p.name || '',
161
+ handler: null,
162
+ // The dispatcher is the registered listener; handler swaps behind it.
163
+ dispatcher(ev) { part.handler?.(ev); },
164
+ };
165
+ el.addEventListener(part.name, part.dispatcher);
166
+ return part;
167
+ }
168
+ if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
169
+ if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
170
+ if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
171
+ if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
172
+ if (p.kind === 'element') return { kind: 'element', el };
173
+ if (p.kind === 'slot') {
174
+ const slotEl = /** @type {HTMLSlotElement} */ (el);
175
+ // Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
176
+ // time so we know whether the slot is light or shadow at the point
177
+ // where the decision matters. At bind time the cloned slot still
178
+ // holds its fallback content from the template clone; we just
179
+ // record the slot ref.
180
+ return { kind: 'slot', slotEl, applied: false };
181
+ }
182
+ throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
183
+ }
184
+
185
+ /**
186
+ * @param {BoundPart} part
187
+ * @param {unknown} value
188
+ * @param {unknown} _prev
189
+ */
190
+ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb) {
191
+ // Unwrap live() to dirty-check against the live DOM value, not the
192
+ // last rendered value. Essential for <input> two-way binding.
193
+ if (isLive(value)) {
194
+ const liveVal = /** @type any */ (value).value;
195
+ if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
196
+ if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
197
+ if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
198
+ value = liveVal;
199
+ }
200
+
201
+ switch (part.kind) {
202
+ case 'child':
203
+ applyChild(part, value, reconcileFormActionsCb);
204
+ break;
205
+ case 'attr': {
206
+ if (value == null || value === false) part.el.removeAttribute(part.name);
207
+ else if (isBoundFormAction(value, part.name, part.el.localName)) {
208
+ // #1155: the ONE supported form-action binding, applied to the live
209
+ // form exactly as SSR wrote it. A component that ships re-renders its
210
+ // whole template on hydration, so without this the SSR'd hidden field
211
+ // would be replaced by an `action` attribute holding a stringified
212
+ // function, and the form would post to a garbage url.
213
+ //
214
+ // Nothing happens here. The whole decision (identity, the submit
215
+ // attributes, the hidden field) is made at the end of the pass by
216
+ // `reconcileFormAction`, because it depends on holes that have not
217
+ // committed yet when this one does.
218
+ } else {
219
+ // #1154: refuse to stringify a function into action=/formaction=
220
+ // (mirrors the SSR guard, so a client re-render cannot write a
221
+ // server action's source into the live DOM).
222
+ assertNotFunctionActionAttr(value, part.name, part.el.localName);
223
+ part.el.setAttribute(part.name, String(value));
224
+ }
225
+ break;
226
+ }
227
+ case 'prop':
228
+ // `.action=${fn}` is a leak too, not just the attribute form, but only
229
+ // where the property is a REFLECTED IDL attribute: assigning a function
230
+ // to `form.action` (or `.formAction` on a button/input) stringifies it
231
+ // into that element's own content attribute in a real browser. On any
232
+ // other native tag it is a plain expando that reflects nothing, and on a
233
+ // custom element it is an author-defined property, so a function is
234
+ // legitimate in both. The helper owns that distinction.
235
+ //
236
+ // Not covered here, because it is not a commit: a custom element's prop
237
+ // declared `reflect: true` reflects from its own setter. That path used
238
+ // to write String(value) and leak the source. #1169 made it remove the
239
+ // attribute instead (an array carrying one included, via the same
240
+ // `carriesFunction` predicate this file's guard uses), so it is guarded
241
+ // at the setter rather than here. One carve-out stays the author's call:
242
+ // a prop supplying its own `converter.toAttribute` runs that converter
243
+ // first and is left alone, so it still writes whatever the author
244
+ // returns for a function.
245
+ assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
246
+ /** @type any */ (part.el)[part.name] = value;
247
+ break;
248
+ case 'bool':
249
+ // #1154: never leaked (a boolean binding stringifies nothing), but
250
+ // `?action=${fn}` is meaningless in every case, and refusing it keeps
251
+ // this renderer agreeing with both SSR machines.
252
+ assertNotFunctionActionAttr(value, part.name, part.el.localName);
253
+ if (value) part.el.setAttribute(part.name, '');
254
+ else part.el.removeAttribute(part.name);
255
+ break;
256
+ case 'event':
257
+ // NOT guarded, deliberately. An event binding never stringifies its
258
+ // value, and a function is the legitimate thing to pass one, so
259
+ // `<my-el @action=${handler}>` has to keep working.
260
+ part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
261
+ break;
262
+ case 'element':
263
+ applyElement(part, value);
264
+ break;
265
+ case 'attr-mixed': {
266
+ // Reconstruct the attribute from static pieces + all dynamic values.
267
+ const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
268
+ let val = mp.statics[0];
269
+ for (let j = 0; j < mp.group.length; j++) {
270
+ let piece = allValues ? allValues[mp.group[j]] : value;
271
+ // Unwrap live() PER PIECE (#1443). The top-of-function unwrap only
272
+ // sees the anchor hole's own value, and every hole inside a QUOTED
273
+ // attribute lands here (single-hole included, the compiler classifies
274
+ // them all attr-mixed), so without this a `title="${live(v)}"` the
275
+ // server now emits correctly would be rewritten to the wrapper's
276
+ // stringification on the next client commit. Mirrors the reconciler's
277
+ // effectiveFormAttr, which already unwraps each group piece.
278
+ if (isLive(piece)) piece = /** @type any */ (piece).value;
279
+ // #1154: same function guard for each piece of a mixed attribute.
280
+ assertNotFunctionActionAttr(piece, part.name, part.el.localName);
281
+ val += String(piece ?? '');
282
+ val += mp.statics[j + 1] || '';
283
+ }
284
+ part.el.setAttribute(part.name, val);
285
+ break;
286
+ }
287
+ case 'slot': {
288
+ // Slot parts have no template-hole value to apply. The "apply" is
289
+ // a one-shot trigger that runs after the template fragment has
290
+ // been inserted into the host's render root. At this point the
291
+ // slot's parent chain reveals whether it lives inside a shadow
292
+ // root (browser native projection) or in light DOM (framework
293
+ // projection). For shadow-DOM slots we leave the cloned fallback
294
+ // in place. For light-DOM slots we move the fallback into a
295
+ // per-slot holding fragment owned by slot.js (via the
296
+ // SLOT_FALLBACK_FRAG symbol) so the slot can receive projected
297
+ // children, and we kick off projection.
298
+ //
299
+ // For NESTED templates (a slot inside `${cond ? html`<slot/>` : ''}`),
300
+ // the slot's parent chain at first apply may still lead through
301
+ // an unattached fragment. findSlotHost returns null then; we
302
+ // retry on the next microtask, by which point the outer's
303
+ // replaceChildren has placed the entire tree into the host.
304
+ if (part.applied) break;
305
+ const slotEl = part.slotEl;
306
+ const finalize = () => {
307
+ const host = findSlotHost(slotEl);
308
+ if (!host) {
309
+ part.applied = true;
310
+ return;
311
+ }
312
+ // A FORWARDED slot's owner is known immediately (the SLOT_OWNER
313
+ // stamp) but the child component has not yet PLACED the slot into
314
+ // the owner's subtree, so the owner's apply cannot find it yet.
315
+ // Re-defer until the child places it (host.contains becomes true);
316
+ // the child WILL place it, or the owner disconnects and the retries
317
+ // stop. A normal own slot is already inside its host, so this passes
318
+ // on the first call with no extra deferral.
319
+ if (!host.contains(slotEl)) {
320
+ if (host.isConnected) queueMicrotask(finalize);
321
+ else part.applied = true;
322
+ return;
323
+ }
324
+ part.applied = true;
325
+ // Shadow DOM: native projection. Leave fallback in place.
326
+ if (isInShadowRootEl(slotEl)) return;
327
+ // Light DOM: harvest the cloned fallback into a holding
328
+ // fragment, then place the host's slot record (#1015). The
329
+ // application is deferred one microtask so the documented
330
+ // lifecycle timing holds: firstUpdated() sees the <slot>
331
+ // element itself, the populated content lands right after.
332
+ const frag = document.createDocumentFragment();
333
+ while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
334
+ /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
335
+ queueMicrotask(() => applySlotAssignments(host));
336
+ };
337
+ const directHost = findSlotHost(slotEl);
338
+ if (directHost && directHost.contains(slotEl)) {
339
+ finalize();
340
+ } else {
341
+ queueMicrotask(finalize);
342
+ }
343
+ break;
344
+ }
345
+ case 'noop':
346
+ // intentionally empty: used for holes inside HTML comments
347
+ break;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Walk a slot element's parent chain looking for a WebComponent host
353
+ * (an element that has slot state initialised). Used by the slot-part's
354
+ * apply and teardown steps to coordinate with slot.js.
355
+ *
356
+ * @param {HTMLSlotElement} slotEl
357
+ * @returns {Element | null}
358
+ */
359
+ export function findSlotHost(slotEl) {
360
+ // Template-owner stamp wins (a forwarded slot's true host), else the
361
+ // nearest SLOT_STATE ancestor structurally.
362
+ const owner = /** @type any */ (slotEl)[SLOT_OWNER];
363
+ if (owner && owner.isConnected) return owner;
364
+ let p = slotEl.parentElement;
365
+ while (p) {
366
+ if (/** @type any */ (p)[SLOT_STATE]) return p;
367
+ p = p.parentElement;
368
+ }
369
+ return null;
370
+ }
371
+
372
+ /**
373
+ * True when an element is inside a shadow root (so native browser slot
374
+ * projection applies). Mirrors slot.js's helper; duplicated here to
375
+ * avoid the round trip through the slot.js public surface for this
376
+ * hot path.
377
+ * @param {Element} el
378
+ * @returns {boolean}
379
+ */
380
+ export function isInShadowRootEl(el) {
381
+ let n = /** @type {Node} */ (el);
382
+ for (let depth = 0; depth < 128; depth++) {
383
+ const parent = n.parentNode;
384
+ if (!parent) return false;
385
+ if (parent === n) return false;
386
+ // A real ShadowRoot is a DocumentFragment (nodeType 11) exposing its
387
+ // owner as `.host`. Checking `.host` truthiness ALONE misfires on
388
+ // ordinary elements: HTMLAnchorElement/HTMLAreaElement expose a
389
+ // URL-derived `.host` ('example.com'), so a slot nested inside an
390
+ // <a> card was misread as shadow DOM and its light-DOM application
391
+ // silently skipped (surfaced by #1015's removal of the redundant
392
+ // observer repair paths that used to mask it).
393
+ if (parent.nodeType === 11 && /** @type any */ (parent).host) return true;
394
+ n = parent;
395
+ }
396
+ return false;
397
+ }
398
+
399
+ /**
400
+ * Child (text-position) part. Replace the marker's surrounding nodes with the
401
+ * new value's rendered form. Nested TemplateResults get an instance with its
402
+ * own parts; we reuse on `strings` identity.
403
+ *
404
+ * @param {Extract<BoundPart, {kind:'child'}>} part
405
+ * @param {unknown} value
406
+ */
407
+ /**
408
+ * Apply a value at an element-position part (`<tag ${expr}>`). The
409
+ * sole supported directive here is `ref(refOrCallback)` and
410
+ * `createRef()`. Other values are ignored so a stray non-ref hole
411
+ * doesn't crash. Tracks the prior target so a change from one ref to
412
+ * another correctly unsets the old target before binding the new one.
413
+ *
414
+ * @param {Extract<BoundPart, {kind:'element'}>} part
415
+ * @param {unknown} value
416
+ */
417
+ export function applyElement(part, value) {
418
+ // Matches lit-html's RefDirective.update():
419
+ // 1. If the ref target changed since last render, unbind the prior one.
420
+ // 2. If the ref target OR the element identity changed, bind the new
421
+ // (ref, element) pair. If both are stable, skip entirely.
422
+ // For callback refs, an unset-before-bind cycle runs whenever the
423
+ // same callback is now pointing at a different element.
424
+ const partAny = /** @type any */ (part);
425
+ const nextTarget = isRef(value) ? /** @type any */ (value).target : undefined;
426
+ const prevTarget = partAny.__refTarget;
427
+ const refChanged = nextTarget !== prevTarget;
428
+
429
+ if (refChanged && prevTarget) {
430
+ if (typeof prevTarget === 'function') {
431
+ try { prevTarget(undefined); } catch {}
432
+ } else if (typeof prevTarget === 'object') {
433
+ prevTarget.value = undefined;
434
+ }
435
+ }
436
+
437
+ if (refChanged || partAny.__refElement !== part.el) {
438
+ partAny.__refTarget = nextTarget;
439
+ if (nextTarget) {
440
+ if (typeof nextTarget === 'function') {
441
+ // Same callback now pointing at a different element: deliver
442
+ // an `undefined` cleanup for the prior element first.
443
+ if (!refChanged && partAny.__refElement !== undefined) {
444
+ try { nextTarget(undefined); } catch {}
445
+ }
446
+ try { nextTarget(part.el); } catch {}
447
+ } else if (typeof nextTarget === 'object') {
448
+ nextTarget.value = part.el;
449
+ }
450
+ }
451
+ partAny.__refElement = part.el;
452
+ // Keep the legacy `lastTarget` field in sync for clearInstance /
453
+ // disposeInstance which read it for template-disposal cleanup.
454
+ part.lastTarget = nextTarget;
455
+ }
456
+ }
457
+
458
+ export function applyChild(part, value, reconcileFormActionsCb) {
459
+ // Drop directive state from prior renders when the new value is for a
460
+ // different directive (or no directive at all). Keeps __untilState
461
+ // from leaking across replacements, __guardDeps from causing a stale
462
+ // short-circuit, etc. Done once per outermost applyChild call; the
463
+ // directive handlers recurse via applyChildInner (no re-clear) so
464
+ // their own state survives the recursion.
465
+ clearStaleDirectiveState(part, value);
466
+ return applyChildInner(part, value, reconcileFormActionsCb);
467
+ }
468
+
469
+ /**
470
+ * Internal dispatch. Used both by `applyChild` (which first clears
471
+ * stale per-part directive state) and by directive handlers that
472
+ * recurse with a different value at the same part. Recursing via
473
+ * `applyChild` would clear the directive state that was just set,
474
+ * because the inner value almost always isn't itself a directive.
475
+ *
476
+ * @param {Extract<BoundPart, {kind:'child'}>} part
477
+ * @param {unknown} value
478
+ */
479
+ export function applyChildInner(part, value, reconcileFormActionsCb) {
480
+ // Open the renderer-write window around this commit. Most calls are
481
+ // synchronous inside render() (the window is already open, this nests
482
+ // harmlessly), but the async directive paths (until, watch, asyncAppend /
483
+ // asyncReplace, streaming) re-enter here from a promise / microtask OUTSIDE
484
+ // any render() window, so this is where those commits into a light host are
485
+ // marked as renderer writes rather than authored content.
486
+ return commitInto(part.marker && part.marker.parentNode, () =>
487
+ applyChildInnerRaw(part, value, reconcileFormActionsCb),
488
+ );
489
+ }
490
+
491
+ /**
492
+ * @param {Extract<BoundPart, {kind:'child'}>} part
493
+ * @param {unknown} value
494
+ */
495
+ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
496
+ const marker = part.marker;
497
+
498
+ // live() in a CHILD position (#1443). applyPart's top-of-function unwrap only
499
+ // sees the hole's own value, so a live() nested inside an ARRAY child arrives
500
+ // here still wrapped and would stringify to "[object Object]". The server
501
+ // recurses through isLive in both machines (template-renderer.js render() and
502
+ // streamRender()), so without this the renderers disagree and an SSR'd list
503
+ // built with live() per item corrupts on upgrade. A plain unwrap is the whole
504
+ // job here: live()'s dirty-check is attribute/property-shaped and has no
505
+ // meaning for a child, which is exactly why the server treats it the same way.
506
+ // Recursing first (rather than after the unsafeHTML branch) matches the
507
+ // server's outcome for a nested directive such as live(unsafeHTML(x)).
508
+ if (isLive(value)) {
509
+ return applyChildInnerRaw(part, /** @type any */ (value).value, reconcileFormActionsCb);
510
+ }
511
+
512
+ // unsafeHTML directive: inject raw HTML string as DOM nodes.
513
+ if (isUnsafeHTML(value)) {
514
+ teardownChild(part);
515
+ const htmlStr = String(/** @type any */ (value).value ?? '');
516
+ const template = document.createElement('template');
517
+ template.innerHTML = htmlStr;
518
+ const nodes = [...template.content.childNodes];
519
+ const frag = document.createDocumentFragment();
520
+ for (const n of nodes) frag.appendChild(n);
521
+ marker.parentNode?.insertBefore(frag, marker);
522
+ part.child = nodes;
523
+ return;
524
+ }
525
+
526
+ // keyed directive: when key changes, tear down and remount fresh DOM.
527
+ // When the key matches, recurse so the standard template-reconciliation
528
+ // path can update the existing DOM in place.
529
+ if (isKeyed(value)) {
530
+ const v = /** @type any */ (value);
531
+ const prevKey = /** @type any */ (part).__keyedKey;
532
+ if (prevKey !== undefined && !Object.is(prevKey, v.key)) {
533
+ teardownChild(part);
534
+ }
535
+ /** @type any */ (part).__keyedKey = v.key;
536
+ applyChildInner(part, v.value, reconcileFormActionsCb);
537
+ return;
538
+ }
539
+
540
+ // guard directive: skip re-evaluation when the deps array is shallow-
541
+ // equal to the prior call. Stored deps live on the part so they
542
+ // persist across renders that reuse the same template (and thus the
543
+ // same part).
544
+ if (isGuard(value)) {
545
+ const v = /** @type any */ (value);
546
+ const prevDeps = /** @type any */ (part).__guardDeps;
547
+ const nextDeps = v.deps;
548
+ // Accept any value for deps. When deps is an array, compare shallowly.
549
+ // When it's a primitive (number, string, undefined), compare with
550
+ // Object.is. Mirrors lit-html's tolerance for non-array deps so user
551
+ // code like `guard(this.id, () => ...)` works without crashing.
552
+ if (prevDeps !== undefined) {
553
+ const equal = Array.isArray(prevDeps) && Array.isArray(nextDeps)
554
+ ? shallowEqualArray(prevDeps, nextDeps)
555
+ : Object.is(prevDeps, nextDeps);
556
+ if (equal) return;
557
+ }
558
+ // Snapshot the deps BEFORE running `fn` (so a fn that mutates the array
559
+ // it was handed cannot rewrite what we record), but RECORD them only
560
+ // after the commit succeeds. Recording first meant a throw part-way
561
+ // through the commit left the region torn down with the NEW deps already
562
+ // stored, so every later render with the same deps hit the `equal`
563
+ // short-circuit above and skipped the region forever. Renders are always
564
+ // microtask-scheduled (`_scheduleUpdate`), so `fn()` cannot re-enter this
565
+ // part synchronously and see the stale deps.
566
+ const depsSnapshot = Array.isArray(nextDeps) ? nextDeps.slice() : nextDeps;
567
+ applyChildInner(part, v.fn(), reconcileFormActionsCb);
568
+ /** @type any */ (part).__guardDeps = depsSnapshot;
569
+ return;
570
+ }
571
+
572
+ // templateContent directive: clone the content of a <template> element.
573
+ if (isTemplateContent(value)) {
574
+ teardownChild(part);
575
+ const tpl = /** @type any */ (value).template;
576
+ if (tpl && tpl.content) {
577
+ const frag = tpl.content.cloneNode(true);
578
+ const nodes = [...frag.childNodes];
579
+ marker.parentNode?.insertBefore(frag, marker);
580
+ part.child = nodes;
581
+ }
582
+ return;
583
+ }
584
+
585
+ // ref directive in a child position: no DOM produced. Element-position
586
+ // refs are bound via element parts; a stray ref() in a child position
587
+ // is a no-op for compatibility.
588
+ if (isRef(value)) {
589
+ return;
590
+ }
591
+
592
+ // cache directive: real DOM retention. When the inner value changes
593
+ // to a different template shape, detach (rather than destroy) the
594
+ // current DOM and stash it keyed by its template strings. When a
595
+ // previously-cached shape returns, re-attach it before the marker
596
+ // and reconcile values. Preserves input state, scroll, focus across
597
+ // toggles between sub-templates (e.g. tab interfaces).
598
+ if (isCache(value)) {
599
+ return applyCache(part, /** @type any */ (value).value, reconcileFormActionsCb);
600
+ }
601
+
602
+ // until directive: render the highest-priority resolved value among
603
+ // the candidates. Synchronous values are rendered immediately; Promises
604
+ // are awaited in the background and applied if no higher-priority
605
+ // candidate has resolved yet. When the marker is torn down, in-flight
606
+ // priorities are cleared so late resolves cannot overwrite later DOM.
607
+ if (isUntil(value)) {
608
+ return applyUntil(part, /** @type any */ (value).args, reconcileFormActionsCb);
609
+ }
610
+
611
+ // watch directive: bind a part to a signal. Reads the signal at
612
+ // render time and subscribes the part to changes. When the signal
613
+ // fires, only this part updates; the host component's render() does
614
+ // not re-run. The signal read inside the watcher's observe is
615
+ // tracked against the part's private Watcher, NOT the host's render
616
+ // watcher (so the host doesn't subscribe to a full re-render too).
617
+ if (isWatch(value)) {
618
+ return applyWatch(part, /** @type any */ (value).signal, reconcileFormActionsCb);
619
+ }
620
+
621
+ // asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
622
+ // yielded value is mapped (optional) and appended (asyncAppend) or
623
+ // replaces (asyncReplace) the prior content. Teardown aborts the
624
+ // iteration so leaked iterators do not keep references to detached
625
+ // DOM.
626
+ if (isAsyncAppend(value)) {
627
+ return applyAsyncAppend(part, /** @type any */ (value), reconcileFormActionsCb);
628
+ }
629
+ if (isAsyncReplace(value)) {
630
+ return applyAsyncReplace(part, /** @type any */ (value), reconcileFormActionsCb);
631
+ }
632
+
633
+ // Repeat directive: keyed reconciliation. Keep previous state when both
634
+ // old and new are repeats; otherwise tear down and rebuild.
635
+ if (isRepeat(value)) {
636
+ if (part.child && /** @type any */ (part.child).kind === 'repeat') {
637
+ reconcileRepeat(part, value, reconcileFormActionsCb);
638
+ return;
639
+ }
640
+ teardownChild(part);
641
+ const state = { kind: 'repeat', map: new Map() };
642
+ part.child = state;
643
+ applyRepeatFresh(marker, state, value, reconcileFormActionsCb);
644
+ return;
645
+ }
646
+
647
+ // Plain array (a `.map()` / list interpolation): positional, non-keyed
648
+ // reconciliation. Update each position's instance IN PLACE when its
649
+ // template shape is unchanged, so DOM node identity survives an item
650
+ // update (focus, selection, scroll, and an in-progress native drag all
651
+ // survive). Mirrors lit-html's non-keyed array child part. Without this,
652
+ // flipping one item's binding rebuilt the WHOLE list and detached every
653
+ // node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
654
+ // reordering.
655
+ if (Array.isArray(value)) {
656
+ if (part.child && /** @type any */ (part.child).kind === 'array') {
657
+ reconcileArray(part, value, reconcileFormActionsCb);
658
+ return;
659
+ }
660
+ teardownChild(part);
661
+ const arrState = { kind: 'array', items: [] };
662
+ part.child = arrState;
663
+ applyArrayFresh(marker, /** @type any */ (arrState), value, reconcileFormActionsCb);
664
+ return;
665
+ }
666
+
667
+ // Remove previously rendered nodes between marker and its next sibling we own.
668
+ if (part.child) {
669
+ const c = /** @type any */ (part.child);
670
+ if (c.kind === 'repeat') {
671
+ teardownRepeat(c);
672
+ part.child = undefined;
673
+ } else if (c.kind === 'array') {
674
+ teardownArray(/** @type any */ (c));
675
+ part.child = undefined;
676
+ } else if (c.kind === 'async-stream') {
677
+ teardownAsyncStream(c);
678
+ part.child = undefined;
679
+ } else if ('strings' in /** @type any */ (part.child)) {
680
+ // Previous was a TemplateInstance.
681
+ const inst = /** @type TemplateInstance */ (part.child);
682
+ if (isTemplate(value) && inst.strings === /** @type any */ (value).strings) {
683
+ updateInstance(inst, /** @type any */ (value).values, reconcileFormActionsCb);
684
+ return;
685
+ }
686
+ removeBetween(inst.startNode, inst.endNode);
687
+ part.child = undefined;
688
+ } else {
689
+ // Previous was ChildNode[]: remove each node we inserted.
690
+ for (const n of /** @type ChildNode[] */ (part.child)) {
691
+ if (n.parentNode) n.parentNode.removeChild(n);
692
+ }
693
+ part.child = undefined;
694
+ }
695
+ }
696
+
697
+ if (value == null || value === false || value === true) return;
698
+
699
+ if (isTemplate(value)) {
700
+ const tr = /** @type any */ (value);
701
+ const { templateEl, parts, formActions } = compile(tr);
702
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
703
+ const startNode = document.createComment(`${MARKER}s`);
704
+ const endNode = document.createComment(`${MARKER}e`);
705
+ const bound = parts.map((p) => bindPart(p, frag));
706
+ const lastValues = [];
707
+ for (let i = 0; i < tr.values.length; i++) {
708
+ applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActionsCb);
709
+ lastValues.push(tr.values[i]);
710
+ }
711
+ if (reconcileFormActionsCb) reconcileFormActionsCb(formActions, bound, tr.values);
712
+ const nodes = [startNode, ...frag.childNodes, endNode];
713
+ marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
714
+ // Slot parts in this nested template need their one-shot apply just
715
+ // like createInstance does for top-level templates. The slot is now
716
+ // in the live tree (insertBefore above) so its parent walk can
717
+ // reach the host. Without this loop, conditional / nested templates
718
+ // with <slot> inside never trigger projection.
719
+ for (const p of bound) {
720
+ if (p.kind === 'slot') applyPart(p, undefined, undefined, [], reconcileFormActionsCb);
721
+ }
722
+ part.child = { strings: tr.strings, bound, lastValues, startNode, endNode };
723
+ return;
724
+ }
725
+
726
+ const node = document.createTextNode(String(value));
727
+ marker.parentNode?.insertBefore(node, marker);
728
+ part.child = [node];
729
+ }
730
+
731
+ export function updateInstance(inst, values, reconcileFormActionsCb) {
732
+ for (let i = 0; i < values.length; i++) {
733
+ const next = values[i];
734
+ if (Object.is(next, inst.lastValues[i])) continue;
735
+ const bp = inst.bound[i];
736
+ // A hole that belongs to a mixed attribute (`class="a ${x} b ${y}"`) is a
737
+ // `noop` pointing at the attribute's anchor part; re-apply the anchor so the
738
+ // whole attribute is rebuilt from every hole's current value, not just the
739
+ // anchor hole's. Without this, a change confined to a non-anchor hole is
740
+ // dropped (the attribute goes stale).
741
+ const anchor = /** @type any */ (bp).mixedAnchor;
742
+ try {
743
+ if (bp.kind === 'noop' && anchor != null) {
744
+ applyPart(inst.bound[anchor], values[anchor], inst.lastValues[anchor], values, reconcileFormActionsCb);
745
+ } else {
746
+ applyPart(bp, next, inst.lastValues[i], values, reconcileFormActionsCb);
747
+ }
748
+ } catch (err) {
749
+ // A commit that throws leaves `lastValues` for THIS hole un-advanced,
750
+ // still holding the value from before the throw. That is almost always
751
+ // the value the recovering render supplies, so the `Object.is` skip at
752
+ // the top of this loop would skip the hole FOREVER. Harmless for an
753
+ // attribute (its commit stringifies before touching the DOM, so nothing
754
+ // changed), and permanently destructive for a child position, whose
755
+ // commit tears the old content down BEFORE the step that throws: the
756
+ // region is left empty and never re-rendered.
757
+ //
758
+ // Poison the entry with a sentinel no author value can ever be, so the
759
+ // next render always re-applies this hole. The value is deliberately
760
+ // NOT advanced to `next` either, since `next` was never committed.
761
+ inst.lastValues[i] = COMMIT_FAILED;
762
+ if (bp.kind === 'noop' && anchor != null) inst.lastValues[anchor] = COMMIT_FAILED;
763
+ throw err;
764
+ }
765
+ inst.lastValues[i] = next;
766
+ }
767
+ // Unconditional, and NOT inside the loop above: a form's correctness depends
768
+ // on holes other than its own, and the `Object.is` skip means the action hole
769
+ // may not have been re-applied at all this pass.
770
+ if (reconcileFormActionsCb) reconcileFormActionsCb(templateCache.get(inst.strings)?.formActions ?? null, inst.bound, values);
771
+ }
772
+
773
+ /**
774
+ * Sentinel parked in `lastValues` for a hole whose commit threw, so the next
775
+ * render cannot mistake the un-advanced entry for "already applied". Never
776
+ * equal (by `Object.is`) to anything an author can pass through a template.
777
+ */
778
+ const COMMIT_FAILED = Symbol('webjs.commitFailed');
779
+
780
+ /**
781
+ * Remove a template instance's whole range, its bookend markers INCLUDED.
782
+ *
783
+ * Every caller discards the instance right after: the map entry or slot that
784
+ * held it is dropped, and a replacement, where there is one, is built with
785
+ * markers of its own. So this is a REMOVE, never lit's clear-and-reuse. A
786
+ * caller that wants to keep the bookends and render into them again needs its
787
+ * OWN function, because the two want opposite answers for the end marker.
788
+ *
789
+ * `parent` is read BEFORE the walk because the walk removes `start` on its
790
+ * first iteration, which nulls `start.parentNode`. Reading it afterwards
791
+ * compared the end marker's live parent against `null`, so the guard could
792
+ * never fire and every teardown left one `wjm-e` comment in the document,
793
+ * unbounded for the life of the region.
794
+ *
795
+ * The `end.parentNode === parent` comparison is a refusal, not a formality. A
796
+ * marker moved under a different parent is not this region's to remove, and
797
+ * `parent.removeChild(end)` on it throws NotFoundError from inside a teardown
798
+ * that has to stay total.
799
+ *
800
+ * The walk assumes the range is INTACT: it steps `nextSibling` from `start`
801
+ * and stops on `end`, so a range whose end no longer follows its start runs
802
+ * off the child list and takes the part's own marker with it. That is not
803
+ * something this function defends against, before or after the parent capture,
804
+ * and the consequence is spelled out where it bites, on `reconcileRepeat`'s
805
+ * catch below. The guard is the narrower promise: whatever the walk did, a
806
+ * marker that is somewhere else is left alone.
807
+ *
808
+ * @param {Node} start @param {Node} end
809
+ */
810
+ export function removeBetween(start, end) {
811
+ const parent = start.parentNode;
812
+ if (!parent) return;
813
+ let n = start;
814
+ while (n && n !== end) {
815
+ const next = n.nextSibling;
816
+ n.parentNode?.removeChild(n);
817
+ n = next;
818
+ }
819
+ if (end.parentNode === parent) parent.removeChild(end);
820
+ }
821
+
822
+ /* ================================================================
823
+ * Keyed list (repeat) support
824
+ * ================================================================ */
825
+
826
+ /** @param {ChildNode[]} nodes */
827
+ export function nodesToFrag(nodes) {
828
+ const frag = document.createDocumentFragment();
829
+ for (const n of nodes) frag.appendChild(n);
830
+ return frag;
831
+ }
832
+
833
+ /**
834
+ * Build a TemplateInstance whose nodes (including bookends) live in a
835
+ * document fragment that the caller will insert wherever it wants.
836
+ * @param {import('../html.js').TemplateResult} tr
837
+ * @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
838
+ */
839
+ export function buildDetached(tr, reconcileFormActionsCb) {
840
+ const { templateEl, parts, formActions } = compile(tr);
841
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
842
+ const startNode = document.createComment(`${MARKER}s`);
843
+ const endNode = document.createComment(`${MARKER}e`);
844
+ const bound = parts.map((p) => bindPart(p, frag));
845
+ const lastValues = [];
846
+ for (let i = 0; i < tr.values.length; i++) {
847
+ applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActionsCb);
848
+ lastValues.push(tr.values[i]);
849
+ }
850
+ if (reconcileFormActionsCb) reconcileFormActionsCb(formActions, bound, tr.values);
851
+ // Slot parts need their one-shot apply exactly like createInstance and the
852
+ // nested-template path. The fragment is still detached here, so the
853
+ // slot-part's own deferred finalize (a one-microtask retry when the parent
854
+ // walk cannot reach a host yet) carries it: every caller inserts the
855
+ // returned fragment synchronously in the same task, so the retry lands in
856
+ // the live tree. Without this loop a <slot> inside a repeat() / array item
857
+ // never finalizes and its content is never placeable.
858
+ for (const p of bound) {
859
+ if (p.kind === 'slot') applyPart(p, undefined, undefined, [], reconcileFormActionsCb);
860
+ }
861
+ const outFrag = document.createDocumentFragment();
862
+ outFrag.appendChild(startNode);
863
+ while (frag.firstChild) outFrag.appendChild(frag.firstChild);
864
+ outFrag.appendChild(endNode);
865
+ return {
866
+ inst: { strings: tr.strings, bound, lastValues, startNode, endNode },
867
+ frag: outFrag,
868
+ };
869
+ }
870
+
871
+ /** @param {TemplateInstance} inst */
872
+ export function disposeInstance(inst) {
873
+ for (const p of inst.bound) {
874
+ if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
875
+ if (p.kind === 'element') {
876
+ // Unbind any active ref so the user observes the element being
877
+ // removed (callback receives undefined / Ref.value cleared).
878
+ // Mirrors lit-html's cleanup-on-disconnect for element parts.
879
+ //
880
+ // BOTH branches swallow, and lit is not the reason: lit's ref directive
881
+ // guards neither, so a throw there propagates. The reason is that a
882
+ // teardown has to be TOTAL. `lastTarget` is cleared only AFTER these
883
+ // writes, so a throw leaves the part still pointing at the ref and
884
+ // every later teardown of the same instance throws at the same line
885
+ // forever. It also aborts the rest of this loop, so the remaining
886
+ // parts keep their listeners and their refs bound. A teardown has no
887
+ // retry either (a commit has the COMMIT_FAILED sentinel and a next
888
+ // render; this does not), so there is nothing a propagated error could
889
+ // usefully repair.
890
+ //
891
+ // The object branch is the one this adds. The callback branch was
892
+ // already guarded here AND on the commit path (`applyElement` wraps
893
+ // every `nextTarget(...)` / `prevTarget(undefined)` call), so a
894
+ // throwing ref CALLBACK has always been swallowed everywhere. What was
895
+ // inconsistent is the object ref, guarded on neither. This makes the
896
+ // two agree on TEARDOWN, which is where the totality argument bites.
897
+ // It does NOT touch the commit path, so `applyElement`'s object-ref
898
+ // writes still propagate to the component boundary, which has a route
899
+ // for the error and a next render to repair it.
900
+ const prev = /** @type any */ (p).lastTarget;
901
+ if (prev) {
902
+ if (typeof prev === 'function') {
903
+ try { prev(undefined); } catch {}
904
+ } else if (typeof prev === 'object') {
905
+ try { prev.value = undefined; } catch {}
906
+ }
907
+ /** @type any */ (p).lastTarget = undefined;
908
+ /** @type any */ (p).__lastEl = undefined;
909
+ }
910
+ }
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Initial fresh render of a repeat directive. Inserts all items' nodes
916
+ * immediately before the part's marker comment.
917
+ *
918
+ * @param {Comment} marker
919
+ * @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state
920
+ * @param {any} value
921
+ */
922
+ function applyRepeatFresh(marker, state, value, reconcileFormActionsCb) {
923
+ const { items, keyFn, templateFn } = value;
924
+ const parent = marker.parentNode;
925
+ if (!parent) return;
926
+ const bulk = document.createDocumentFragment();
927
+ for (let i = 0; i < items.length; i++) {
928
+ const key = keyFn(items[i], i);
929
+ const tr = templateFn(items[i], i);
930
+ if (!isTemplate(tr)) continue;
931
+ const { inst, frag } = buildDetached(/** @type any */ (tr), reconcileFormActionsCb);
932
+ state.map.set(key, inst);
933
+ bulk.appendChild(frag);
934
+ }
935
+ parent.insertBefore(bulk, marker);
936
+ }
937
+
938
+ /**
939
+ * Keyed reconciliation. For each key in the new list:
940
+ * - hit: update the existing instance in place (if template shape matches),
941
+ * then move its nodes into position
942
+ * - miss: build a new instance and insert
943
+ * Finally drop instances whose keys aren't in the new list.
944
+ *
945
+ * @param {Extract<BoundPart, {kind:'child'}>} part
946
+ * @param {any} value
947
+ */
948
+ function reconcileRepeat(part, value, reconcileFormActionsCb) {
949
+ const marker = part.marker;
950
+ const parent = marker.parentNode;
951
+ if (!parent) return;
952
+ const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
953
+ const { items, keyFn, templateFn } = value;
954
+ const newMap = new Map();
955
+
956
+ try {
957
+ // Walk the new list and position each item's nodes immediately before the marker.
958
+ for (let i = 0; i < items.length; i++) {
959
+ const key = keyFn(items[i], i);
960
+ const tr = templateFn(items[i], i);
961
+ if (!isTemplate(tr)) continue;
962
+ const existing = state.map.get(key);
963
+ if (existing && existing.strings === /** @type any */ (tr).strings) {
964
+ updateInstance(existing, /** @type any */ (tr).values, reconcileFormActionsCb);
965
+ // Move nodes before marker preserving element identity.
966
+ moveRange(existing.startNode, existing.endNode, parent, marker);
967
+ newMap.set(key, existing);
968
+ state.map.delete(key);
969
+ } else {
970
+ if (existing) {
971
+ // Unmapped BEFORE the row is touched, for the reason spelled out
972
+ // in the leftover loop below: a key kept across a refused removal
973
+ // points at a half-removed row, and reusing that row later walks
974
+ // the removal off the end of the region. Same ordering, same
975
+ // trade, and the two have to agree or the invariant the catch
976
+ // relies on holds on one branch and not the other.
977
+ state.map.delete(key);
978
+ disposeInstance(existing);
979
+ removeBetween(existing.startNode, existing.endNode);
980
+ }
981
+ const { inst, frag } = buildDetached(/** @type any */ (tr), reconcileFormActionsCb);
982
+ parent.insertBefore(frag, marker);
983
+ newMap.set(key, inst);
984
+ }
985
+ }
986
+
987
+ // Remove any keys that remain in the old map. The key leaves the map
988
+ // BEFORE its row is touched and the removal is in a `finally`, so at any
989
+ // throw point `state.map` holds exactly the leftovers this pass has not
990
+ // reached, and a row whose dispose threw still leaves the document.
991
+ // Iterating a snapshot keeps the delete obviously safe rather than
992
+ // relying on the reader knowing that deleting during a Map iteration is
993
+ // legal.
994
+ for (const [k, inst] of [...state.map]) {
995
+ state.map.delete(k);
996
+ try {
997
+ disposeInstance(inst);
998
+ } finally {
999
+ removeBetween(inst.startNode, inst.endNode);
1000
+ }
1001
+ }
1002
+ state.map = newMap;
1003
+ } catch (err) {
1004
+ // The walk moves DOM and drains `state.map` incrementally while the
1005
+ // replacement map is accumulated locally, so a throw part-way through
1006
+ // leaves NEITHER map describing what is on screen: the already-processed
1007
+ // keys sit only in `newMap`, which is about to be discarded, while their
1008
+ // nodes stay in the document tracked by nothing. The next (perfectly
1009
+ // valid) render then rebuilds those keys from scratch and the orphaned
1010
+ // originals are never removed, so the list shows duplicated rows forever
1011
+ // with nothing logged after the first throw.
1012
+ //
1013
+ // Re-unite every instance still in the document under `state.map` (the
1014
+ // two maps are disjoint: a key lands in `newMap` only after being deleted
1015
+ // from `state.map`), so the map describes the DOM again and nothing is
1016
+ // orphaned. That is the whole repair. The next render is then an ORDINARY
1017
+ // reconcile against a truthful map, which repositions every row and
1018
+ // re-applies whatever the throw skipped.
1019
+ //
1020
+ // That claim covers the REMOVAL loop as well as the walk, and only
1021
+ // because the loop was written to earn it. It drops each key before
1022
+ // touching that row and removes the nodes in a `finally`, so a throw
1023
+ // mid-removal cannot merge `newMap` over a `state.map` still holding
1024
+ // disposed, detached rows. That was the failure: the row the app DELETED
1025
+ // stayed on screen, the survivors reordered, and a later render that
1026
+ // re-added that key reinserted the detached instance. The invariant, at
1027
+ // any throw point on either branch: every instance this pass has not
1028
+ // destructively touched is described by exactly one of the two maps,
1029
+ // `newMap` for the processed new keys and `state.map` for the leftovers
1030
+ // not reached yet, which is what makes the merge below correct. The
1031
+ // exception is the row named in the residual just below, whose removal
1032
+ // refused part-way; that one is in neither map, by choice.
1033
+ //
1034
+ // The residual is a throw from `removeBetween` ITSELF, which only calls
1035
+ // `removeChild` on nodes the renderer owns, so it takes a throwing DOM to
1036
+ // reach. That row is already unmapped, so its remaining nodes stay in the
1037
+ // document tracked by nothing and a later re-add of that key builds a
1038
+ // second row beside them. Unmapping AFTER the removal instead would keep
1039
+ // that key, and it is measurably worse rather than better: the row is
1040
+ // half removed, its start marker gone and its end marker still in place,
1041
+ // so the re-add hits the reuse branch and `moveRange` re-attaches the
1042
+ // lone start marker AFTER the end marker. The next removal of that key
1043
+ // then walks forward from a start that never reaches its end, taking the
1044
+ // repeat part's own marker and every following sibling with it, and the
1045
+ // region is dead for good. One untracked row beats a destroyed list.
1046
+ //
1047
+ // Deliberately NOT a teardown-and-rebuild of the region. Rebuilding is
1048
+ // the obvious defensive move and it is measurably worse: it discards node
1049
+ // identity for every row, which is the exact cost keyed reconciliation
1050
+ // exists to avoid (see the plain-array note below, a rebuild cancels an
1051
+ // in-progress native drag). It is also unnecessary, because this map is
1052
+ // restored to the truth rather than left describing a DOM that moved, so
1053
+ // there is nothing to guess and no partial DOM move to unwind.
1054
+ //
1055
+ // That is only half of it, and the other half does NOT live here: the row
1056
+ // whose own commit threw is repaired by the COMMIT_FAILED sentinel in
1057
+ // `updateInstance`, not by anything below. Without it the failed hole
1058
+ // compares EQUAL on the next render (its `lastValues` entry never
1059
+ // advanced past the throw, so it still holds the value that render
1060
+ // supplies) and is skipped forever, which for a child position means the
1061
+ // row stays permanently blank. Do not remove the sentinel on the theory
1062
+ // that a half-updated instance heals itself; it does not.
1063
+ for (const [k, inst] of newMap) state.map.set(k, inst);
1064
+ throw err;
1065
+ }
1066
+ }
1067
+
1068
+ /** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
1069
+ function teardownRepeat(state) {
1070
+ // Same delete-as-you-go shape as the leftover loop in `reconcileRepeat`,
1071
+ // for the same reason: a throw part-way must not leave already-removed
1072
+ // instances in the map. The trailing `clear()` stays as a no-op safety net.
1073
+ for (const [k, inst] of [...state.map]) {
1074
+ state.map.delete(k);
1075
+ try {
1076
+ disposeInstance(inst);
1077
+ } finally {
1078
+ removeBetween(inst.startNode, inst.endNode);
1079
+ }
1080
+ }
1081
+ state.map.clear();
1082
+ }
1083
+
1084
+ /* ================================================================
1085
+ * Plain array (.map) support: positional, non-keyed reconciliation
1086
+ * ================================================================ */
1087
+
1088
+ /**
1089
+ * One rendered slot of a plain array. A `tpl` carries a detached template
1090
+ * instance (bookended by its own markers), a `text` carries a single text
1091
+ * node, and an `empty` slot (a nullish / boolean element) renders nothing
1092
+ * but still holds the position so index-based reconciliation stays aligned.
1093
+ * @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'empty' }} ArrayItem
1094
+ */
1095
+
1096
+ /** @param {ArrayItem} item @returns {ChildNode | null} */
1097
+ function arrayItemFirstNode(item) {
1098
+ if (item.type === 'tpl') return item.inst.startNode;
1099
+ if (item.type === 'text') return item.node;
1100
+ return null;
1101
+ }
1102
+
1103
+ /** @param {ArrayItem} item */
1104
+ function removeArrayItem(item) {
1105
+ if (item.type === 'tpl') {
1106
+ disposeInstance(item.inst);
1107
+ removeBetween(item.inst.startNode, item.inst.endNode);
1108
+ } else if (item.type === 'text') {
1109
+ item.node.parentNode?.removeChild(item.node);
1110
+ }
1111
+ }
1112
+
1113
+ /**
1114
+ * Build the slot for one array element, plus the fragment to insert (null
1115
+ * for an empty slot). A TemplateResult becomes a detached instance, a
1116
+ * primitive becomes a text node, and nullish / boolean renders nothing.
1117
+ * @param {unknown} v
1118
+ * @returns {{ item: ArrayItem, frag: Node | null }}
1119
+ */
1120
+ function buildArrayItem(v, reconcileFormActionsCb) {
1121
+ // #1443: unwrap live() per ITEM. applyPart unwraps the hole's own value, so
1122
+ // an array child arrives unwrapped at the top and its ITEMS do not.
1123
+ if (isLive(v)) v = /** @type any */ (v).value;
1124
+ if (isTemplate(v)) {
1125
+ const { inst, frag } = buildDetached(/** @type any */ (v), reconcileFormActionsCb);
1126
+ return { item: { type: 'tpl', inst }, frag };
1127
+ }
1128
+ if (v != null && v !== false && v !== true) {
1129
+ const node = document.createTextNode(String(v));
1130
+ return { item: { type: 'text', node }, frag: node };
1131
+ }
1132
+ return { item: { type: 'empty' }, frag: null };
1133
+ }
1134
+
1135
+ /**
1136
+ * Initial render of a plain array: insert every slot's nodes before the marker.
1137
+ * @param {Comment} marker
1138
+ * @param {{ kind: 'array', items: ArrayItem[] }} state
1139
+ * @param {unknown[]} value
1140
+ */
1141
+ function applyArrayFresh(marker, state, value, reconcileFormActionsCb) {
1142
+ const parent = marker.parentNode;
1143
+ if (!parent) return;
1144
+ const bulk = document.createDocumentFragment();
1145
+ /** @type {ArrayItem[]} */
1146
+ const items = [];
1147
+ for (const v of value) {
1148
+ const { item, frag } = buildArrayItem(v, reconcileFormActionsCb);
1149
+ if (frag) bulk.appendChild(frag);
1150
+ items.push(item);
1151
+ }
1152
+ parent.insertBefore(bulk, marker);
1153
+ state.items = items;
1154
+ }
1155
+
1156
+ /**
1157
+ * Positional (non-keyed) reconciliation. For each index, update the slot
1158
+ * in place when its shape is unchanged (preserving DOM node identity),
1159
+ * otherwise replace it; grow or shrink at the tail. There is no key
1160
+ * matching, so a reorder reduces to a series of in-place value updates;
1161
+ * reach for `repeat()` when element identity must follow a moved key.
1162
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1163
+ * @param {unknown[]} value
1164
+ */
1165
+ function reconcileArray(part, value, reconcileFormActionsCb) {
1166
+ const marker = part.marker;
1167
+ const parent = marker.parentNode;
1168
+ if (!parent) return;
1169
+ const state = /** @type {{ kind: 'array', items: ArrayItem[] }} */ (part.child);
1170
+ const old = state.items;
1171
+ /** @type {ArrayItem[]} */
1172
+ const next = [];
1173
+ // How many slots of `old` are fully processed. Tracked rather than inferred
1174
+ // from `next.length`, because the shrink loop below advances through `old`
1175
+ // while `next` stops growing, so the two part company there. The catch is
1176
+ // the only reader.
1177
+ let consumed = 0;
1178
+
1179
+ try {
1180
+ for (let i = 0; i < value.length; i++) {
1181
+ // #1443: same per-item unwrap as buildArrayItem, on the in-place update
1182
+ // path. Without it a re-render writes the wrapper's stringification into
1183
+ // the existing text node.
1184
+ const raw = value[i];
1185
+ const v = isLive(raw) ? /** @type any */ (raw).value : raw;
1186
+ const o = old[i];
1187
+ if (isTemplate(v)) {
1188
+ const tr = /** @type any */ (v);
1189
+ if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
1190
+ updateInstance(o.inst, tr.values, reconcileFormActionsCb);
1191
+ next.push(o);
1192
+ consumed = i + 1;
1193
+ continue;
1194
+ }
1195
+ } else if (v != null && v !== false && v !== true) {
1196
+ if (o && o.type === 'text') {
1197
+ const str = String(v);
1198
+ if (o.node.data !== str) o.node.data = str;
1199
+ next.push(o);
1200
+ consumed = i + 1;
1201
+ continue;
1202
+ }
1203
+ } else {
1204
+ // Empty slot: drop any prior nodes that occupied this position.
1205
+ // Deliberately NOT reordered like the branch below. That reorder
1206
+ // exists to keep a slot that was already BUILT and INSERTED tracked,
1207
+ // and this branch builds and inserts nothing, so pushing first would
1208
+ // only mean a throw from the removal leaves a phantom empty slot at
1209
+ // this index AND `old[i]` spliced in at the next one, shifting every
1210
+ // later slot by one in a POSITIONAL reconciler. Removing first, a
1211
+ // throw here leaves `old[i]` describing its own position, which is
1212
+ // still exactly where its nodes are.
1213
+ if (o) removeArrayItem(o);
1214
+ next.push({ type: 'empty' });
1215
+ consumed = i + 1;
1216
+ continue;
1217
+ }
1218
+ // Shape changed, or the array grew past the old length. Build fresh,
1219
+ // insert at this position (before the current / next still-attached
1220
+ // old node, else the marker), then drop the old slot it replaced.
1221
+ // The push sits BEFORE the removal, which is a pure reordering on the
1222
+ // success path and means a slot that has already been built and
1223
+ // inserted is never untracked at any throw point.
1224
+ const { item, frag } = buildArrayItem(v, reconcileFormActionsCb);
1225
+ if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
1226
+ next.push(item);
1227
+ if (o) removeArrayItem(o);
1228
+ consumed = i + 1;
1229
+ }
1230
+
1231
+ // Shrink: remove slots beyond the new length.
1232
+ for (let i = value.length; i < old.length; i++) {
1233
+ removeArrayItem(old[i]);
1234
+ consumed = i + 1;
1235
+ }
1236
+ state.items = next;
1237
+ } catch (err) {
1238
+ // `state.items` is committed only after the whole walk, so a throw part
1239
+ // way through discards `next` entirely: the map of slots keeps describing
1240
+ // positions whose nodes were already removed, while the freshly built and
1241
+ // inserted ones are in the document tracked by nothing. Nothing is logged
1242
+ // after the first throw, and the orphan outlives even a render of an EMPTY
1243
+ // array, because the only code that could remove it walks `state.items`.
1244
+ //
1245
+ // Splice the untouched tail of `old` onto what `next` accumulated, so the
1246
+ // bookkeeping describes the DOM again. The invariant that holds at any
1247
+ // throw point: every live node is described by exactly one slot, the
1248
+ // slots below `next.length` being the rebuilt or reused ones and the rest
1249
+ // the part of `old` this pass never reached. Index alignment survives
1250
+ // because this reconciler is POSITIONAL, so a slot's index IS its
1251
+ // identity, which is also why the boundary has to come from `consumed`
1252
+ // rather than `next.length`: during the shrink loop those differ, and
1253
+ // splicing from `next.length` would re-describe slots already removed.
1254
+ // A later render that grew the array would then match a live value
1255
+ // against a DETACHED slot with the same `strings`, update it in place,
1256
+ // and that row would silently never appear.
1257
+ //
1258
+ // Deliberately NOT a teardown-and-rebuild of the region, for the reason
1259
+ // recorded on `reconcileRepeat`'s catch above: it discards node identity
1260
+ // for every row, which cancels an in-progress native drag and drops focus
1261
+ // and scroll.
1262
+ //
1263
+ // The residual is a throw from the removal step itself, which takes a
1264
+ // throwing DOM to reach (the teardown it calls is total, so only
1265
+ // `removeBetween` is left, and that calls `removeChild` solely on nodes
1266
+ // the renderer owns). State it rather than deny it: `removeBetween`
1267
+ // takes the start marker first and then early-returns for good once that
1268
+ // marker is gone, so a row whose removal refused part-way can never be
1269
+ // removed afterwards. Its remaining nodes stay in the document, and an
1270
+ // EMPTY render will not clear them. Tracked or not, they are there for
1271
+ // the life of the region, the same residual `reconcileRepeat`'s catch
1272
+ // names. What this repair buys is that there is only ONE such row and
1273
+ // every other slot still reconciles, where before the whole pass was
1274
+ // discarded.
1275
+ state.items = next.concat(old.slice(consumed));
1276
+ throw err;
1277
+ }
1278
+ }
1279
+
1280
+ /**
1281
+ * The node a freshly-built slot at index `i` inserts before: the first
1282
+ * node of the current or next still-attached old slot, else the part
1283
+ * marker (a tail append).
1284
+ * @param {ArrayItem[]} old @param {number} i @param {Comment} marker
1285
+ * @returns {ChildNode}
1286
+ */
1287
+ function nextArrayAnchor(old, i, marker) {
1288
+ for (let j = i; j < old.length; j++) {
1289
+ const f = arrayItemFirstNode(old[j]);
1290
+ if (f && f.parentNode) return f;
1291
+ }
1292
+ return marker;
1293
+ }
1294
+
1295
+ /** @param {{ kind: 'array', items: ArrayItem[] }} state */
1296
+ function teardownArray(state) {
1297
+ for (const it of state.items) removeArrayItem(it);
1298
+ state.items = [];
1299
+ }
1300
+
1301
+ /**
1302
+ * Collect [start .. end] (inclusive) and insert immediately before `anchor`.
1303
+ * Browsers treat insertBefore of an already-connected node as a move and
1304
+ * preserve element identity + focus.
1305
+ *
1306
+ * @param {Node} start
1307
+ * @param {Node} end
1308
+ * @param {Node} parent
1309
+ * @param {Node} anchor
1310
+ */
1311
+ export function moveRange(start, end, parent, anchor) {
1312
+ // No-op if the range is already immediately before the anchor.
1313
+ if (end.nextSibling === anchor && start.parentNode === parent) return;
1314
+ const frag = document.createDocumentFragment();
1315
+ let n = start;
1316
+ while (n) {
1317
+ const next = n.nextSibling;
1318
+ frag.appendChild(n);
1319
+ if (n === end) break;
1320
+ n = next;
1321
+ }
1322
+ parent.insertBefore(frag, anchor);
1323
+ }
1324
+
1325
+ /**
1326
+ * Shallow array equality (Object.is on each element). Used by the
1327
+ * `guard` directive to skip re-evaluation when deps are unchanged.
1328
+ * @param {readonly unknown[]} a
1329
+ * @param {readonly unknown[]} b
1330
+ */
1331
+ function shallowEqualArray(a, b) {
1332
+ if (a === b) return true;
1333
+ if (!a || !b || a.length !== b.length) return false;
1334
+ for (let i = 0; i < a.length; i++) {
1335
+ if (!Object.is(a[i], b[i])) return false;
1336
+ }
1337
+ return true;
1338
+ }
1339
+
1340
+ /** @param {Extract<BoundPart, {kind:'child'}>} part */
1341
+ export function teardownChild(part) {
1342
+ // Always abort any in-flight directive state on the part, even if
1343
+ // `part.child` itself is something else (e.g. an `until` directive
1344
+ // installed __untilState but applyChild's recursion overwrote
1345
+ // part.child to the rendered fallback shape).
1346
+ const partAny = /** @type any */ (part);
1347
+ if (partAny.__untilState) {
1348
+ partAny.__untilState.aborted = true;
1349
+ partAny.__untilState = undefined;
1350
+ }
1351
+ if (partAny.__watchSub) {
1352
+ teardownWatch(partAny);
1353
+ }
1354
+
1355
+ if (!part.child) return;
1356
+ const c = /** @type any */ (part.child);
1357
+ if (c.kind === 'repeat') {
1358
+ teardownRepeat(c);
1359
+ } else if (c.kind === 'array') {
1360
+ teardownArray(c);
1361
+ } else if (c.kind === 'async-stream') {
1362
+ teardownAsyncStream(c);
1363
+ } else if ('strings' in c) {
1364
+ const inst = /** @type TemplateInstance */ (part.child);
1365
+ disposeInstance(inst);
1366
+ removeBetween(inst.startNode, inst.endNode);
1367
+ } else {
1368
+ for (const n of /** @type ChildNode[] */ (part.child)) {
1369
+ if (n.parentNode) n.parentNode.removeChild(n);
1370
+ }
1371
+ }
1372
+ part.child = undefined;
1373
+ }
1374
+
1375
+ /**
1376
+ * Clear per-part directive state slots that don't apply to the value
1377
+ * currently being rendered. Prevents stale `__guardDeps` from short-
1378
+ * circuiting a render when the directive at this position is no longer
1379
+ * a guard, stale `__cacheMap` from accumulating across non-cache
1380
+ * renders, and stale `__untilState` from letting a prior Promise
1381
+ * resolution overwrite newer DOM.
1382
+ *
1383
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1384
+ * @param {unknown} value
1385
+ */
1386
+ export function clearStaleDirectiveState(part, value) {
1387
+ const partAny = /** @type any */ (part);
1388
+ if (partAny.__untilState && !isUntil(value)) {
1389
+ partAny.__untilState.aborted = true;
1390
+ partAny.__untilState = undefined;
1391
+ }
1392
+ if (partAny.__guardDeps !== undefined && !isGuard(value)) {
1393
+ partAny.__guardDeps = undefined;
1394
+ }
1395
+ if (partAny.__cacheMap && !isCache(value)) {
1396
+ partAny.__cacheMap = undefined;
1397
+ }
1398
+ if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
1399
+ partAny.__keyedKey = undefined;
1400
+ }
1401
+ if (partAny.__watchSub && !isWatch(value)) {
1402
+ teardownWatch(partAny);
1403
+ }
1404
+ }
1405
+
1406
+ /* ================================================================
1407
+ * Cache directive: detach + retain prior template instances so that
1408
+ * toggling between sub-templates preserves their DOM state.
1409
+ * ================================================================ */
1410
+
1411
+ /**
1412
+ * Apply the `cache` directive at a child position. The cache is stored
1413
+ * on the part as `__cacheMap: Map<strings, { inst, holderFrag }>`.
1414
+ *
1415
+ * When the new inner value is a template whose `strings` already lives
1416
+ * in the cache map, re-attach the stashed nodes before the marker and
1417
+ * reconcile values against the new template. When the new inner is a
1418
+ * template whose strings aren't cached, stash the currently-attached
1419
+ * instance (if any) into the cache map before rendering the new one.
1420
+ *
1421
+ * Non-template inner values fall through to the generic applyChild path
1422
+ * (after first stashing any currently-attached cached instance).
1423
+ *
1424
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1425
+ * @param {unknown} inner
1426
+ */
1427
+ function applyCache(part, inner, reconcileFormActionsCb) {
1428
+ const marker = part.marker;
1429
+ const partAny = /** @type any */ (part);
1430
+ /** @type {Map<TemplateStringsArray, { inst: TemplateInstance, holder: DocumentFragment }>} */
1431
+ let cacheMap = partAny.__cacheMap;
1432
+ if (!cacheMap) {
1433
+ cacheMap = new Map();
1434
+ partAny.__cacheMap = cacheMap;
1435
+ }
1436
+
1437
+ const currentChild = /** @type any */ (part.child);
1438
+ const currentIsInstance = currentChild && 'strings' in currentChild;
1439
+
1440
+ // If the currently-attached child IS a template instance, decide
1441
+ // whether to update-in-place, stash for later, or destroy.
1442
+ if (currentIsInstance) {
1443
+ const currentInst = /** @type TemplateInstance */ (currentChild);
1444
+ // Same template structure: reconcile values, no detach/re-attach.
1445
+ if (isTemplate(inner) && currentInst.strings === /** @type any */ (inner).strings) {
1446
+ updateInstance(currentInst, /** @type any */ (inner).values, reconcileFormActionsCb);
1447
+ return;
1448
+ }
1449
+ // Different shape: detach the current instance into a holder fragment
1450
+ // and store it in the cache map. We keep the existing instance, slot
1451
+ // markers, and rendered nodes; only the parent changes. moveRange's
1452
+ // null anchor means "append to parent".
1453
+ const holder = document.createDocumentFragment();
1454
+ moveRange(currentInst.startNode, currentInst.endNode, holder, null);
1455
+ cacheMap.set(currentInst.strings, { inst: currentInst, holder });
1456
+ part.child = undefined;
1457
+ }
1458
+ // Now part.child is either undefined or some non-instance shape (rare;
1459
+ // happens when prior render had a string / array / etc.). For non-
1460
+ // instance shapes, fall through to the generic teardown via applyChild.
1461
+
1462
+ // If the new inner is a template AND we've cached an instance for its
1463
+ // strings, re-attach it.
1464
+ if (isTemplate(inner)) {
1465
+ const tr = /** @type any */ (inner);
1466
+ const cached = cacheMap.get(tr.strings);
1467
+ if (cached) {
1468
+ cacheMap.delete(tr.strings);
1469
+ // Tear down any non-instance child currently attached (a string /
1470
+ // array of text nodes from a prior cache(non-template) render).
1471
+ // Without this the prior nodes remain in the DOM alongside the
1472
+ // re-attached cached template.
1473
+ if (part.child) {
1474
+ teardownChild(part);
1475
+ }
1476
+ // Move the cached nodes back before the marker.
1477
+ moveRange(cached.inst.startNode, cached.inst.endNode, /** @type Node */ (marker.parentNode), marker);
1478
+ // Reconcile values so any state changes since detachment apply.
1479
+ updateInstance(cached.inst, tr.values, reconcileFormActionsCb);
1480
+ part.child = cached.inst;
1481
+ // A re-attached instance may carry ALREADY-APPLIED slot parts, whose
1482
+ // finalize will never fire again, while the host's record moved on
1483
+ // during the stash (content for these slots was parked when an apply
1484
+ // ran with the slot unreachable). Re-run the apply for each owning
1485
+ // host so parked content is pulled back out. The collection walks the
1486
+ // re-attached DOM RANGE (not the instance tree): slot parts live on
1487
+ // whatever template level contains the <slot> (nested holes, repeat /
1488
+ // array items, streamed chunks), so a structural DOM walk is the only
1489
+ // shape that covers every composition uniformly. moveRange already
1490
+ // ran, so the range is live and each slot's parent walk reaches its
1491
+ // host.
1492
+ const hosts = new Set();
1493
+ for (
1494
+ let n = cached.inst.startNode.nextSibling;
1495
+ n && n !== cached.inst.endNode;
1496
+ n = n.nextSibling
1497
+ ) {
1498
+ if (n.nodeType !== 1) continue;
1499
+ const el = /** @type {Element} */ (n);
1500
+ if (el.matches('slot[data-webjs-light]')) {
1501
+ const h = findSlotHost(el);
1502
+ if (h) hosts.add(h);
1503
+ }
1504
+ for (const s of el.querySelectorAll('slot[data-webjs-light]')) {
1505
+ const h = findSlotHost(s);
1506
+ if (h) hosts.add(h);
1507
+ }
1508
+ }
1509
+ for (const h of hosts) applySlotAssignments(h);
1510
+ return;
1511
+ }
1512
+ }
1513
+
1514
+ // No cached instance available. Render the new inner value via the
1515
+ // standard applyChild path. The currentIsInstance branch already
1516
+ // handled detaching the prior instance; if part.child still holds a
1517
+ // non-instance shape, applyChild will tear it down generically.
1518
+ applyChildInner(part, inner, reconcileFormActionsCb);
1519
+ }
1520
+
1521
+ /**
1522
+ * Apply the `until` directive at a child position.
1523
+ *
1524
+ * Priority is left-to-right: args[0] has the highest priority. The
1525
+ * highest-priority synchronous candidate (if any) renders immediately.
1526
+ * Strictly-higher-priority Promises are awaited in the background; when
1527
+ * one resolves AND no higher-priority Promise has already resolved, its
1528
+ * result becomes the rendered value.
1529
+ *
1530
+ * The directive's state lives on `part.__untilState` (a stable slot
1531
+ * that survives `applyChild`'s overwrites of `part.child`). When a new
1532
+ * render replaces the directive, the prior state's `aborted` flag flips
1533
+ * to `true` so any in-flight Promise resolutions short-circuit instead
1534
+ * of overwriting newer DOM.
1535
+ *
1536
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1537
+ * @param {readonly unknown[]} args
1538
+ */
1539
+ function applyUntil(part, args, reconcileFormActionsCb) {
1540
+ // Carry forward the prior render's `highestResolved` ONLY when the
1541
+ // args list is unchanged. When any argument identity changes, prior
1542
+ // priorities no longer apply (a Promise that won at index 0 may now
1543
+ // sit at a different index, or have been replaced entirely); the
1544
+ // state must reset to Infinity so the new args' Promises can compete.
1545
+ //
1546
+ // For TemplateResult args, compare by `strings` array identity rather
1547
+ // than the wrapper object identity. `html\`loading...\`` evaluates to
1548
+ // a fresh TemplateResult on every call but the strings array is
1549
+ // interned per call site, so the conceptual value is unchanged.
1550
+ const partAny = /** @type any */ (part);
1551
+ // Same as applyWatch: the promise handlers below commit with no render on
1552
+ // the stack, so the owning component is recorded here while it is knowable.
1553
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1554
+ const prevState = partAny.__untilState;
1555
+ const prevArgs = partAny.__untilArgs;
1556
+ const argEq = (a, b) => {
1557
+ if (Object.is(a, b)) return true;
1558
+ if (isTemplate(a) && isTemplate(b)
1559
+ && /** @type any */ (a).strings === /** @type any */ (b).strings) return true;
1560
+ return false;
1561
+ };
1562
+ const argsEqual = prevArgs && prevArgs.length === args.length
1563
+ && prevArgs.every((a, i) => argEq(a, args[i]));
1564
+ const carriedHighest = argsEqual && prevState ? prevState.highestResolved : Infinity;
1565
+ if (prevState) prevState.aborted = true;
1566
+ partAny.__untilArgs = args.slice();
1567
+
1568
+ /** @type {{aborted:boolean, highestResolved:number}} */
1569
+ const state = { aborted: false, highestResolved: carriedHighest };
1570
+ partAny.__untilState = state;
1571
+
1572
+ // Highest-priority synchronous candidate. If found, render it now
1573
+ // and cap further Promise subscription to strictly-higher priorities.
1574
+ let firstSyncIdx = -1;
1575
+ let firstSyncVal = undefined;
1576
+ for (let i = 0; i < args.length; i++) {
1577
+ const a = args[i];
1578
+ if (!a || typeof (/** @type any */ (a).then) !== 'function') {
1579
+ firstSyncIdx = i;
1580
+ firstSyncVal = a;
1581
+ break;
1582
+ }
1583
+ }
1584
+
1585
+ if (firstSyncIdx !== -1 && firstSyncIdx <= state.highestResolved) {
1586
+ // The sync candidate beats any previously-rendered Promise value
1587
+ // (when firstSyncIdx < state.highestResolved) OR re-renders the
1588
+ // sync fallback at the same priority slot (when ===), in case its
1589
+ // value changed between renders.
1590
+ applyChildInner(part, firstSyncVal, reconcileFormActionsCb);
1591
+ state.highestResolved = firstSyncIdx;
1592
+ } else if (firstSyncIdx === -1 && !partAny.__untilEverRendered) {
1593
+ // First-ever render of this part with all-Promise args: render
1594
+ // empty as the initial fallback while Promises settle.
1595
+ applyChildInner(part, '', reconcileFormActionsCb);
1596
+ }
1597
+ // Else: either there is no sync candidate but the part has rendered
1598
+ // before (preserve existing DOM until a Promise resolves), OR the
1599
+ // sync candidate is lower-priority than what's already rendered.
1600
+ // Either way: leave the existing DOM in place. This prevents the
1601
+ // "all-Promises wipes prior content" flash on re-renders.
1602
+ partAny.__untilEverRendered = true;
1603
+
1604
+ // Subscribe to Promises with priority strictly less than what's
1605
+ // currently rendered. (Lower index = higher priority in lit's model.)
1606
+ // Each subscription wraps in Promise.resolve() so synchronous
1607
+ // thenables get a microtask boundary, matching lit's contract that
1608
+ // all Promise/thenable resolutions are deferred.
1609
+ const cap = firstSyncIdx === -1
1610
+ ? Math.min(args.length, state.highestResolved)
1611
+ : Math.min(firstSyncIdx, state.highestResolved);
1612
+ for (let i = 0; i < cap; i++) {
1613
+ const a = args[i];
1614
+ if (!a || typeof (/** @type any */ (a).then) !== 'function') continue;
1615
+
1616
+ Promise.resolve(/** @type Promise<unknown> */ (a)).then(
1617
+ (resolved) => {
1618
+ if (state.aborted) return;
1619
+ if (i >= state.highestResolved) return;
1620
+ // Commit FIRST, record the new priority only once it succeeded.
1621
+ // Advancing `highestResolved` up front meant a commit throw left the
1622
+ // region torn down while the state claimed index `i` had won, so
1623
+ // every later resolution at a lower priority was refused and the
1624
+ // region stayed empty. Committing first also keeps the throw inside
1625
+ // the try, where it can reach the component boundary.
1626
+ try {
1627
+ commitOutOfBand(part, () => applyChildInner(part, resolved, reconcileFormActionsCb));
1628
+ } catch (err) {
1629
+ reportOutOfBandCommitError(part, err);
1630
+ return;
1631
+ }
1632
+ state.highestResolved = i;
1633
+ },
1634
+ // Swallow rejection. A rejected Promise is treated as "no value";
1635
+ // the existing render stays in place.
1636
+ () => {},
1637
+ );
1638
+ }
1639
+ }
1640
+
1641
+ /**
1642
+ * Route a throw from an OUT-OF-BAND commit to the owning component's
1643
+ * render-error boundary.
1644
+ *
1645
+ * `watch`'s notify microtask and `until`'s Promise resolution commit from
1646
+ * outside `component.js`'s update cycle, so none of the boundaries that wrap
1647
+ * every other render path are on the stack. Left alone, a commit throw there
1648
+ * escapes as a window-level `error` / unhandled rejection instead of the
1649
+ * per-component `renderError()` the sync and async render paths both route
1650
+ * to, which breaks per-component error isolation for exactly these two
1651
+ * directives.
1652
+ *
1653
+ * Reads the owner stamped on the part when the directive was installed. See
1654
+ * the note in the body for why this is not a walk up the parent chain.
1655
+ *
1656
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1657
+ * @param {unknown} error
1658
+ */
1659
+ export function reportOutOfBandCommitError(part, error) {
1660
+ let err;
1661
+ try {
1662
+ err = error instanceof Error ? error : new Error(String(error));
1663
+ } catch {
1664
+ // The thrown value's own `toString` threw. That is the exact class of
1665
+ // value that makes a commit throw in the first place, so it is reachable
1666
+ // here; do not let stringifying it mask the original failure.
1667
+ err = new Error('render error (unstringifiable thrown value)');
1668
+ }
1669
+ // The owner stamped when the directive was installed, which is the
1670
+ // component whose TEMPLATE holds this part. Never walk the parent chain
1671
+ // for this: `_handleRenderError` lives on WebComponent's prototype, so
1672
+ // every upgraded element on the way up carries one, and the FIRST one a
1673
+ // structural walk meets is the innermost element the part happens to sit
1674
+ // inside, not the template that owns it. Routing there is not merely the
1675
+ // wrong log line: a light-DOM component's renderError() commits into the
1676
+ // component itself, which would replace the very children holding this
1677
+ // part's markers and silently kill every later update through it.
1678
+ const owner = /** @type any */ (part).__commitOwner;
1679
+ if (owner && typeof owner._handleRenderError === 'function') {
1680
+ owner._handleRenderError(err);
1681
+ return;
1682
+ }
1683
+ // No component boundary owns this part (a bare `render()` into a plain
1684
+ // container). Nothing can contain the error, so surface it rather than
1685
+ // swallow it.
1686
+ throw err;
1687
+ }
1688
+
1689
+ /**
1690
+ * Bind a child part to a signal. Reads the signal once and writes its
1691
+ * value into the part. Installs a per-part `Signal.subtle.Watcher`
1692
+ * that, on signal change, re-reads and re-applies the value WITHOUT
1693
+ * re-running the host component's render(). When the part is torn
1694
+ * down (teardownChild) the watcher is disposed.
1695
+ *
1696
+ * The signal read happens inside the watcher's `observe()`, so the
1697
+ * dependency edge connects the signal to THIS watcher. The host's
1698
+ * own render watcher is outside the active stack here, so the host
1699
+ * does not also subscribe to the signal (which would double-fire as
1700
+ * both a full re-render and a watch update).
1701
+ *
1702
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1703
+ * @param {{ get: () => unknown, __isSignal: true }} sig
1704
+ */
1705
+ function applyWatch(part, sig, reconcileFormActionsCb) {
1706
+ const partAny = /** @type any */ (part);
1707
+ // Record the owning component while we are still inside its render(), the
1708
+ // only moment it is knowable. The notify microtask below commits with no
1709
+ // render on the stack and no way to derive it. Keep a prior stamp if this
1710
+ // somehow runs outside a render, rather than clearing a good owner.
1711
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1712
+ // Same signal as last render: refresh dep tracking via observe (the
1713
+ // spec-aligned Watcher fires once per arm, so re-observing re-arms).
1714
+ if (partAny.__watchSig === sig && partAny.__watchSub) {
1715
+ let value;
1716
+ partAny.__watchSub.observe(() => { value = sig.get(); });
1717
+ applyChildInner(part, value, reconcileFormActionsCb);
1718
+ return;
1719
+ }
1720
+ // Signal changed (or first render). Tear down any prior watcher.
1721
+ if (partAny.__watchSub) {
1722
+ partAny.__watchSub.dispose();
1723
+ partAny.__watchSub = undefined;
1724
+ }
1725
+ partAny.__watchSig = sig;
1726
+ // Notify defers to a microtask because the spec forbids signal reads
1727
+ // inside the notify itself. The microtask re-observes (re-arms + re-
1728
+ // records the dep) and applies the new value to the part.
1729
+ const watcher = new Signal.subtle.Watcher(() => {
1730
+ queueMicrotask(() => {
1731
+ if (partAny.__watchSub !== watcher) return;
1732
+ let v;
1733
+ // Nothing above this microtask catches: it runs outside the host's
1734
+ // update cycle, so a commit throw here would surface at the window
1735
+ // instead of the component's renderError(). Route it explicitly.
1736
+ try {
1737
+ watcher.observe(() => { v = sig.get(); });
1738
+ commitOutOfBand(part, () => applyChildInner(part, v, reconcileFormActionsCb));
1739
+ } catch (err) {
1740
+ reportOutOfBandCommitError(part, err);
1741
+ }
1742
+ });
1743
+ });
1744
+ partAny.__watchSub = watcher;
1745
+ let initial;
1746
+ watcher.observe(() => { initial = sig.get(); });
1747
+ applyChildInner(part, initial, reconcileFormActionsCb);
1748
+ }
1749
+
1750
+ /**
1751
+ * Dispose a `watch` directive's per-part watcher. Called from
1752
+ * `teardownChild` and from `clearStaleDirectiveState` when the value
1753
+ * at the part is no longer a watch.
1754
+ * @param {any} partAny
1755
+ */
1756
+ function teardownWatch(partAny) {
1757
+ if (partAny.__watchSub) {
1758
+ partAny.__watchSub.dispose();
1759
+ partAny.__watchSub = undefined;
1760
+ partAny.__watchSig = undefined;
1761
+ }
1762
+ }
1763
+
1764
+ /* ================================================================
1765
+ * asyncAppend / asyncReplace: stream from AsyncIterable.
1766
+ * ================================================================ */
1767
+
1768
+ /**
1769
+ * Apply `asyncAppend(iterable, mapper?)` at a child position.
1770
+ *
1771
+ * Iterates the AsyncIterable in the background. Each yielded value is
1772
+ * mapped (optional) and rendered as a node group, appended before the
1773
+ * marker. The state is stored on `part.child` so `teardownChild` can
1774
+ * abort the iteration when the part is reset.
1775
+ *
1776
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1777
+ * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1778
+ */
1779
+ function applyAsyncAppend(part, dir, reconcileFormActionsCb) {
1780
+ const partAny = /** @type any */ (part);
1781
+ // Record the owning component while we are still inside its render(), the
1782
+ // only moment it is knowable, exactly as `applyWatch` / `applyUntil` do.
1783
+ // Chunks commit from an async loop with no render on the stack, so without
1784
+ // this both the chunk's own commit throw and any directive nested inside a
1785
+ // chunk have no owner to route to. Stamped ABOVE the short-circuit so a
1786
+ // re-render that returns early still refreshes the owner, and guarded so a
1787
+ // re-install outside a render keeps a previously good one.
1788
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1789
+ // Same-iterable short-circuit: if the prior render's iterable identity
1790
+ // matches, the existing iterator is still consuming it. Re-subscribing
1791
+ // would start a fresh iterator that misses already-yielded values.
1792
+ // Matches lit-html's behavior.
1793
+ const currentChild = /** @type any */ (part.child);
1794
+ if (currentChild && currentChild.kind === 'async-stream'
1795
+ && currentChild.mode === 'append'
1796
+ && currentChild.iterable === dir.iterable) {
1797
+ return;
1798
+ }
1799
+
1800
+ teardownChild(part);
1801
+
1802
+ const iterator = /** @type AsyncIterator<unknown> */ (
1803
+ dir.iterable[Symbol.asyncIterator]()
1804
+ );
1805
+ /** @type {AsyncStreamState} */
1806
+ const state = {
1807
+ kind: 'async-stream',
1808
+ mode: 'append',
1809
+ aborted: false,
1810
+ iterable: dir.iterable,
1811
+ iterator,
1812
+ /** @type {ChildNode[]} */ nodes: [],
1813
+ };
1814
+ part.child = state;
1815
+
1816
+ consumeAsyncStream(state, part, dir, reconcileFormActionsCb);
1817
+ }
1818
+
1819
+ /**
1820
+ * Apply `asyncReplace(iterable, mapper?)` at a child position. Same as
1821
+ * `applyAsyncAppend` but each new value replaces the previous content.
1822
+ *
1823
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1824
+ * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1825
+ */
1826
+ function applyAsyncReplace(part, dir, reconcileFormActionsCb) {
1827
+ const partAny = /** @type any */ (part);
1828
+ // Owner stamp: see comment in applyAsyncAppend. Above the short-circuit.
1829
+ if (currentRenderRoot) partAny.__commitOwner = boundaryOwnerOf(currentRenderRoot);
1830
+ // Same-iterable short-circuit: see comment in applyAsyncAppend.
1831
+ const currentChild = /** @type any */ (part.child);
1832
+ if (currentChild && currentChild.kind === 'async-stream'
1833
+ && currentChild.mode === 'replace'
1834
+ && currentChild.iterable === dir.iterable) {
1835
+ return;
1836
+ }
1837
+
1838
+ teardownChild(part);
1839
+
1840
+ const iterator = /** @type AsyncIterator<unknown> */ (
1841
+ dir.iterable[Symbol.asyncIterator]()
1842
+ );
1843
+ /** @type {AsyncStreamState} */
1844
+ const state = {
1845
+ kind: 'async-stream',
1846
+ mode: 'replace',
1847
+ aborted: false,
1848
+ iterable: dir.iterable,
1849
+ iterator,
1850
+ /** @type {ChildNode[]} */ nodes: [],
1851
+ };
1852
+ part.child = state;
1853
+
1854
+ consumeAsyncStream(state, part, dir, reconcileFormActionsCb);
1855
+ }
1856
+
1857
+ /**
1858
+ * @typedef {{
1859
+ * kind: 'async-stream',
1860
+ * mode: 'append' | 'replace',
1861
+ * aborted: boolean,
1862
+ * iterable: AsyncIterable<unknown>,
1863
+ * iterator: AsyncIterator<unknown>,
1864
+ * nodes: ChildNode[],
1865
+ * }} AsyncStreamState
1866
+ */
1867
+ /**
1868
+ * Consume an AsyncIterable for `asyncAppend` / `asyncReplace`. Drives
1869
+ * the iterator with an explicit `.next()` loop (rather than `for await`)
1870
+ * so that `teardownAsyncStream` can call `iterator.return()` to break
1871
+ * a generator parked on an `await`. The `aborted` flag is also checked
1872
+ * after every `next()` resolve to short-circuit if abortion happened
1873
+ * while the iterator was suspended.
1874
+ *
1875
+ * Each pass carries TWO try spans, and which failure lands in which is the
1876
+ * load-bearing part. SPAN A is the author's own code, the iterable AND the
1877
+ * `mapper` it was given, and a throw from either is logged to the console and
1878
+ * ends the stream, on the long-standing reasoning that an author's iterable
1879
+ * should handle its own errors. SPAN B is the chunk COMMIT, which is a render
1880
+ * failure of the component whose template holds the binding, so it routes to
1881
+ * that component's `renderError()` and stops the stream.
1882
+ *
1883
+ * Scope note: only a throw from the COMMIT can stop the stream from here. A
1884
+ * directive nested INSIDE a committed chunk (a `watch` whose signal changes
1885
+ * later) throws from its own handler, outside this loop entirely, so it
1886
+ * reaches the boundary but this loop knows nothing about it and keeps
1887
+ * pulling. That is the same for any directive nested anywhere else. lit is no authority
1888
+ * either way here (it has no per-component boundary, and both failures become
1889
+ * unhandled rejections at the window), so this follows the
1890
+ * per-component error isolation WebJs has instead.
1891
+ *
1892
+ * @param {AsyncStreamState} state
1893
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1894
+ * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1895
+ */
1896
+ async function consumeAsyncStream(state, part, dir, reconcileFormActionsCb) {
1897
+ const marker = part.marker;
1898
+ let i = 0;
1899
+ while (!state.aborted) {
1900
+ /** @type {IteratorResult<unknown>} */
1901
+ let result;
1902
+ /** @type {unknown} */
1903
+ let mapped;
1904
+ // SPAN A, the author's iterable. A throw here is the author's generator
1905
+ // failing, not a render, so it keeps the long-standing console.error and
1906
+ // ends the stream. It is a separate span from the commit below on purpose
1907
+ // rather than a flag the one catch inspects, because the distinction is
1908
+ // the whole point: `reportOutOfBandCommitError` RETHROWS for a part with
1909
+ // no owner, and a single enclosing try would hand that rethrow straight
1910
+ // back to this swallow, which is the escape this split exists to stop.
1911
+ try {
1912
+ result = await state.iterator.next();
1913
+ if (state.aborted) break;
1914
+ if (result.done) break;
1915
+ mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
1916
+ } catch (err) {
1917
+ // Note this ENDS the stream, and always has: the catch used to sit
1918
+ // outside the loop, so there has never been a resume path here.
1919
+ if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
1920
+ return;
1921
+ }
1922
+
1923
+ // SPAN B, the chunk commit. This is a render of the component whose
1924
+ // TEMPLATE holds the binding, so a throw is that component's render
1925
+ // failure and routes to its `renderError()`, the same as `watch` and
1926
+ // `until` already do from their own out-of-band commits. `renderToNodes`
1927
+ // is INSIDE the wrap because that is where a nested directive is
1928
+ // installed and reads `currentRenderRoot`; without it, a `watch()` inside
1929
+ // a chunk is stamped with no owner and its later throw escapes.
1930
+ // `commitInto` is a different concern (the renderer-write window for a
1931
+ // light slot host), so the two nest rather than replace each other.
1932
+ try {
1933
+ commitOutOfBand(part, () => {
1934
+ const newNodes = renderToNodes(mapped, reconcileFormActionsCb);
1935
+
1936
+ // This chunk commit runs in an async loop OUTSIDE any render() window,
1937
+ // so open the renderer-write window explicitly: without it, committing a
1938
+ // stream chunk into a light slot host would hit the patched insertBefore /
1939
+ // removeChild and fold the renderer's own output into `authored`.
1940
+ commitInto(marker.parentNode, () => {
1941
+ if (state.mode === 'replace') {
1942
+ for (const n of state.nodes) {
1943
+ if (n.parentNode) n.parentNode.removeChild(n);
1944
+ }
1945
+ state.nodes = [];
1946
+ }
1947
+
1948
+ const frag = document.createDocumentFragment();
1949
+ for (const n of newNodes) frag.appendChild(n);
1950
+ marker.parentNode?.insertBefore(frag, marker);
1951
+ state.nodes.push(...newNodes);
1952
+ });
1953
+ });
1954
+ } catch (err) {
1955
+ // Stop the stream. The boundary is about to render an error state, and
1956
+ // appending later chunks into a region it may have replaced is not a
1957
+ // recovery. The rendered nodes are left alone: blanking the region is a
1958
+ // separate decision, and `teardownAsyncStream` is for the part being
1959
+ // reset, not for this.
1960
+ state.aborted = true;
1961
+ try { state.iterator.return?.()?.catch?.(() => {}); } catch {}
1962
+ // Rethrows when nothing can receive the error, which for a bare
1963
+ // `render()` into a plain container is an owner that carries no
1964
+ // `_handleRenderError` (the stamp records the container itself, so the
1965
+ // owner is present, just not a component). Surfacing beats swallowing
1966
+ // there, and it matches `watch` and `until`, which rethrow from their
1967
+ // own out-of-band handlers for the same reason. The exact shape differs
1968
+ // by site rather than being one thing: this rejects the loop's
1969
+ // promise, `until` rejects from its `.then`, and `watch` throws inside
1970
+ // a `queueMicrotask`, which is an uncaught error rather than a
1971
+ // rejection.
1972
+ reportOutOfBandCommitError(part, err);
1973
+ return;
1974
+ }
1975
+
1976
+ i++;
1977
+ }
1978
+ }
1979
+
1980
+ /**
1981
+ * Render a single value into a flat list of DOM nodes for insertion via
1982
+ * insertBefore. Handles strings, numbers, TemplateResult, and arrays.
1983
+ * @param {unknown} value
1984
+ * @returns {ChildNode[]}
1985
+ */
1986
+ function renderToNodes(value, reconcileFormActionsCb) {
1987
+ // #1443: the streamed / detached counterpart of the per-item unwrap above.
1988
+ // Recursion covers a nested array, since each level re-enters here.
1989
+ if (isLive(value)) value = /** @type any */ (value).value;
1990
+ if (value == null || value === false || value === true) return [];
1991
+ if (isTemplate(value)) {
1992
+ const tr = /** @type any */ (value);
1993
+ const { templateEl, parts, formActions } = compile(tr);
1994
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1995
+ const bound = parts.map((p) => bindPart(p, frag));
1996
+ for (let i = 0; i < tr.values.length; i++) {
1997
+ applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActionsCb);
1998
+ }
1999
+ if (reconcileFormActionsCb) reconcileFormActionsCb(formActions, bound, tr.values);
2000
+ // Slot parts need their one-shot apply here too (same contract as
2001
+ // createInstance / nested templates / buildDetached): the caller
2002
+ // (consumeAsyncStream) inserts these nodes synchronously in the same
2003
+ // task, so the slot-part's one-microtask finalize retry lands in the
2004
+ // live tree. Without this, a <slot> inside streamed chunk content never
2005
+ // finalizes and its name suppresses parking forever.
2006
+ for (const p of bound) {
2007
+ if (p.kind === 'slot') applyPart(p, undefined, undefined, [], reconcileFormActionsCb);
2008
+ }
2009
+ return [...frag.childNodes];
2010
+ }
2011
+ if (Array.isArray(value)) {
2012
+ const nodes = [];
2013
+ for (const v of value) nodes.push(...renderToNodes(v, reconcileFormActionsCb));
2014
+ return nodes;
2015
+ }
2016
+ return [document.createTextNode(String(value))];
2017
+ }
2018
+
2019
+ /**
2020
+ * Abort an async-stream directive. Sets `aborted = true` (so the next
2021
+ * `await iterator.next()` resolution short-circuits), removes all nodes
2022
+ * rendered so far, and explicitly calls `iterator.return()` so a
2023
+ * generator parked on `await` can unwind via its `finally` blocks
2024
+ * instead of leaking.
2025
+ * @param {AsyncStreamState} state
2026
+ */
2027
+ function teardownAsyncStream(state) {
2028
+ state.aborted = true;
2029
+ for (const n of state.nodes) {
2030
+ if (n.parentNode) n.parentNode.removeChild(n);
2031
+ }
2032
+ state.nodes = [];
2033
+ // Best-effort iterator cleanup. `.return()` is optional on AsyncIterators;
2034
+ // generators built via `async function*` provide it and run their
2035
+ // `finally` blocks. Swallow any rejection so teardown can't throw.
2036
+ try {
2037
+ state.iterator.return?.()?.catch?.(() => {});
2038
+ } catch { /* ignore */ }
2039
+ }