@webjsdev/core 0.7.1

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.
@@ -0,0 +1,1717 @@
1
+ import { isTemplate, MARKER } from './html.js';
2
+ import { escapeAttr } from './escape.js';
3
+ import { isRepeat } from './repeat.js';
4
+ import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
5
+ import { Signal } from './signal.js';
6
+ import {
7
+ LIGHT_SLOT_ATTR,
8
+ PROJECTION_ATTR,
9
+ PROJECTION_FALLBACK,
10
+ SLOT_FALLBACK_FRAG,
11
+ SLOT_STATE,
12
+ scheduleProjection,
13
+ moveSlotChildrenToPending,
14
+ ensureSlotState,
15
+ } from './slot.js';
16
+
17
+ /**
18
+ * Client-side renderer with **fine-grained** updates.
19
+ *
20
+ * Each TemplateResult is compiled once (keyed by the tagged-template's
21
+ * `strings` array identity, so reuse is free across renders) into:
22
+ * - a `<template>` element with static HTML + marker comments/attributes
23
+ * at each dynamic hole
24
+ * - a list of `Part` descriptors (kind + DOM location + attr/event name)
25
+ *
26
+ * On first render the template is cloned into the container and each Part
27
+ * is bound to the freshly-created node. Subsequent renders compare the new
28
+ * values to the last-applied values and only touch parts that changed.
29
+ * Text-position holes containing nested TemplateResults reuse the existing
30
+ * child instance when the inner `strings` match; they only rebuild when the
31
+ * template shape changes.
32
+ *
33
+ * Consequences worth knowing:
34
+ * - Input focus, cursor position, selection, and scroll inside components
35
+ * survive re-renders triggered by property assignments, signal changes,
36
+ * or `requestUpdate()`.
37
+ * - Event listeners are attached once and retargeted when the handler
38
+ * reference changes (swap-in-place via a dispatch closure, so `addEventListener`
39
+ * isn't churned every render).
40
+ * - This is a conservative implementation; no keyed list diffing yet: array
41
+ * changes rebuild the whole text part.
42
+ */
43
+
44
+ /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[] }>} */
45
+ const templateCache = new WeakMap();
46
+ const INSTANCE = Symbol.for('webjs.instance');
47
+
48
+ /**
49
+ * @typedef {{
50
+ * kind: 'child' | 'attr' | 'attr-mixed' | 'event' | 'prop' | 'bool' | 'element' | 'slot' | 'noop',
51
+ * path: number[],
52
+ * name?: string,
53
+ * statics?: string[],
54
+ * group?: number[],
55
+ * }} PartDescriptor
56
+ *
57
+ * @typedef {{
58
+ * strings: TemplateStringsArray | string[],
59
+ * bound: BoundPart[],
60
+ * lastValues: unknown[],
61
+ * startNode: Comment,
62
+ * endNode: Comment,
63
+ * }} TemplateInstance
64
+ *
65
+ * @typedef {
66
+ * | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
67
+ * | { kind: 'attr', el: Element, name: string }
68
+ * | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
69
+ * | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
70
+ * | { kind: 'prop', el: Element, name: string }
71
+ * | { kind: 'bool', el: Element, name: string }
72
+ * | { kind: 'element', el: Element, lastTarget?: any }
73
+ * | { kind: 'slot', slotEl: HTMLSlotElement, applied: boolean }
74
+ * | { kind: 'noop' }
75
+ * } BoundPart
76
+ */
77
+
78
+ /**
79
+ * Render a value into a container, reusing DOM where possible.
80
+ *
81
+ * @param {unknown} value
82
+ * @param {Element | DocumentFragment | ShadowRoot} container
83
+ */
84
+ export function render(value, container) {
85
+ const host = /** @type any */ (container);
86
+ const prev = host[INSTANCE];
87
+
88
+ if (isTemplate(value)) {
89
+ const tr = /** @type {import('./html.js').TemplateResult} */ (value);
90
+ if (prev && prev.strings === tr.strings) {
91
+ updateInstance(prev, tr.values);
92
+ return;
93
+ }
94
+ if (prev) clearInstance(prev, container);
95
+
96
+ // Light DOM hydration: if container has SSR content (marked by
97
+ // <!--webjs-hydrate-->), remove the marker and proceed with normal
98
+ // rendering. The content will be replaced with identical output -
99
+ // no visible flash because SSR and client render produce the same HTML.
100
+ const firstChild = container.firstChild;
101
+ if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
102
+ firstChild.remove();
103
+ }
104
+
105
+ const inst = createInstance(tr, container);
106
+ host[INSTANCE] = inst;
107
+ return;
108
+ }
109
+
110
+ // Non-template value: treat as a single text child.
111
+ if (prev) clearInstance(prev, container);
112
+ host[INSTANCE] = null;
113
+ container.replaceChildren();
114
+ if (value == null || value === false || value === true) return;
115
+ if (Array.isArray(value)) {
116
+ for (const v of value) {
117
+ const text = document.createTextNode(String(v ?? ''));
118
+ container.appendChild(text);
119
+ }
120
+ return;
121
+ }
122
+ container.appendChild(document.createTextNode(String(value)));
123
+ }
124
+
125
+ /* ================================================================
126
+ * Template compilation
127
+ * ================================================================ */
128
+
129
+ /** @param {import('./html.js').TemplateResult} tr */
130
+ function compile(tr) {
131
+ const { strings } = tr;
132
+ let cached = templateCache.get(strings);
133
+ if (cached) return cached;
134
+
135
+ /** @type {PartDescriptor[]} */
136
+ const parts = [];
137
+ let html = '';
138
+ let state = 'text';
139
+ let attrName = '';
140
+ let attrStart = 0;
141
+ let attrQuote = '';
142
+ let commentDashes = 0;
143
+ /** @type {{ name: string, firstPartIdx: number } | null} */
144
+ let mixedAttr = null;
145
+ let currentTag = '';
146
+ let rawTail = '';
147
+
148
+ for (let i = 0; i < strings.length; i++) {
149
+ const s = strings[i];
150
+ for (let j = 0; j < s.length; j++) {
151
+ const c = s[j];
152
+ switch (state) {
153
+ case 'text':
154
+ html += c;
155
+ if (c === '<') state = 'tag-open';
156
+ break;
157
+ case 'tag-open':
158
+ html += c;
159
+ if (c === '!') state = 'bang-1';
160
+ else if (c === '/') { state = 'tag-name'; currentTag = ''; }
161
+ else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
162
+ else state = 'text';
163
+ break;
164
+ case 'bang-1':
165
+ html += c;
166
+ state = c === '-' ? 'bang-dash' : 'tag-name';
167
+ break;
168
+ case 'bang-dash':
169
+ html += c;
170
+ if (c === '-') { state = 'comment'; commentDashes = 0; }
171
+ else state = 'tag-name';
172
+ break;
173
+ case 'comment':
174
+ html += c;
175
+ if (c === '-') commentDashes += 1;
176
+ else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
177
+ else commentDashes = 0;
178
+ break;
179
+ case 'tag-name':
180
+ html += c;
181
+ if (c === '>') {
182
+ state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
183
+ if (state === 'rawtext') rawTail = '';
184
+ } else if (/\s/.test(c)) state = 'in-tag';
185
+ else currentTag += c.toLowerCase();
186
+ break;
187
+ case 'in-tag':
188
+ html += c;
189
+ if (c === '>') {
190
+ state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
191
+ if (state === 'rawtext') rawTail = '';
192
+ } else if (!/\s/.test(c) && c !== '/') {
193
+ state = 'attr-name';
194
+ attrName = c;
195
+ attrStart = html.length - 1;
196
+ }
197
+ break;
198
+ case 'rawtext':
199
+ html += c;
200
+ rawTail = (rawTail + c.toLowerCase()).slice(-9);
201
+ if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
202
+ state = 'text';
203
+ rawTail = '';
204
+ currentTag = '';
205
+ }
206
+ break;
207
+ case 'attr-name':
208
+ if (c === '=') { state = 'after-eq'; html += c; }
209
+ else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
210
+ else if (c === '>') { state = 'text'; attrName = ''; html += c; }
211
+ else { attrName += c; html += c; }
212
+ break;
213
+ case 'after-eq':
214
+ if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; }
215
+ else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
216
+ else if (c === '>') { state = 'text'; attrName = ''; html += c; }
217
+ else { state = 'attr-unquoted'; html += c; }
218
+ break;
219
+ case 'attr-unquoted':
220
+ if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
221
+ else if (c === '>') { state = 'text'; attrName = ''; html += c; }
222
+ else html += c;
223
+ break;
224
+ case 'attr-quoted':
225
+ html += c;
226
+ if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
227
+ break;
228
+ case 'skip-attr':
229
+ // Consume mixed-attribute chars without appending to html.
230
+ // The attribute was replaced with a sentinel on the first hole.
231
+ if (c === attrQuote) {
232
+ // Closing quote: finalize the attr-mixed part.
233
+ if (mixedAttr) {
234
+ const idx0 = mixedAttr.firstPartIdx;
235
+ const group = [];
236
+ for (let k = idx0; k < parts.length; k++) {
237
+ if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k);
238
+ }
239
+ // Build statics from the template strings array.
240
+ // For `attr="a ${x} b ${y} c"`, group=[idx0,idx1].
241
+ // statics[0] = tail of strings[idx0] after the `="`
242
+ // statics[1] = strings[idx1] (between holes)
243
+ // statics[n] = prefix of strings[last+1] up to closing quote
244
+ const statics = [];
245
+ const s0 = strings[group[0]];
246
+ const qp = s0.lastIndexOf(attrQuote);
247
+ statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
248
+ for (let k = 1; k < group.length; k++) {
249
+ statics.push(strings[group[k]]);
250
+ }
251
+ const sLast = strings[group[group.length - 1] + 1];
252
+ const eq = sLast.indexOf(attrQuote);
253
+ statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);
254
+
255
+ parts[idx0] = {
256
+ kind: 'attr-mixed',
257
+ path: [],
258
+ name: mixedAttr.name,
259
+ statics,
260
+ group,
261
+ };
262
+ mixedAttr = null;
263
+ }
264
+ state = 'in-tag';
265
+ attrName = '';
266
+ }
267
+ break;
268
+ }
269
+ }
270
+
271
+ if (i < strings.length - 1) {
272
+ const partIdx = parts.length;
273
+ if (state === 'comment') {
274
+ // Holes inside <!-- ... --> are dropped. Comments are inert and
275
+ // the compile cache is keyed on `strings`, so per-render values
276
+ // can't be baked in anyway.
277
+ commentDashes = 0;
278
+ parts.push({ kind: 'noop', path: [] });
279
+ continue;
280
+ }
281
+ if (state === 'rawtext') {
282
+ // Inside <script>/<style>: per-render interpolation isn't supported;
283
+ // the compile cache would lock in whatever was first rendered. The
284
+ // hole is dropped and authors should set body text via a child part
285
+ // outside the raw-text container, or inline style/script directly.
286
+ rawTail = '';
287
+ parts.push({ kind: 'noop', path: [] });
288
+ continue;
289
+ }
290
+ if (state === 'text') {
291
+ // Child hole: insert a comment marker. Use bracketed markers so we can
292
+ // later walk all comments and find ours without ambiguity.
293
+ html += `<!--${MARKER}${partIdx}-->`;
294
+ parts.push({ kind: 'child', path: [] });
295
+ } else if (state === 'in-tag') {
296
+ // Element-position hole: `<tag ${expr}>`. Used by the `ref` directive
297
+ // (and any future element-bound directive). Emit a sentinel attribute
298
+ // on the current open tag; at bind time the attribute is stripped
299
+ // and the element is captured into the part.
300
+ const sentinel = `data-${MARKER}${partIdx}`;
301
+ html += `${sentinel}=""`;
302
+ parts.push({ kind: 'element', path: [] });
303
+ } else if (state === 'after-eq') {
304
+ const prefix = attrName[0];
305
+ const name = attrName.slice(1);
306
+ if (prefix === '@' || prefix === '.' || prefix === '?') {
307
+ // Strip the attribute name+"=" from html and add a sentinel attr.
308
+ html = html.slice(0, attrStart);
309
+ const kind = prefix === '@' ? 'event' : prefix === '.' ? 'prop' : 'bool';
310
+ const sentinel = `data-${MARKER}${partIdx}`;
311
+ html += `${sentinel}=""`;
312
+ parts.push({ kind, path: [], name });
313
+ } else {
314
+ // Regular attribute: rewrite to `attrName="__MARKER__"` and parse as attr.
315
+ html = html.slice(0, attrStart);
316
+ const sentinel = `data-${MARKER}${partIdx}`;
317
+ html += `${sentinel}=""`;
318
+ parts.push({ kind: 'attr', path: [], name: attrName });
319
+ }
320
+ state = 'in-tag';
321
+ attrName = '';
322
+ } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
323
+ // First hole inside a quoted attribute value: start mixed-attr tracking.
324
+ // Replace the entire attribute with a sentinel (same as regular attr).
325
+ html = html.slice(0, attrStart);
326
+ const sentinel = `data-${MARKER}${partIdx}`;
327
+ html += `${sentinel}=""`;
328
+ mixedAttr = { name: attrName, firstPartIdx: partIdx };
329
+ parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
330
+ state = 'skip-attr';
331
+ } else if (state === 'skip-attr') {
332
+ // Subsequent hole in the same mixed attribute.
333
+ parts.push({ kind: 'noop', path: [] });
334
+ }
335
+ }
336
+ }
337
+
338
+ const templateEl = document.createElement('template');
339
+ templateEl.innerHTML = html;
340
+
341
+ // Mark every <slot> in the template for framework projection and
342
+ // register a SLOT part for each so projectChildren can find them on
343
+ // clones. This runs BEFORE assignPaths so the sentinel attributes the
344
+ // discovery step adds are picked up in the same path-recording walk.
345
+ discoverSlots(templateEl.content, parts);
346
+
347
+ // Walk the parsed fragment and record DOM paths for each part.
348
+ assignPaths(templateEl.content, parts);
349
+
350
+ cached = { templateEl, parts };
351
+ templateCache.set(strings, cached);
352
+ return cached;
353
+ }
354
+
355
+ /**
356
+ * Walk the compiled template content for <slot> elements (the static ones
357
+ * written into the template, not dynamically-inserted ones). For each:
358
+ * 1. Add the `data-webjs-light` attribute so slot.js's polyfilled APIs
359
+ * recognise it as a framework-managed light-DOM slot.
360
+ * 2. Add a sentinel attribute (`data-MARKER<idx>`) so the subsequent
361
+ * assignPaths walk records the slot's path into the new SLOT part.
362
+ * 3. Move the slot's authored children into a `fallbackTemplate`
363
+ * DocumentFragment stored on the PartDescriptor. The slot in the
364
+ * cached template becomes empty, so every clone starts empty too.
365
+ * bindPart clones a fresh fallback fragment per instance from this
366
+ * template, giving each instance an independent fallback supply
367
+ * that slot.js swaps in via the SLOT_FALLBACK_FRAG symbol.
368
+ *
369
+ * Fallback content with template holes (`<slot>fallback ${x}</slot>`)
370
+ * is captured as a static-HTML snapshot of the template state at
371
+ * compile time. Dynamic holes inside fallback content are not
372
+ * re-bound per instance in v1; authors should put dynamic content
373
+ * outside the slot.
374
+ *
375
+ * @param {DocumentFragment} root
376
+ * @param {PartDescriptor[]} parts
377
+ */
378
+ function discoverSlots(root, parts) {
379
+ const slots = root.querySelectorAll('slot');
380
+ for (const slot of slots) {
381
+ slot.setAttribute(LIGHT_SLOT_ATTR, '');
382
+ const partIdx = parts.length;
383
+ slot.setAttribute(`data-${MARKER}${partIdx}`, '');
384
+ parts.push({ kind: 'slot', path: [] });
385
+ }
386
+ // NOTE: fallback content stays IN the slot's children in the cached
387
+ // template. Each clone gets its own copy. For shadow-DOM components,
388
+ // native browser projection uses those children as fallback content
389
+ // when no light child matches. For light-DOM components, the slot's
390
+ // apply step (run after the cloned template is in the live tree)
391
+ // moves the cloned fallback into a per-instance holding fragment
392
+ // owned by slot.js, so the slot is empty and ready to receive
393
+ // projected children. Doing the strip at apply time, not at compile
394
+ // time, lets a single cached template serve both DOM modes.
395
+ }
396
+
397
+ /**
398
+ * Walk the template fragment and record the path (chain of child indices) to
399
+ * each part's anchor node. We use marker comments for child parts and sentinel
400
+ * attributes for everything else.
401
+ *
402
+ * @param {DocumentFragment} root
403
+ * @param {PartDescriptor[]} parts
404
+ */
405
+ function assignPaths(root, parts) {
406
+ /** @type {number[]} */
407
+ const path = [];
408
+ /** @param {Node} node */
409
+ function visit(node) {
410
+ for (let i = 0; i < node.childNodes.length; i++) {
411
+ const child = node.childNodes[i];
412
+ path.push(i);
413
+ // Comment marker?
414
+ if (child.nodeType === 8) {
415
+ const txt = /** @type Comment */ (child).data;
416
+ if (txt.startsWith(MARKER)) {
417
+ const idx = Number(txt.slice(MARKER.length));
418
+ if (parts[idx] && parts[idx].kind === 'child') {
419
+ parts[idx].path = path.slice();
420
+ }
421
+ }
422
+ } else if (child.nodeType === 1) {
423
+ const el = /** @type Element */ (child);
424
+ // Sentinel attribute?
425
+ const toRemove = [];
426
+ for (const attr of el.attributes) {
427
+ if (attr.name.startsWith(`data-${MARKER}`)) {
428
+ const idx = Number(attr.name.slice(`data-${MARKER}`.length));
429
+ if (parts[idx] && parts[idx].kind !== 'child') {
430
+ parts[idx].path = path.slice();
431
+ }
432
+ toRemove.push(attr.name);
433
+ }
434
+ }
435
+ for (const a of toRemove) el.removeAttribute(a);
436
+ visit(child);
437
+ }
438
+ path.pop();
439
+ }
440
+ }
441
+ visit(root);
442
+ }
443
+
444
+ /* ================================================================
445
+ * Instance lifecycle
446
+ * ================================================================ */
447
+
448
+ /**
449
+ * @param {import('./html.js').TemplateResult} tr
450
+ * @param {Element | DocumentFragment | ShadowRoot} container
451
+ */
452
+ function createInstance(tr, container) {
453
+ const { templateEl, parts } = compile(tr);
454
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
455
+
456
+ // Bookend markers bound the instance so we can tear it down cleanly.
457
+ const startNode = document.createComment(`${MARKER}s`);
458
+ const endNode = document.createComment(`${MARKER}e`);
459
+
460
+ const bound = parts.map((p) => bindPart(p, frag));
461
+ const lastValues = [];
462
+ for (let i = 0; i < tr.values.length; i++) {
463
+ applyPart(bound[i], tr.values[i], undefined, tr.values);
464
+ lastValues.push(tr.values[i]);
465
+ }
466
+
467
+ /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);
468
+
469
+ // Slot parts have no value-hole to drive applyPart from the loop above.
470
+ // Apply them once now that the fragment is inserted into the live
471
+ // container, so each slot can locate its host by walking parents and
472
+ // schedule the first projection through slot.js.
473
+ for (const part of bound) {
474
+ if (part.kind === 'slot') applyPart(part, undefined, undefined, []);
475
+ }
476
+
477
+ return { strings: tr.strings, bound, lastValues, startNode, endNode };
478
+ }
479
+
480
+ /**
481
+ * @param {PartDescriptor} p
482
+ * @param {DocumentFragment | Element} root
483
+ * @returns {BoundPart}
484
+ */
485
+ function bindPart(p, root) {
486
+ if (p.kind === 'noop') return /** @type any */ ({ kind: 'noop' });
487
+ let node = /** @type Node */ (root);
488
+ for (const i of p.path) node = node.childNodes[i];
489
+ if (p.kind === 'child') {
490
+ return { kind: 'child', marker: /** @type Comment */ (node) };
491
+ }
492
+ const el = /** @type Element */ (node);
493
+ if (p.kind === 'event') {
494
+ /** @type {BoundPart} */
495
+ const part = {
496
+ kind: 'event',
497
+ el,
498
+ name: p.name || '',
499
+ handler: null,
500
+ // The dispatcher is the registered listener; handler swaps behind it.
501
+ dispatcher(ev) { part.handler?.(ev); },
502
+ };
503
+ el.addEventListener(part.name, part.dispatcher);
504
+ return part;
505
+ }
506
+ if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
507
+ if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
508
+ if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
509
+ if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
510
+ if (p.kind === 'element') return { kind: 'element', el };
511
+ if (p.kind === 'slot') {
512
+ const slotEl = /** @type {HTMLSlotElement} */ (el);
513
+ // Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
514
+ // time so we know whether the slot is light or shadow at the point
515
+ // where the decision matters. At bind time the cloned slot still
516
+ // holds its fallback content from the template clone; we just
517
+ // record the slot ref.
518
+ return { kind: 'slot', slotEl, applied: false };
519
+ }
520
+ throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
521
+ }
522
+
523
+ /**
524
+ * @param {TemplateInstance} inst
525
+ * @param {unknown[]} values
526
+ */
527
+ function updateInstance(inst, values) {
528
+ for (let i = 0; i < values.length; i++) {
529
+ const next = values[i];
530
+ if (Object.is(next, inst.lastValues[i])) continue;
531
+ applyPart(inst.bound[i], next, inst.lastValues[i], values);
532
+ inst.lastValues[i] = next;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * @param {TemplateInstance} inst
538
+ * @param {Element | DocumentFragment | ShadowRoot} container
539
+ */
540
+ function clearInstance(inst, container) {
541
+ // Dispose event listeners on event parts, unbind active refs on
542
+ // element parts, and rescue any projected children sitting inside
543
+ // slot parts so they survive teardown of a collapsing conditional
544
+ // fragment.
545
+ for (const p of inst.bound) {
546
+ if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
547
+ if (p.kind === 'element') {
548
+ const prev = /** @type any */ (p).lastTarget;
549
+ if (prev) {
550
+ if (typeof prev === 'function') {
551
+ try { prev(undefined); } catch { /* swallow */ }
552
+ } else if (typeof prev === 'object') {
553
+ prev.value = undefined;
554
+ }
555
+ /** @type any */ (p).lastTarget = undefined;
556
+ /** @type any */ (p).__lastEl = undefined;
557
+ }
558
+ }
559
+ if (p.kind === 'slot') {
560
+ const host = findSlotHost(p.slotEl);
561
+ if (host) moveSlotChildrenToPending(host, p.slotEl);
562
+ }
563
+ }
564
+ /** @type any */ (container).replaceChildren();
565
+ }
566
+
567
+ /* ================================================================
568
+ * Part application
569
+ * ================================================================ */
570
+
571
+ /**
572
+ * @param {BoundPart} part
573
+ * @param {unknown} value
574
+ * @param {unknown} _prev
575
+ */
576
+ function applyPart(part, value, _prev, allValues) {
577
+ // Unwrap live() to dirty-check against the live DOM value, not the
578
+ // last rendered value. Essential for <input> two-way binding.
579
+ if (isLive(value)) {
580
+ const liveVal = /** @type any */ (value).value;
581
+ if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
582
+ if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
583
+ if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
584
+ value = liveVal;
585
+ }
586
+
587
+ switch (part.kind) {
588
+ case 'child':
589
+ applyChild(part, value);
590
+ break;
591
+ case 'attr': {
592
+ if (value == null || value === false) part.el.removeAttribute(part.name);
593
+ else part.el.setAttribute(part.name, String(value));
594
+ break;
595
+ }
596
+ case 'prop':
597
+ /** @type any */ (part.el)[part.name] = value;
598
+ break;
599
+ case 'bool':
600
+ if (value) part.el.setAttribute(part.name, '');
601
+ else part.el.removeAttribute(part.name);
602
+ break;
603
+ case 'event':
604
+ part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
605
+ break;
606
+ case 'element':
607
+ applyElement(part, value);
608
+ break;
609
+ case 'attr-mixed': {
610
+ // Reconstruct the attribute from static pieces + all dynamic values.
611
+ const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
612
+ let val = mp.statics[0];
613
+ for (let j = 0; j < mp.group.length; j++) {
614
+ val += String((allValues ? allValues[mp.group[j]] : value) ?? '');
615
+ val += mp.statics[j + 1] || '';
616
+ }
617
+ part.el.setAttribute(part.name, val);
618
+ break;
619
+ }
620
+ case 'slot': {
621
+ // Slot parts have no template-hole value to apply. The "apply" is
622
+ // a one-shot trigger that runs after the template fragment has
623
+ // been inserted into the host's render root. At this point the
624
+ // slot's parent chain reveals whether it lives inside a shadow
625
+ // root (browser native projection) or in light DOM (framework
626
+ // projection). For shadow-DOM slots we leave the cloned fallback
627
+ // in place. For light-DOM slots we move the fallback into a
628
+ // per-slot holding fragment owned by slot.js (via the
629
+ // SLOT_FALLBACK_FRAG symbol) so the slot can receive projected
630
+ // children, and we kick off projection.
631
+ //
632
+ // For NESTED templates (a slot inside `${cond ? html`<slot/>` : ''}`),
633
+ // the slot's parent chain at first apply may still lead through
634
+ // an unattached fragment. findSlotHost returns null then; we
635
+ // retry on the next microtask, by which point the outer's
636
+ // replaceChildren has placed the entire tree into the host.
637
+ if (part.applied) break;
638
+ const slotEl = part.slotEl;
639
+ const finalize = () => {
640
+ part.applied = true;
641
+ const host = findSlotHost(slotEl);
642
+ if (!host) return; // truly orphan slot
643
+ // Shadow DOM: native projection. Leave fallback in place.
644
+ if (isInShadowRootEl(slotEl)) return;
645
+ // Light DOM: harvest the cloned fallback into a holding
646
+ // fragment, then trigger projection.
647
+ const frag = document.createDocumentFragment();
648
+ while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
649
+ /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
650
+ scheduleProjection(host);
651
+ };
652
+ const directHost = findSlotHost(slotEl);
653
+ if (directHost) {
654
+ finalize();
655
+ } else {
656
+ queueMicrotask(finalize);
657
+ }
658
+ break;
659
+ }
660
+ case 'noop':
661
+ // intentionally empty: used for holes inside HTML comments
662
+ break;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Walk a slot element's parent chain looking for a WebComponent host
668
+ * (an element that has slot state initialised). Used by the slot-part's
669
+ * apply and teardown steps to coordinate with slot.js.
670
+ *
671
+ * @param {HTMLSlotElement} slotEl
672
+ * @returns {Element | null}
673
+ */
674
+ function findSlotHost(slotEl) {
675
+ let p = slotEl.parentElement;
676
+ while (p) {
677
+ if (/** @type any */ (p)[SLOT_STATE]) return p;
678
+ p = p.parentElement;
679
+ }
680
+ return null;
681
+ }
682
+
683
+ /**
684
+ * True when an element is inside a shadow root (so native browser slot
685
+ * projection applies). Mirrors slot.js's helper; duplicated here to
686
+ * avoid the round trip through the slot.js public surface for this
687
+ * hot path.
688
+ * @param {Element} el
689
+ * @returns {boolean}
690
+ */
691
+ function isInShadowRootEl(el) {
692
+ let n = /** @type {Node} */ (el);
693
+ for (let depth = 0; depth < 128; depth++) {
694
+ const parent = n.parentNode;
695
+ if (!parent) return false;
696
+ if (parent === n) return false;
697
+ if (/** @type any */ (parent).host) return true;
698
+ n = parent;
699
+ }
700
+ return false;
701
+ }
702
+
703
+ /**
704
+ * Child (text-position) part. Replace the marker's surrounding nodes with the
705
+ * new value's rendered form. Nested TemplateResults get an instance with its
706
+ * own parts; we reuse on `strings` identity.
707
+ *
708
+ * @param {Extract<BoundPart, {kind:'child'}>} part
709
+ * @param {unknown} value
710
+ */
711
+ /**
712
+ * Apply a value at an element-position part (`<tag ${expr}>`). The
713
+ * sole supported directive here is `ref(refOrCallback)` and
714
+ * `createRef()`. Other values are ignored so a stray non-ref hole
715
+ * doesn't crash. Tracks the prior target so a change from one ref to
716
+ * another correctly unsets the old target before binding the new one.
717
+ *
718
+ * @param {Extract<BoundPart, {kind:'element'}>} part
719
+ * @param {unknown} value
720
+ */
721
+ function applyElement(part, value) {
722
+ // Matches lit-html's RefDirective.update():
723
+ // 1. If the ref target changed since last render, unbind the prior one.
724
+ // 2. If the ref target OR the element identity changed, bind the new
725
+ // (ref, element) pair. If both are stable, skip entirely.
726
+ // For callback refs, an unset-before-bind cycle runs whenever the
727
+ // same callback is now pointing at a different element.
728
+ const partAny = /** @type any */ (part);
729
+ const nextTarget = isRef(value) ? /** @type any */ (value).target : undefined;
730
+ const prevTarget = partAny.__refTarget;
731
+ const refChanged = nextTarget !== prevTarget;
732
+
733
+ if (refChanged && prevTarget) {
734
+ if (typeof prevTarget === 'function') {
735
+ try { prevTarget(undefined); } catch { /* swallow */ }
736
+ } else if (typeof prevTarget === 'object') {
737
+ prevTarget.value = undefined;
738
+ }
739
+ }
740
+
741
+ if (refChanged || partAny.__refElement !== part.el) {
742
+ partAny.__refTarget = nextTarget;
743
+ if (nextTarget) {
744
+ if (typeof nextTarget === 'function') {
745
+ // Same callback now pointing at a different element: deliver
746
+ // an `undefined` cleanup for the prior element first.
747
+ if (!refChanged && partAny.__refElement !== undefined) {
748
+ try { nextTarget(undefined); } catch { /* swallow */ }
749
+ }
750
+ try { nextTarget(part.el); } catch { /* swallow */ }
751
+ } else if (typeof nextTarget === 'object') {
752
+ nextTarget.value = part.el;
753
+ }
754
+ }
755
+ partAny.__refElement = part.el;
756
+ // Keep the legacy `lastTarget` field in sync for clearInstance /
757
+ // disposeInstance which read it for template-disposal cleanup.
758
+ part.lastTarget = nextTarget;
759
+ }
760
+ }
761
+
762
+ function applyChild(part, value) {
763
+ // Drop directive state from prior renders when the new value is for a
764
+ // different directive (or no directive at all). Keeps __untilState
765
+ // from leaking across replacements, __guardDeps from causing a stale
766
+ // short-circuit, etc. Done once per outermost applyChild call; the
767
+ // directive handlers recurse via applyChildInner (no re-clear) so
768
+ // their own state survives the recursion.
769
+ clearStaleDirectiveState(part, value);
770
+ return applyChildInner(part, value);
771
+ }
772
+
773
+ /**
774
+ * Internal dispatch. Used both by `applyChild` (which first clears
775
+ * stale per-part directive state) and by directive handlers that
776
+ * recurse with a different value at the same part. Recursing via
777
+ * `applyChild` would clear the directive state that was just set,
778
+ * because the inner value almost always isn't itself a directive.
779
+ *
780
+ * @param {Extract<BoundPart, {kind:'child'}>} part
781
+ * @param {unknown} value
782
+ */
783
+ function applyChildInner(part, value) {
784
+ const marker = part.marker;
785
+
786
+ // unsafeHTML directive: inject raw HTML string as DOM nodes.
787
+ if (isUnsafeHTML(value)) {
788
+ teardownChild(part);
789
+ const htmlStr = String(/** @type any */ (value).value ?? '');
790
+ const template = document.createElement('template');
791
+ template.innerHTML = htmlStr;
792
+ const nodes = [...template.content.childNodes];
793
+ const frag = document.createDocumentFragment();
794
+ for (const n of nodes) frag.appendChild(n);
795
+ marker.parentNode?.insertBefore(frag, marker);
796
+ part.child = nodes;
797
+ return;
798
+ }
799
+
800
+ // keyed directive: when key changes, tear down and remount fresh DOM.
801
+ // When the key matches, recurse so the standard template-reconciliation
802
+ // path can update the existing DOM in place.
803
+ if (isKeyed(value)) {
804
+ const v = /** @type any */ (value);
805
+ const prevKey = /** @type any */ (part).__keyedKey;
806
+ if (prevKey !== undefined && !Object.is(prevKey, v.key)) {
807
+ teardownChild(part);
808
+ }
809
+ /** @type any */ (part).__keyedKey = v.key;
810
+ applyChildInner(part, v.value);
811
+ return;
812
+ }
813
+
814
+ // guard directive: skip re-evaluation when the deps array is shallow-
815
+ // equal to the prior call. Stored deps live on the part so they
816
+ // persist across renders that reuse the same template (and thus the
817
+ // same part).
818
+ if (isGuard(value)) {
819
+ const v = /** @type any */ (value);
820
+ const prevDeps = /** @type any */ (part).__guardDeps;
821
+ const nextDeps = v.deps;
822
+ // Accept any value for deps. When deps is an array, compare shallowly.
823
+ // When it's a primitive (number, string, undefined), compare with
824
+ // Object.is. Mirrors lit-html's tolerance for non-array deps so user
825
+ // code like `guard(this.id, () => ...)` works without crashing.
826
+ if (prevDeps !== undefined) {
827
+ const equal = Array.isArray(prevDeps) && Array.isArray(nextDeps)
828
+ ? shallowEqualArray(prevDeps, nextDeps)
829
+ : Object.is(prevDeps, nextDeps);
830
+ if (equal) return;
831
+ }
832
+ /** @type any */ (part).__guardDeps = Array.isArray(nextDeps)
833
+ ? nextDeps.slice()
834
+ : nextDeps;
835
+ applyChildInner(part, v.fn());
836
+ return;
837
+ }
838
+
839
+ // templateContent directive: clone the content of a <template> element.
840
+ if (isTemplateContent(value)) {
841
+ teardownChild(part);
842
+ const tpl = /** @type any */ (value).template;
843
+ if (tpl && tpl.content) {
844
+ const frag = tpl.content.cloneNode(true);
845
+ const nodes = [...frag.childNodes];
846
+ marker.parentNode?.insertBefore(frag, marker);
847
+ part.child = nodes;
848
+ }
849
+ return;
850
+ }
851
+
852
+ // ref directive in a child position: no DOM produced. Element-position
853
+ // refs are bound via element parts; a stray ref() in a child position
854
+ // is a no-op for compatibility.
855
+ if (isRef(value)) {
856
+ return;
857
+ }
858
+
859
+ // cache directive: real DOM retention. When the inner value changes
860
+ // to a different template shape, detach (rather than destroy) the
861
+ // current DOM and stash it keyed by its template strings. When a
862
+ // previously-cached shape returns, re-attach it before the marker
863
+ // and reconcile values. Preserves input state, scroll, focus across
864
+ // toggles between sub-templates (e.g. tab interfaces).
865
+ if (isCache(value)) {
866
+ return applyCache(part, /** @type any */ (value).value);
867
+ }
868
+
869
+ // until directive: render the highest-priority resolved value among
870
+ // the candidates. Synchronous values are rendered immediately; Promises
871
+ // are awaited in the background and applied if no higher-priority
872
+ // candidate has resolved yet. When the marker is torn down, in-flight
873
+ // priorities are cleared so late resolves cannot overwrite later DOM.
874
+ if (isUntil(value)) {
875
+ return applyUntil(part, /** @type any */ (value).args);
876
+ }
877
+
878
+ // watch directive: bind a part to a signal. Reads the signal at
879
+ // render time and subscribes the part to changes. When the signal
880
+ // fires, only this part updates; the host component's render() does
881
+ // not re-run. The signal read inside the watcher's observe is
882
+ // tracked against the part's private Watcher, NOT the host's render
883
+ // watcher (so the host doesn't subscribe to a full re-render too).
884
+ if (isWatch(value)) {
885
+ return applyWatch(part, /** @type any */ (value).signal);
886
+ }
887
+
888
+ // asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
889
+ // yielded value is mapped (optional) and appended (asyncAppend) or
890
+ // replaces (asyncReplace) the prior content. Teardown aborts the
891
+ // iteration so leaked iterators do not keep references to detached
892
+ // DOM.
893
+ if (isAsyncAppend(value)) {
894
+ return applyAsyncAppend(part, /** @type any */ (value));
895
+ }
896
+ if (isAsyncReplace(value)) {
897
+ return applyAsyncReplace(part, /** @type any */ (value));
898
+ }
899
+
900
+ // Repeat directive: keyed reconciliation. Keep previous state when both
901
+ // old and new are repeats; otherwise tear down and rebuild.
902
+ if (isRepeat(value)) {
903
+ if (part.child && /** @type any */ (part.child).kind === 'repeat') {
904
+ reconcileRepeat(part, value);
905
+ return;
906
+ }
907
+ teardownChild(part);
908
+ const state = { kind: 'repeat', map: new Map() };
909
+ part.child = state;
910
+ applyRepeatFresh(marker, state, value);
911
+ return;
912
+ }
913
+
914
+ // Remove previously rendered nodes between marker and its next sibling we own.
915
+ if (part.child) {
916
+ const c = /** @type any */ (part.child);
917
+ if (c.kind === 'repeat') {
918
+ teardownRepeat(c);
919
+ part.child = undefined;
920
+ } else if (c.kind === 'async-stream') {
921
+ teardownAsyncStream(c);
922
+ part.child = undefined;
923
+ } else if ('strings' in /** @type any */ (part.child)) {
924
+ // Previous was a TemplateInstance.
925
+ const inst = /** @type TemplateInstance */ (part.child);
926
+ if (isTemplate(value) && inst.strings === /** @type any */ (value).strings) {
927
+ updateInstance(inst, /** @type any */ (value).values);
928
+ return;
929
+ }
930
+ removeBetween(inst.startNode, inst.endNode);
931
+ part.child = undefined;
932
+ } else {
933
+ // Previous was ChildNode[]: remove each node we inserted.
934
+ for (const n of /** @type ChildNode[] */ (part.child)) {
935
+ if (n.parentNode) n.parentNode.removeChild(n);
936
+ }
937
+ part.child = undefined;
938
+ }
939
+ }
940
+
941
+ if (value == null || value === false || value === true) return;
942
+
943
+ if (isTemplate(value)) {
944
+ const tr = /** @type any */ (value);
945
+ const { templateEl, parts } = compile(tr);
946
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
947
+ const startNode = document.createComment(`${MARKER}s`);
948
+ const endNode = document.createComment(`${MARKER}e`);
949
+ const bound = parts.map((p) => bindPart(p, frag));
950
+ const lastValues = [];
951
+ for (let i = 0; i < tr.values.length; i++) {
952
+ applyPart(bound[i], tr.values[i], undefined, tr.values);
953
+ lastValues.push(tr.values[i]);
954
+ }
955
+ const nodes = [startNode, ...frag.childNodes, endNode];
956
+ marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
957
+ // Slot parts in this nested template need their one-shot apply just
958
+ // like createInstance does for top-level templates. The slot is now
959
+ // in the live tree (insertBefore above) so its parent walk can
960
+ // reach the host. Without this loop, conditional / nested templates
961
+ // with <slot> inside never trigger projection.
962
+ for (const p of bound) {
963
+ if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
964
+ }
965
+ part.child = { strings: tr.strings, bound, lastValues, startNode, endNode };
966
+ return;
967
+ }
968
+
969
+ if (Array.isArray(value)) {
970
+ const list = [];
971
+ for (const v of value) {
972
+ if (isTemplate(v)) {
973
+ // Create an inline instance. No keyed reconciliation yet.
974
+ const tr = /** @type any */ (v);
975
+ const { templateEl, parts } = compile(tr);
976
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
977
+ const bound = parts.map((p) => bindPart(p, frag));
978
+ for (let i = 0; i < tr.values.length; i++) {
979
+ applyPart(bound[i], tr.values[i], undefined, tr.values);
980
+ }
981
+ list.push(...frag.childNodes);
982
+ } else if (v != null && v !== false && v !== true) {
983
+ list.push(document.createTextNode(String(v)));
984
+ }
985
+ }
986
+ const frag = nodesToFrag(list);
987
+ marker.parentNode?.insertBefore(frag, marker);
988
+ part.child = list;
989
+ return;
990
+ }
991
+
992
+ const node = document.createTextNode(String(value));
993
+ marker.parentNode?.insertBefore(node, marker);
994
+ part.child = [node];
995
+ }
996
+
997
+ /** @param {ChildNode[]} nodes */
998
+ function nodesToFrag(nodes) {
999
+ const frag = document.createDocumentFragment();
1000
+ for (const n of nodes) frag.appendChild(n);
1001
+ return frag;
1002
+ }
1003
+
1004
+ /** @param {Node} start @param {Node} end */
1005
+ function removeBetween(start, end) {
1006
+ if (!start.parentNode) return;
1007
+ let n = start;
1008
+ while (n && n !== end) {
1009
+ const next = n.nextSibling;
1010
+ n.parentNode?.removeChild(n);
1011
+ n = next;
1012
+ }
1013
+ if (end.parentNode === start.parentNode) end.parentNode?.removeChild(end);
1014
+ }
1015
+
1016
+ /* ================================================================
1017
+ * Keyed list (repeat) support
1018
+ * ================================================================ */
1019
+
1020
+ /**
1021
+ * Build a TemplateInstance whose nodes (including bookends) live in a
1022
+ * document fragment that the caller will insert wherever it wants.
1023
+ * @param {import('./html.js').TemplateResult} tr
1024
+ * @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
1025
+ */
1026
+ function buildDetached(tr) {
1027
+ const { templateEl, parts } = compile(tr);
1028
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1029
+ const startNode = document.createComment(`${MARKER}s`);
1030
+ const endNode = document.createComment(`${MARKER}e`);
1031
+ const bound = parts.map((p) => bindPart(p, frag));
1032
+ const lastValues = [];
1033
+ for (let i = 0; i < tr.values.length; i++) {
1034
+ applyPart(bound[i], tr.values[i], undefined, tr.values);
1035
+ lastValues.push(tr.values[i]);
1036
+ }
1037
+ const outFrag = document.createDocumentFragment();
1038
+ outFrag.appendChild(startNode);
1039
+ while (frag.firstChild) outFrag.appendChild(frag.firstChild);
1040
+ outFrag.appendChild(endNode);
1041
+ return {
1042
+ inst: { strings: tr.strings, bound, lastValues, startNode, endNode },
1043
+ frag: outFrag,
1044
+ };
1045
+ }
1046
+
1047
+ /** @param {TemplateInstance} inst */
1048
+ function disposeInstance(inst) {
1049
+ for (const p of inst.bound) {
1050
+ if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
1051
+ if (p.kind === 'element') {
1052
+ // Unbind any active ref so the user observes the element being
1053
+ // removed (callback receives undefined / Ref.value cleared).
1054
+ // Mirrors lit-html's cleanup-on-disconnect for element parts.
1055
+ const prev = /** @type any */ (p).lastTarget;
1056
+ if (prev) {
1057
+ if (typeof prev === 'function') {
1058
+ try { prev(undefined); } catch { /* swallow */ }
1059
+ } else if (typeof prev === 'object') {
1060
+ prev.value = undefined;
1061
+ }
1062
+ /** @type any */ (p).lastTarget = undefined;
1063
+ /** @type any */ (p).__lastEl = undefined;
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+
1069
+ /**
1070
+ * Initial fresh render of a repeat directive. Inserts all items' nodes
1071
+ * immediately before the part's marker comment.
1072
+ *
1073
+ * @param {Comment} marker
1074
+ * @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state
1075
+ * @param {any} value
1076
+ */
1077
+ function applyRepeatFresh(marker, state, value) {
1078
+ const { items, keyFn, templateFn } = value;
1079
+ const parent = marker.parentNode;
1080
+ if (!parent) return;
1081
+ const bulk = document.createDocumentFragment();
1082
+ for (let i = 0; i < items.length; i++) {
1083
+ const key = keyFn(items[i], i);
1084
+ const tr = templateFn(items[i], i);
1085
+ if (!isTemplate(tr)) continue;
1086
+ const { inst, frag } = buildDetached(/** @type any */ (tr));
1087
+ state.map.set(key, inst);
1088
+ bulk.appendChild(frag);
1089
+ }
1090
+ parent.insertBefore(bulk, marker);
1091
+ }
1092
+
1093
+ /**
1094
+ * Keyed reconciliation. For each key in the new list:
1095
+ * - hit: update the existing instance in place (if template shape matches),
1096
+ * then move its nodes into position
1097
+ * - miss: build a new instance and insert
1098
+ * Finally drop instances whose keys aren't in the new list.
1099
+ *
1100
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1101
+ * @param {any} value
1102
+ */
1103
+ function reconcileRepeat(part, value) {
1104
+ const marker = part.marker;
1105
+ const parent = marker.parentNode;
1106
+ if (!parent) return;
1107
+ const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
1108
+ const { items, keyFn, templateFn } = value;
1109
+
1110
+ const newMap = new Map();
1111
+
1112
+ // Walk the new list and position each item's nodes immediately before the marker.
1113
+ for (let i = 0; i < items.length; i++) {
1114
+ const key = keyFn(items[i], i);
1115
+ const tr = templateFn(items[i], i);
1116
+ if (!isTemplate(tr)) continue;
1117
+ const existing = state.map.get(key);
1118
+ if (existing && existing.strings === /** @type any */ (tr).strings) {
1119
+ updateInstance(existing, /** @type any */ (tr).values);
1120
+ // Move nodes before marker preserving element identity.
1121
+ moveRange(existing.startNode, existing.endNode, parent, marker);
1122
+ newMap.set(key, existing);
1123
+ state.map.delete(key);
1124
+ } else {
1125
+ if (existing) {
1126
+ disposeInstance(existing);
1127
+ removeBetween(existing.startNode, existing.endNode);
1128
+ state.map.delete(key);
1129
+ }
1130
+ const { inst, frag } = buildDetached(/** @type any */ (tr));
1131
+ parent.insertBefore(frag, marker);
1132
+ newMap.set(key, inst);
1133
+ }
1134
+ }
1135
+
1136
+ // Remove any keys that remain in the old map.
1137
+ for (const inst of state.map.values()) {
1138
+ disposeInstance(inst);
1139
+ removeBetween(inst.startNode, inst.endNode);
1140
+ }
1141
+ state.map = newMap;
1142
+ }
1143
+
1144
+ /** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
1145
+ function teardownRepeat(state) {
1146
+ for (const inst of state.map.values()) {
1147
+ disposeInstance(inst);
1148
+ removeBetween(inst.startNode, inst.endNode);
1149
+ }
1150
+ state.map.clear();
1151
+ }
1152
+
1153
+ /**
1154
+ * Collect [start .. end] (inclusive) and insert immediately before `anchor`.
1155
+ * Browsers treat insertBefore of an already-connected node as a move and
1156
+ * preserve element identity + focus.
1157
+ *
1158
+ * @param {Node} start
1159
+ * @param {Node} end
1160
+ * @param {Node} parent
1161
+ * @param {Node} anchor
1162
+ */
1163
+ function moveRange(start, end, parent, anchor) {
1164
+ // No-op if the range is already immediately before the anchor.
1165
+ if (end.nextSibling === anchor && start.parentNode === parent) return;
1166
+ const frag = document.createDocumentFragment();
1167
+ let n = start;
1168
+ while (n) {
1169
+ const next = n.nextSibling;
1170
+ frag.appendChild(n);
1171
+ if (n === end) break;
1172
+ n = next;
1173
+ }
1174
+ parent.insertBefore(frag, anchor);
1175
+ }
1176
+
1177
+ /**
1178
+ * Shallow array equality (Object.is on each element). Used by the
1179
+ * `guard` directive to skip re-evaluation when deps are unchanged.
1180
+ * @param {readonly unknown[]} a
1181
+ * @param {readonly unknown[]} b
1182
+ */
1183
+ function shallowEqualArray(a, b) {
1184
+ if (a === b) return true;
1185
+ if (!a || !b || a.length !== b.length) return false;
1186
+ for (let i = 0; i < a.length; i++) {
1187
+ if (!Object.is(a[i], b[i])) return false;
1188
+ }
1189
+ return true;
1190
+ }
1191
+
1192
+ /** @param {Extract<BoundPart, {kind:'child'}>} part */
1193
+ function teardownChild(part) {
1194
+ // Always abort any in-flight directive state on the part, even if
1195
+ // `part.child` itself is something else (e.g. an `until` directive
1196
+ // installed __untilState but applyChild's recursion overwrote
1197
+ // part.child to the rendered fallback shape).
1198
+ const partAny = /** @type any */ (part);
1199
+ if (partAny.__untilState) {
1200
+ partAny.__untilState.aborted = true;
1201
+ partAny.__untilState = undefined;
1202
+ }
1203
+ if (partAny.__watchSub) {
1204
+ teardownWatch(partAny);
1205
+ }
1206
+
1207
+ if (!part.child) return;
1208
+ const c = /** @type any */ (part.child);
1209
+ if (c.kind === 'repeat') {
1210
+ teardownRepeat(c);
1211
+ } else if (c.kind === 'async-stream') {
1212
+ teardownAsyncStream(c);
1213
+ } else if ('strings' in c) {
1214
+ const inst = /** @type TemplateInstance */ (part.child);
1215
+ disposeInstance(inst);
1216
+ removeBetween(inst.startNode, inst.endNode);
1217
+ } else {
1218
+ for (const n of /** @type ChildNode[] */ (part.child)) {
1219
+ if (n.parentNode) n.parentNode.removeChild(n);
1220
+ }
1221
+ }
1222
+ part.child = undefined;
1223
+ }
1224
+
1225
+ /**
1226
+ * Clear per-part directive state slots that don't apply to the value
1227
+ * currently being rendered. Prevents stale `__guardDeps` from short-
1228
+ * circuiting a render when the directive at this position is no longer
1229
+ * a guard, stale `__cacheMap` from accumulating across non-cache
1230
+ * renders, and stale `__untilState` from letting a prior Promise
1231
+ * resolution overwrite newer DOM.
1232
+ *
1233
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1234
+ * @param {unknown} value
1235
+ */
1236
+ function clearStaleDirectiveState(part, value) {
1237
+ const partAny = /** @type any */ (part);
1238
+ if (partAny.__untilState && !isUntil(value)) {
1239
+ partAny.__untilState.aborted = true;
1240
+ partAny.__untilState = undefined;
1241
+ }
1242
+ if (partAny.__guardDeps !== undefined && !isGuard(value)) {
1243
+ partAny.__guardDeps = undefined;
1244
+ }
1245
+ if (partAny.__cacheMap && !isCache(value)) {
1246
+ partAny.__cacheMap = undefined;
1247
+ }
1248
+ if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
1249
+ partAny.__keyedKey = undefined;
1250
+ }
1251
+ if (partAny.__watchSub && !isWatch(value)) {
1252
+ teardownWatch(partAny);
1253
+ }
1254
+ }
1255
+
1256
+ /* ================================================================
1257
+ * Cache directive: detach + retain prior template instances so that
1258
+ * toggling between sub-templates preserves their DOM state.
1259
+ * ================================================================ */
1260
+
1261
+ /**
1262
+ * Apply the `cache` directive at a child position. The cache is stored
1263
+ * on the part as `__cacheMap: Map<strings, { inst, holderFrag }>`.
1264
+ *
1265
+ * When the new inner value is a template whose `strings` already lives
1266
+ * in the cache map, re-attach the stashed nodes before the marker and
1267
+ * reconcile values against the new template. When the new inner is a
1268
+ * template whose strings aren't cached, stash the currently-attached
1269
+ * instance (if any) into the cache map before rendering the new one.
1270
+ *
1271
+ * Non-template inner values fall through to the generic applyChild path
1272
+ * (after first stashing any currently-attached cached instance).
1273
+ *
1274
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1275
+ * @param {unknown} inner
1276
+ */
1277
+ function applyCache(part, inner) {
1278
+ const marker = part.marker;
1279
+ const partAny = /** @type any */ (part);
1280
+ /** @type {Map<TemplateStringsArray, { inst: TemplateInstance, holder: DocumentFragment }>} */
1281
+ let cacheMap = partAny.__cacheMap;
1282
+ if (!cacheMap) {
1283
+ cacheMap = new Map();
1284
+ partAny.__cacheMap = cacheMap;
1285
+ }
1286
+
1287
+ const currentChild = /** @type any */ (part.child);
1288
+ const currentIsInstance = currentChild && 'strings' in currentChild;
1289
+
1290
+ // If the currently-attached child IS a template instance, decide
1291
+ // whether to update-in-place, stash for later, or destroy.
1292
+ if (currentIsInstance) {
1293
+ const currentInst = /** @type TemplateInstance */ (currentChild);
1294
+
1295
+ // Same template structure: reconcile values, no detach/re-attach.
1296
+ if (isTemplate(inner) && currentInst.strings === /** @type any */ (inner).strings) {
1297
+ updateInstance(currentInst, /** @type any */ (inner).values);
1298
+ return;
1299
+ }
1300
+
1301
+ // Different shape: detach the current instance into a holder fragment
1302
+ // and store it in the cache map. We keep the existing instance, slot
1303
+ // markers, and rendered nodes; only the parent changes. moveRange's
1304
+ // null anchor means "append to parent".
1305
+ const holder = document.createDocumentFragment();
1306
+ moveRange(currentInst.startNode, currentInst.endNode, holder, null);
1307
+ cacheMap.set(currentInst.strings, { inst: currentInst, holder });
1308
+ part.child = undefined;
1309
+ }
1310
+
1311
+ // Now part.child is either undefined or some non-instance shape (rare;
1312
+ // happens when prior render had a string / array / etc.). For non-
1313
+ // instance shapes, fall through to the generic teardown via applyChild.
1314
+
1315
+ // If the new inner is a template AND we've cached an instance for its
1316
+ // strings, re-attach it.
1317
+ if (isTemplate(inner)) {
1318
+ const tr = /** @type any */ (inner);
1319
+ const cached = cacheMap.get(tr.strings);
1320
+ if (cached) {
1321
+ cacheMap.delete(tr.strings);
1322
+ // Tear down any non-instance child currently attached (a string /
1323
+ // array of text nodes from a prior cache(non-template) render).
1324
+ // Without this the prior nodes remain in the DOM alongside the
1325
+ // re-attached cached template.
1326
+ if (part.child) {
1327
+ teardownChild(part);
1328
+ }
1329
+ // Move the cached nodes back before the marker.
1330
+ moveRange(cached.inst.startNode, cached.inst.endNode, /** @type Node */ (marker.parentNode), marker);
1331
+ // Reconcile values so any state changes since detachment apply.
1332
+ updateInstance(cached.inst, tr.values);
1333
+ part.child = cached.inst;
1334
+ return;
1335
+ }
1336
+ }
1337
+
1338
+ // No cached instance available. Render the new inner value via the
1339
+ // standard applyChild path. The currentIsInstance branch already
1340
+ // handled detaching the prior instance; if part.child still holds a
1341
+ // non-instance shape, applyChild will tear it down generically.
1342
+ applyChildInner(part, inner);
1343
+ }
1344
+
1345
+ /* ================================================================
1346
+ * Until directive: render highest-priority resolved candidate.
1347
+ * ================================================================ */
1348
+
1349
+ /**
1350
+ * Apply the `until` directive at a child position.
1351
+ *
1352
+ * Priority is left-to-right: args[0] has the highest priority. The
1353
+ * highest-priority synchronous candidate (if any) renders immediately.
1354
+ * Strictly-higher-priority Promises are awaited in the background; when
1355
+ * one resolves AND no higher-priority Promise has already resolved, its
1356
+ * result becomes the rendered value.
1357
+ *
1358
+ * The directive's state lives on `part.__untilState` (a stable slot
1359
+ * that survives `applyChild`'s overwrites of `part.child`). When a new
1360
+ * render replaces the directive, the prior state's `aborted` flag flips
1361
+ * to `true` so any in-flight Promise resolutions short-circuit instead
1362
+ * of overwriting newer DOM.
1363
+ *
1364
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1365
+ * @param {readonly unknown[]} args
1366
+ */
1367
+ function applyUntil(part, args) {
1368
+ // Carry forward the prior render's `highestResolved` ONLY when the
1369
+ // args list is unchanged. When any argument identity changes, prior
1370
+ // priorities no longer apply (a Promise that won at index 0 may now
1371
+ // sit at a different index, or have been replaced entirely); the
1372
+ // state must reset to Infinity so the new args' Promises can compete.
1373
+ //
1374
+ // For TemplateResult args, compare by `strings` array identity rather
1375
+ // than the wrapper object identity. `html\`loading...\`` evaluates to
1376
+ // a fresh TemplateResult on every call but the strings array is
1377
+ // interned per call site, so the conceptual value is unchanged.
1378
+ const partAny = /** @type any */ (part);
1379
+ const prevState = partAny.__untilState;
1380
+ const prevArgs = partAny.__untilArgs;
1381
+ const argEq = (a, b) => {
1382
+ if (Object.is(a, b)) return true;
1383
+ if (isTemplate(a) && isTemplate(b)
1384
+ && /** @type any */ (a).strings === /** @type any */ (b).strings) return true;
1385
+ return false;
1386
+ };
1387
+ const argsEqual = prevArgs && prevArgs.length === args.length
1388
+ && prevArgs.every((a, i) => argEq(a, args[i]));
1389
+ const carriedHighest = argsEqual && prevState ? prevState.highestResolved : Infinity;
1390
+ if (prevState) prevState.aborted = true;
1391
+ partAny.__untilArgs = args.slice();
1392
+
1393
+ /** @type {{aborted:boolean, highestResolved:number}} */
1394
+ const state = { aborted: false, highestResolved: carriedHighest };
1395
+ partAny.__untilState = state;
1396
+
1397
+ // Highest-priority synchronous candidate. If found, render it now
1398
+ // and cap further Promise subscription to strictly-higher priorities.
1399
+ let firstSyncIdx = -1;
1400
+ let firstSyncVal = undefined;
1401
+ for (let i = 0; i < args.length; i++) {
1402
+ const a = args[i];
1403
+ if (!a || typeof (/** @type any */ (a).then) !== 'function') {
1404
+ firstSyncIdx = i;
1405
+ firstSyncVal = a;
1406
+ break;
1407
+ }
1408
+ }
1409
+
1410
+ if (firstSyncIdx !== -1 && firstSyncIdx <= state.highestResolved) {
1411
+ // The sync candidate beats any previously-rendered Promise value
1412
+ // (when firstSyncIdx < state.highestResolved) OR re-renders the
1413
+ // sync fallback at the same priority slot (when ===), in case its
1414
+ // value changed between renders.
1415
+ applyChildInner(part, firstSyncVal);
1416
+ state.highestResolved = firstSyncIdx;
1417
+ } else if (firstSyncIdx === -1 && !partAny.__untilEverRendered) {
1418
+ // First-ever render of this part with all-Promise args: render
1419
+ // empty as the initial fallback while Promises settle.
1420
+ applyChildInner(part, '');
1421
+ }
1422
+ // Else: either there is no sync candidate but the part has rendered
1423
+ // before (preserve existing DOM until a Promise resolves), OR the
1424
+ // sync candidate is lower-priority than what's already rendered.
1425
+ // Either way: leave the existing DOM in place. This prevents the
1426
+ // "all-Promises wipes prior content" flash on re-renders.
1427
+ partAny.__untilEverRendered = true;
1428
+
1429
+ // Subscribe to Promises with priority strictly less than what's
1430
+ // currently rendered. (Lower index = higher priority in lit's model.)
1431
+ // Each subscription wraps in Promise.resolve() so synchronous
1432
+ // thenables get a microtask boundary, matching lit's contract that
1433
+ // all Promise/thenable resolutions are deferred.
1434
+ const cap = firstSyncIdx === -1
1435
+ ? Math.min(args.length, state.highestResolved)
1436
+ : Math.min(firstSyncIdx, state.highestResolved);
1437
+ for (let i = 0; i < cap; i++) {
1438
+ const a = args[i];
1439
+ if (!a || typeof (/** @type any */ (a).then) !== 'function') continue;
1440
+
1441
+ Promise.resolve(/** @type Promise<unknown> */ (a)).then(
1442
+ (resolved) => {
1443
+ if (state.aborted) return;
1444
+ if (i >= state.highestResolved) return;
1445
+ state.highestResolved = i;
1446
+ applyChildInner(part, resolved);
1447
+ },
1448
+ () => {
1449
+ // Swallow rejection. A rejected Promise is treated as "no value";
1450
+ // the existing render stays in place.
1451
+ },
1452
+ );
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * Abort an `until` directive's in-flight Promise tracking. Called from
1458
+ * `teardownChild` when the part is being reset.
1459
+ * @param {{aborted:boolean}} state
1460
+ */
1461
+ function teardownUntil(state) {
1462
+ state.aborted = true;
1463
+ }
1464
+
1465
+ /* ================================================================
1466
+ * watch (signal binding): fine-grained reactive part.
1467
+ * ================================================================ */
1468
+
1469
+ /**
1470
+ * Bind a child part to a signal. Reads the signal once and writes its
1471
+ * value into the part. Installs a per-part `Signal.subtle.Watcher`
1472
+ * that, on signal change, re-reads and re-applies the value WITHOUT
1473
+ * re-running the host component's render(). When the part is torn
1474
+ * down (teardownChild) the watcher is disposed.
1475
+ *
1476
+ * The signal read happens inside the watcher's `observe()`, so the
1477
+ * dependency edge connects the signal to THIS watcher. The host's
1478
+ * own render watcher is outside the active stack here, so the host
1479
+ * does not also subscribe to the signal (which would double-fire as
1480
+ * both a full re-render and a watch update).
1481
+ *
1482
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1483
+ * @param {{ get: () => unknown, __isSignal: true }} sig
1484
+ */
1485
+ function applyWatch(part, sig) {
1486
+ const partAny = /** @type any */ (part);
1487
+ // Same signal as last render: refresh dep tracking via observe (the
1488
+ // spec-aligned Watcher fires once per arm, so re-observing re-arms).
1489
+ if (partAny.__watchSig === sig && partAny.__watchSub) {
1490
+ let value;
1491
+ partAny.__watchSub.observe(() => { value = sig.get(); });
1492
+ applyChildInner(part, value);
1493
+ return;
1494
+ }
1495
+ // Signal changed (or first render). Tear down any prior watcher.
1496
+ if (partAny.__watchSub) {
1497
+ partAny.__watchSub.dispose();
1498
+ partAny.__watchSub = undefined;
1499
+ }
1500
+ partAny.__watchSig = sig;
1501
+ // Notify defers to a microtask because the spec forbids signal reads
1502
+ // inside the notify itself. The microtask re-observes (re-arms + re-
1503
+ // records the dep) and applies the new value to the part.
1504
+ const watcher = new Signal.subtle.Watcher(() => {
1505
+ queueMicrotask(() => {
1506
+ if (partAny.__watchSub !== watcher) return; // disposed mid-flight
1507
+ let v;
1508
+ watcher.observe(() => { v = sig.get(); });
1509
+ applyChildInner(part, v);
1510
+ });
1511
+ });
1512
+ partAny.__watchSub = watcher;
1513
+ let initial;
1514
+ watcher.observe(() => { initial = sig.get(); });
1515
+ applyChildInner(part, initial);
1516
+ }
1517
+
1518
+ /**
1519
+ * Dispose a `watch` directive's per-part watcher. Called from
1520
+ * `teardownChild` and from `clearStaleDirectiveState` when the value
1521
+ * at the part is no longer a watch.
1522
+ * @param {any} partAny
1523
+ */
1524
+ function teardownWatch(partAny) {
1525
+ if (partAny.__watchSub) {
1526
+ partAny.__watchSub.dispose();
1527
+ partAny.__watchSub = undefined;
1528
+ partAny.__watchSig = undefined;
1529
+ }
1530
+ }
1531
+
1532
+ /* ================================================================
1533
+ * asyncAppend / asyncReplace: stream from AsyncIterable.
1534
+ * ================================================================ */
1535
+
1536
+ /**
1537
+ * Apply `asyncAppend(iterable, mapper?)` at a child position.
1538
+ *
1539
+ * Iterates the AsyncIterable in the background. Each yielded value is
1540
+ * mapped (optional) and rendered as a node group, appended before the
1541
+ * marker. The state is stored on `part.child` so `teardownChild` can
1542
+ * abort the iteration when the part is reset.
1543
+ *
1544
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1545
+ * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1546
+ */
1547
+ function applyAsyncAppend(part, dir) {
1548
+ // Same-iterable short-circuit: if the prior render's iterable identity
1549
+ // matches, the existing iterator is still consuming it. Re-subscribing
1550
+ // would start a fresh iterator that misses already-yielded values.
1551
+ // Matches lit-html's behavior.
1552
+ const currentChild = /** @type any */ (part.child);
1553
+ if (currentChild && currentChild.kind === 'async-stream'
1554
+ && currentChild.mode === 'append'
1555
+ && currentChild.iterable === dir.iterable) {
1556
+ return;
1557
+ }
1558
+
1559
+ teardownChild(part);
1560
+
1561
+ const iterator = /** @type AsyncIterator<unknown> */ (
1562
+ dir.iterable[Symbol.asyncIterator]()
1563
+ );
1564
+ /** @type {AsyncStreamState} */
1565
+ const state = {
1566
+ kind: 'async-stream',
1567
+ mode: 'append',
1568
+ aborted: false,
1569
+ iterable: dir.iterable,
1570
+ iterator,
1571
+ /** @type {ChildNode[]} */ nodes: [],
1572
+ };
1573
+ part.child = state;
1574
+
1575
+ consumeAsyncStream(state, part, dir);
1576
+ }
1577
+
1578
+ /**
1579
+ * Apply `asyncReplace(iterable, mapper?)` at a child position. Same as
1580
+ * `applyAsyncAppend` but each new value replaces the previous content.
1581
+ *
1582
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1583
+ * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1584
+ */
1585
+ function applyAsyncReplace(part, dir) {
1586
+ // Same-iterable short-circuit: see comment in applyAsyncAppend.
1587
+ const currentChild = /** @type any */ (part.child);
1588
+ if (currentChild && currentChild.kind === 'async-stream'
1589
+ && currentChild.mode === 'replace'
1590
+ && currentChild.iterable === dir.iterable) {
1591
+ return;
1592
+ }
1593
+
1594
+ teardownChild(part);
1595
+
1596
+ const iterator = /** @type AsyncIterator<unknown> */ (
1597
+ dir.iterable[Symbol.asyncIterator]()
1598
+ );
1599
+ /** @type {AsyncStreamState} */
1600
+ const state = {
1601
+ kind: 'async-stream',
1602
+ mode: 'replace',
1603
+ aborted: false,
1604
+ iterable: dir.iterable,
1605
+ iterator,
1606
+ /** @type {ChildNode[]} */ nodes: [],
1607
+ };
1608
+ part.child = state;
1609
+
1610
+ consumeAsyncStream(state, part, dir);
1611
+ }
1612
+
1613
+ /**
1614
+ * @typedef {{
1615
+ * kind: 'async-stream',
1616
+ * mode: 'append' | 'replace',
1617
+ * aborted: boolean,
1618
+ * iterable: AsyncIterable<unknown>,
1619
+ * iterator: AsyncIterator<unknown>,
1620
+ * nodes: ChildNode[],
1621
+ * }} AsyncStreamState
1622
+ */
1623
+
1624
+ /**
1625
+ * Consume an AsyncIterable for `asyncAppend` / `asyncReplace`. Drives
1626
+ * the iterator with an explicit `.next()` loop (rather than `for await`)
1627
+ * so that `teardownAsyncStream` can call `iterator.return()` to break
1628
+ * a generator parked on an `await`. The `aborted` flag is also checked
1629
+ * after every `next()` resolve to short-circuit if abortion happened
1630
+ * while the iterator was suspended.
1631
+ *
1632
+ * @param {AsyncStreamState} state
1633
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1634
+ * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
1635
+ */
1636
+ async function consumeAsyncStream(state, part, dir) {
1637
+ const marker = part.marker;
1638
+ let i = 0;
1639
+ try {
1640
+ while (!state.aborted) {
1641
+ const result = await state.iterator.next();
1642
+ if (state.aborted) break;
1643
+ if (result.done) break;
1644
+ const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
1645
+ const newNodes = renderToNodes(mapped);
1646
+
1647
+ if (state.mode === 'replace') {
1648
+ for (const n of state.nodes) {
1649
+ if (n.parentNode) n.parentNode.removeChild(n);
1650
+ }
1651
+ state.nodes = [];
1652
+ }
1653
+
1654
+ const frag = document.createDocumentFragment();
1655
+ for (const n of newNodes) frag.appendChild(n);
1656
+ marker.parentNode?.insertBefore(frag, marker);
1657
+ state.nodes.push(...newNodes);
1658
+
1659
+ i++;
1660
+ }
1661
+ } catch (err) {
1662
+ // Swallow iteration errors. A leaked iterator throwing should not
1663
+ // crash the host's render cycle. Authors who care about errors
1664
+ // should handle them in their iterable / generator.
1665
+ if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
1666
+ }
1667
+ }
1668
+
1669
+ /**
1670
+ * Render a single value into a flat list of DOM nodes for insertion via
1671
+ * insertBefore. Handles strings, numbers, TemplateResult, and arrays.
1672
+ * @param {unknown} value
1673
+ * @returns {ChildNode[]}
1674
+ */
1675
+ function renderToNodes(value) {
1676
+ if (value == null || value === false || value === true) return [];
1677
+ if (isTemplate(value)) {
1678
+ const tr = /** @type any */ (value);
1679
+ const { templateEl, parts } = compile(tr);
1680
+ const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1681
+ const bound = parts.map((p) => bindPart(p, frag));
1682
+ for (let i = 0; i < tr.values.length; i++) {
1683
+ applyPart(bound[i], tr.values[i], undefined, tr.values);
1684
+ }
1685
+ return [...frag.childNodes];
1686
+ }
1687
+ if (Array.isArray(value)) {
1688
+ const nodes = [];
1689
+ for (const v of value) nodes.push(...renderToNodes(v));
1690
+ return nodes;
1691
+ }
1692
+ return [document.createTextNode(String(value))];
1693
+ }
1694
+
1695
+ /**
1696
+ * Abort an async-stream directive. Sets `aborted = true` (so the next
1697
+ * `await iterator.next()` resolution short-circuits), removes all nodes
1698
+ * rendered so far, and explicitly calls `iterator.return()` so a
1699
+ * generator parked on `await` can unwind via its `finally` blocks
1700
+ * instead of leaking.
1701
+ * @param {AsyncStreamState} state
1702
+ */
1703
+ function teardownAsyncStream(state) {
1704
+ state.aborted = true;
1705
+ for (const n of state.nodes) {
1706
+ if (n.parentNode) n.parentNode.removeChild(n);
1707
+ }
1708
+ state.nodes = [];
1709
+ // Best-effort iterator cleanup. `.return()` is optional on AsyncIterators;
1710
+ // generators built via `async function*` provide it and run their
1711
+ // `finally` blocks. Swallow any rejection so teardown can't throw.
1712
+ try {
1713
+ state.iterator.return?.()?.catch?.(() => {});
1714
+ } catch {
1715
+ // ignore
1716
+ }
1717
+ }