@webjsdev/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +20 -19
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +14 -9
  9. package/index.d.ts +22 -10
  10. package/index.js +17 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.js +12 -13
  16. package/src/async-directive.d.ts +18 -0
  17. package/src/async-directive.js +88 -0
  18. package/src/attribute-reader.js +197 -23
  19. package/src/binding-prefixes.js +0 -12
  20. package/src/component/lifecycle.js +760 -209
  21. package/src/component/reactive.js +2 -10
  22. package/src/component/server-element.js +23 -1
  23. package/src/component.d.ts +151 -41
  24. package/src/context.d.ts +94 -18
  25. package/src/context.js +341 -217
  26. package/src/converters.d.ts +7 -0
  27. package/src/converters.js +38 -0
  28. package/src/css.d.ts +32 -6
  29. package/src/css.js +178 -14
  30. package/src/directive.d.ts +58 -0
  31. package/src/directive.js +119 -0
  32. package/src/directives.d.ts +38 -1
  33. package/src/directives.js +158 -1
  34. package/src/form-action.d.ts +13 -0
  35. package/src/form-action.js +139 -4
  36. package/src/hot.js +23 -0
  37. package/src/html.d.ts +4 -0
  38. package/src/html.js +35 -3
  39. package/src/is-server.d.ts +2 -0
  40. package/src/is-server.js +18 -0
  41. package/src/jsx-guard.js +130 -0
  42. package/src/metadata.d.ts +22 -3
  43. package/src/nav.d.ts +4 -1
  44. package/src/nav.js +44 -11
  45. package/src/optimistic.js +2 -2
  46. package/src/refresh-header.js +52 -0
  47. package/src/registry.js +10 -1
  48. package/src/render-client/adopt-slots.js +8 -3
  49. package/src/render-client/parts.js +745 -31
  50. package/src/render-client/reconciler.js +56 -11
  51. package/src/render-client/template-compiler.js +139 -44
  52. package/src/render-client.d.ts +5 -1
  53. package/src/render-server/attrs.js +2 -2
  54. package/src/render-server/compile.js +34 -0
  55. package/src/render-server/errors.js +37 -0
  56. package/src/render-server/expand.js +61 -8
  57. package/src/render-server/served-attrs.js +1 -2
  58. package/src/render-server/walk.js +135 -3
  59. package/src/render-server.d.ts +3 -0
  60. package/src/render-server.js +2 -0
  61. package/src/repeat.d.ts +7 -2
  62. package/src/repeat.js +20 -6
  63. package/src/router-client/dom-differ.js +17 -0
  64. package/src/router-client/fetch-apply.js +86 -5
  65. package/src/router-client/navigator.js +45 -9
  66. package/src/router-client/prefetch.js +3 -4
  67. package/src/router-client/root-attrs.js +43 -0
  68. package/src/router-client/stream.js +7 -0
  69. package/src/router-client/swap.js +4 -1
  70. package/src/routes.d.ts +75 -18
  71. package/src/serialize.d.ts +2 -0
  72. package/src/serialize.js +35 -4
  73. package/src/signal.d.ts +7 -0
  74. package/src/signal.js +26 -6
  75. package/src/slot/polyfills.js +3 -3
  76. package/src/slot.js +4 -8
  77. package/src/static-html.d.ts +22 -0
  78. package/src/static-html.js +144 -0
  79. package/src/task.d.ts +63 -20
  80. package/src/task.js +326 -293
  81. package/src/webjs-config.d.ts +1 -8
@@ -5,6 +5,8 @@ import {
5
5
  assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp, isBoundFormAction,
6
6
  } from '../form-action.js';
7
7
  import { isRepeat } from '../repeat.js';
8
+ import { isDirectiveResult, PartType } from '../directive.js';
9
+ import { isSignal } from '../signal.js';
8
10
  import {
9
11
  isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch
10
12
  } from '../directives.js';
@@ -34,6 +36,29 @@ export function setCurrentRenderRoot(root) {
34
36
  currentRenderRoot = root;
35
37
  }
36
38
 
39
+ /**
40
+ * Where the child part being committed renders, while a fresh template's
41
+ * parts still sit in their detached fragment. A directive reading
42
+ * `part.parentNode` in `update()` gets this, lit's "logical DOM parent", rather
43
+ * than the fragment the clone is built in.
44
+ * @type {Node | null}
45
+ */
46
+ let logicalParent = null;
47
+
48
+ /**
49
+ * The logical parent for a marker's DOM parent: itself, unless it is the
50
+ * detached fragment a template clone is built in (not a shadow root and not
51
+ * the fragment being rendered into).
52
+ * @param {Node | null} node
53
+ * @returns {Node | null}
54
+ */
55
+ function logicalParentOf(node) {
56
+ if (node && node.nodeType === 11 && !(/** @type any */ (node)).host && node !== currentRenderRoot) {
57
+ return logicalParent ?? currentRenderRoot ?? node;
58
+ }
59
+ return node;
60
+ }
61
+
37
62
  /**
38
63
  * Open the renderer-write window on a light-DOM host while `fn` commits into
39
64
  * it, so the host's patched slot-interception methods delegate to native and
@@ -180,7 +205,7 @@ export function bindPart(p, root) {
180
205
  if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
181
206
  if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
182
207
  if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
183
- if (p.kind === 'element') return { kind: 'element', el };
208
+ if (p.kind === 'element') return { kind: 'element', el, refHost: currentRenderHost };
184
209
  if (p.kind === 'slot') {
185
210
  const slotEl = /** @type {HTMLSlotElement} */ (el);
186
211
  // Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
@@ -193,19 +218,451 @@ export function bindPart(p, root) {
193
218
  throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
194
219
  }
195
220
 
221
+ /* ================================================================
222
+ * Custom directives (`@webjsdev/core/directive`, lit's directive API).
223
+ *
224
+ * One directive INSTANCE lives per part (per piece of an interpolated
225
+ * attribute, under its own slot) for as long as the same class renders
226
+ * there, in a record `{ ctor, inst, child }`. `child` holds the record of a
227
+ * directive the first one RETURNED, so a directive composing another keeps
228
+ * both instances. A record is replaced (and an async one disconnected) when a
229
+ * different class, or a plain value, takes the part over.
230
+ * ================================================================ */
231
+
232
+ /** Per-host set of live async-directive records, toggled with the host. */
233
+ const ASYNC_DIRECTIVES = Symbol.for('webjs.asyncDirectives');
234
+
235
+ /**
236
+ * The lit-shaped part handed to `Directive#update()`.
237
+ * @param {any} part
238
+ * @param {number} [piece] the piece index, for a piece of an interpolated attribute
239
+ */
240
+ function directivePartFacade(part, piece) {
241
+ const options = { host: part.__eventHost ?? part.eventHost ?? currentRenderHost };
242
+ switch (part.kind) {
243
+ case 'child':
244
+ return {
245
+ type: PartType.CHILD,
246
+ options,
247
+ get parentNode() { return logicalParentOf(part.marker.parentNode); },
248
+ get endNode() { return part.marker; },
249
+ get startNode() {
250
+ const c = part.child;
251
+ const first = c && (c.startNode || (Array.isArray(c) ? c[0] : undefined));
252
+ return first ? first.previousSibling : part.marker.previousSibling;
253
+ },
254
+ };
255
+ case 'attr':
256
+ case 'attr-mixed':
257
+ return {
258
+ type: PartType.ATTRIBUTE,
259
+ options,
260
+ element: part.el,
261
+ name: part.name,
262
+ tagName: part.el.tagName,
263
+ strings: part.kind === 'attr-mixed' ? part.statics : undefined,
264
+ piece,
265
+ };
266
+ case 'prop':
267
+ return { type: PartType.PROPERTY, options, element: part.el, name: part.name, tagName: part.el.tagName };
268
+ case 'bool':
269
+ return { type: PartType.BOOLEAN_ATTRIBUTE, options, element: part.el, name: part.name, tagName: part.el.tagName };
270
+ case 'event':
271
+ return { type: PartType.EVENT, options, element: part.el, name: part.name, tagName: part.el.tagName };
272
+ default:
273
+ return { type: PartType.ELEMENT, options, element: part.el };
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Report a directive record disconnected (async ones run `disconnected()`)
279
+ * and drop it from its host's registry, with every record it chained to.
280
+ * @param {any} rec
281
+ */
282
+ function disconnectDirectiveRecord(rec) {
283
+ for (let r = rec; r; r = r.child) {
284
+ r.released = true;
285
+ if (typeof r.inst._$webjsSetConnected === 'function') {
286
+ r.inst._$webjsCommit = undefined;
287
+ try { r.inst._$webjsSetConnected(false); } catch (err) {
288
+ console.error('[webjs] an async directive\'s disconnected() threw:', err);
289
+ }
290
+ r.host?.[ASYNC_DIRECTIVES]?.delete(r);
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Register an async directive record with its host so it follows the host's
297
+ * connection. The host gets ONE reactive controller, on its first async
298
+ * directive, that flips every registered record.
299
+ * @param {any} host
300
+ * @param {any} rec
301
+ */
302
+ function registerAsyncDirective(host, rec) {
303
+ if (!host || typeof host.addController !== 'function') return;
304
+ let set = host[ASYNC_DIRECTIVES];
305
+ if (!set) {
306
+ const records = (set = host[ASYNC_DIRECTIVES] = new Set());
307
+ host.addController({
308
+ hostConnected() { for (const r of [...records]) r.inst._$webjsSetConnected(true); },
309
+ hostDisconnected() { for (const r of [...records]) r.inst._$webjsSetConnected(false); },
310
+ });
311
+ }
312
+ set.add(rec);
313
+ rec.host = host;
314
+ }
315
+
316
+ /**
317
+ * Resolve a value that may be a custom directive result into what it renders,
318
+ * keeping the directive instances on `holder[key]` (and their `child` chain).
319
+ *
320
+ * @param {any} part
321
+ * @param {any} holder the object the record lives on (the part, or its piece map)
322
+ * @param {string | number} key
323
+ * @param {unknown} value
324
+ * @param {(v: unknown) => void} commit applies a value `setValue()` pushes
325
+ * @param {boolean} releaseTop when the value is NOT a directive, whether to
326
+ * release a record left from an earlier render (false while a wrapper
327
+ * directive such as keyed / guard / cache passes its inner value on)
328
+ * @param {number} [piece]
329
+ * @returns {unknown}
330
+ */
331
+ function resolveDirectiveChain(part, holder, key, value, commit, releaseTop, piece) {
332
+ if (part.__settingValue) return value;
333
+ let parent = holder;
334
+ /** @type {string | number} */
335
+ let slot = key;
336
+ let depth = 0;
337
+ while (isDirectiveResult(value)) {
338
+ if (++depth > 100) throw new Error('[webjs] a custom directive kept returning directives (more than 100 levels deep).');
339
+ const d = /** @type {any} */ (value);
340
+ let rec = parent[slot];
341
+ if (!rec || rec.ctor !== d.ctor) {
342
+ if (rec) disconnectDirectiveRecord(rec);
343
+ const facade = directivePartFacade(part, piece);
344
+ /** @type {any} */
345
+ const f = facade;
346
+ const inst = new d.ctor({ type: f.type, name: f.name, tagName: f.tagName, strings: f.strings });
347
+ rec = parent[slot] = { ctor: d.ctor, inst, facade, child: undefined, host: undefined, released: false };
348
+ if (typeof inst._$webjsSetConnected === 'function') {
349
+ // Record the owning component while its render is on the stack, the
350
+ // only moment it is knowable, so a `setValue()` commit throw reaches
351
+ // that component's renderError() (the `watch` rule).
352
+ if (currentRenderRoot) part.__commitOwner = boundaryOwnerOf(currentRenderRoot);
353
+ if (part.kind !== 'child') part.__eventHost ??= currentRenderHost;
354
+ const own = rec;
355
+ // A container `render()` disconnected (lit's `RootPart.setConnected`)
356
+ // starts the directives it creates disconnected, as lit does.
357
+ if (currentRenderRoot?.[ROOT_DISCONNECTED]) inst.isConnected = false;
358
+ // So does a component rendering while it is out of the document: its
359
+ // directives start disconnected and are reconnected with it.
360
+ const owner = part.__eventHost ?? currentRenderHost;
361
+ if (owner && owner.isConnected === false && typeof owner.addController === 'function') inst.isConnected = false;
362
+ inst._$webjsCommit = (v) => {
363
+ if (own.released) return;
364
+ try {
365
+ // A directive `setValue()` hands on resolves UNDER this one, so its
366
+ // instance lives as long as this directive does (lit passes the
367
+ // directive as the parent to `_$setValue`).
368
+ commitOutOfBand(part, () => {
369
+ const resolved = resolveDirectiveChain(part, own, 'child', v, commit, true, piece);
370
+ if (resolved !== noChange) commit(resolved);
371
+ });
372
+ } catch (err) {
373
+ reportOutOfBandCommitError(part, err);
374
+ }
375
+ };
376
+ registerAsyncDirective(part.__eventHost ?? currentRenderHost, rec);
377
+ }
378
+ }
379
+ value = rec.inst.update(rec.facade, d.values);
380
+ parent = rec;
381
+ slot = 'child';
382
+ }
383
+ // A directive that answers `noChange` keeps everything below it, a nested
384
+ // directive's instance included, as lit's `resolveDirective` returns early.
385
+ if (parent[slot] && value !== noChange && (depth > 0 || releaseTop)) {
386
+ disconnectDirectiveRecord(parent[slot]);
387
+ parent[slot] = undefined;
388
+ }
389
+ return value;
390
+ }
391
+
392
+ /**
393
+ * Set on a container whose `render()` result was disconnected through
394
+ * `setConnected(false)`.
395
+ */
396
+ export const ROOT_DISCONNECTED = Symbol('webjs.rootDisconnected');
397
+
398
+ /**
399
+ * Report every async directive in a rendered template instance connected or
400
+ * disconnected, in template order, the walk lit's `RootPart.setConnected`
401
+ * makes. `disconnected()` / `reconnected()` run once per transition.
402
+ * @param {any} inst
403
+ * @param {boolean} connected
404
+ */
405
+ export function setInstanceDirectivesConnected(inst, connected) {
406
+ if (!inst || !inst.bound) return;
407
+ for (const p of inst.bound) setPartDirectivesConnected(p, connected);
408
+ }
409
+
410
+ /**
411
+ * Connect or disconnect the directives one part holds, and those in what it
412
+ * rendered, an array item that is a child part of its own included.
413
+ * @param {any} p
414
+ * @param {boolean} connected
415
+ */
416
+ function setPartDirectivesConnected(p, connected) {
417
+ /** @param {any} rec */
418
+ const flip = (rec) => {
419
+ for (let r = rec; r; r = r.child) {
420
+ if (typeof r.inst._$webjsSetConnected === 'function') r.inst._$webjsSetConnected(connected);
421
+ }
422
+ };
423
+ flip(p.__directive);
424
+ if (p.__pieceDirectives) for (const k of Object.keys(p.__pieceDirectives)) flip(p.__pieceDirectives[k]);
425
+ const c = p.kind === 'child' ? p.child : null;
426
+ if (!c) return;
427
+ if (c.kind === 'repeat') for (const i of c.map.values()) setInstanceDirectivesConnected(i, connected);
428
+ else if (c.kind === 'array') {
429
+ for (const it of c.items) {
430
+ if (it.type === 'tpl') setInstanceDirectivesConnected(it.inst, connected);
431
+ else if (it.type === 'part') setPartDirectivesConnected(it.sub, connected);
432
+ }
433
+ } else if ('strings' in c) setInstanceDirectivesConnected(c, connected);
434
+ }
435
+
436
+ /**
437
+ * Release every directive record a bound part holds (its own and, for an
438
+ * interpolated attribute, each piece's), and recurse into a child part's
439
+ * rendered template so a nested directive is released with the region that
440
+ * is going away.
441
+ * @param {any} p
442
+ */
443
+ function releasePartDirectives(p) {
444
+ // A part going away drops its signal subscriptions too (a bare signal or a
445
+ // `watch()` in it), so a later change cannot commit into detached nodes.
446
+ if (p.__sigSub) teardownSignalPart(p);
447
+ if (p.__watchSub) teardownWatch(p);
448
+ if (p.__directive) { disconnectDirectiveRecord(p.__directive); p.__directive = undefined; }
449
+ if (p.__pieceDirectives) {
450
+ for (const k of Object.keys(p.__pieceDirectives)) disconnectDirectiveRecord(p.__pieceDirectives[k]);
451
+ p.__pieceDirectives = undefined;
452
+ }
453
+ if (p.kind === 'child' && p.child) releaseRegionDirectives(p.child);
454
+ }
455
+
456
+ /**
457
+ * Release the directives inside a child part's rendered content, whatever
458
+ * shape it took (a template instance, a repeat map, a positional array).
459
+ * @param {any} c
460
+ */
461
+ function releaseRegionDirectives(c) {
462
+ if (!c) return;
463
+ if (c.kind === 'repeat') { for (const inst of c.map.values()) releaseInstanceDirectives(inst); return; }
464
+ if (c.kind === 'array') {
465
+ for (const item of c.items) {
466
+ if (item.type === 'tpl') releaseInstanceDirectives(item.inst);
467
+ else if (item.type === 'part') releasePartDirectives(item.sub);
468
+ }
469
+ return;
470
+ }
471
+ if ('strings' in c) releaseInstanceDirectives(c);
472
+ }
473
+
474
+ /**
475
+ * Release every directive record in a template instance's parts. Exported for
476
+ * the top-level reconciler, which swaps a component's whole template.
477
+ * @param {any} inst
478
+ */
479
+ export function releaseInstanceDirectives(inst) {
480
+ if (!inst || !inst.bound) return;
481
+ for (const p of inst.bound) releasePartDirectives(p);
482
+ }
483
+
484
+ /** Whether a value only WRAPS another one the part renders (so its directive record must survive). */
485
+ function isWrapperDirective(value) {
486
+ return isKeyed(value) || isGuard(value) || isCache(value) || isLive(value);
487
+ }
488
+
489
+ /** Settled values of the promises `until` has seen, so a re-render that
490
+ * passes an already-settled promise commits its value at once rather than
491
+ * flashing the placeholder (Lit's until keeps the same per-part memory). */
492
+ const settledUntil = new WeakMap();
493
+
494
+ /**
495
+ * `until(...args)` outside a child position: the value to commit now (the
496
+ * first non-promise argument, or the value of a higher-priority promise that
497
+ * has already settled), with each higher-priority promise still pending
498
+ * scheduled to commit through `onLater` when it settles, while `isCurrent()`.
499
+ * `has` is false when every argument is a pending promise, which commits
500
+ * nothing, as Lit's `noChange`.
501
+ * @param {unknown[]} args
502
+ * @param {() => boolean} isCurrent
503
+ * @param {(v: unknown) => void} onLater
504
+ * @returns {{ has: boolean, value?: unknown }}
505
+ */
506
+ function untilValueNow(args, isCurrent, onLater) {
507
+ const isThenable = (/** @type any */ x) => !!x && typeof x.then === 'function';
508
+ const syncIdx = args.findIndex((x) => !isThenable(x));
509
+ let limit = syncIdx < 0 ? args.length : syncIdx;
510
+ /** @type {{ has: boolean, value?: unknown }} */
511
+ let now = syncIdx < 0 ? { has: false } : { has: true, value: args[syncIdx] };
512
+ for (let i = 0; i < limit; i++) {
513
+ if (settledUntil.has(/** @type object */ (args[i]))) {
514
+ now = { has: true, value: settledUntil.get(/** @type object */ (args[i])) };
515
+ limit = i;
516
+ break;
517
+ }
518
+ }
519
+ let best = limit;
520
+ for (let i = 0; i < limit; i++) {
521
+ const p = /** @type object */ (args[i]);
522
+ Promise.resolve(p).then((v) => {
523
+ settledUntil.set(p, v);
524
+ if (!isCurrent() || i >= best) return;
525
+ best = i;
526
+ onLater(v);
527
+ }, () => {});
528
+ }
529
+ return now;
530
+ }
531
+
532
+ /**
533
+ * @param {unknown} prev
534
+ * @param {unknown} deps
535
+ */
536
+ function sameGuardDeps(prev, deps) {
537
+ if (prev === undefined) return false;
538
+ if (Array.isArray(prev) && Array.isArray(deps)) {
539
+ return prev.length === deps.length && deps.every((d, i) => Object.is(d, prev[i]));
540
+ }
541
+ return Object.is(prev, deps);
542
+ }
543
+
544
+ /**
545
+ * One piece of an interpolated attribute through the same unwrap a whole
546
+ * attribute gets (#1686): `guard`, `keyed`, `cache`, and `until`, whose
547
+ * later promise rewrites just this piece. A piece with nothing to commit yet
548
+ * keeps the text it last committed (empty on a first render), as `noChange`.
549
+ * @param {any} part
550
+ * @param {number} j
551
+ * @param {unknown} piece
552
+ * @param {string[]} committed
553
+ * @param {{ statics: string[], group: number[] }} mp
554
+ */
555
+ function mixedPieceValue(part, j, piece, committed, mp) {
556
+ const guards = (part.__pieceGuardDeps ??= []);
557
+ const untils = (part.__pieceUntil ??= []);
558
+ untils[j] = null;
559
+ if (isGuard(piece)) {
560
+ const deps = /** @type any */ (piece).deps;
561
+ if (sameGuardDeps(guards[j], deps)) return committed[j] ?? '';
562
+ guards[j] = deps;
563
+ piece = /** @type any */ (piece).fn();
564
+ } else {
565
+ guards[j] = undefined;
566
+ }
567
+ while (isKeyed(piece) || isCache(piece)) piece = /** @type any */ (piece).value;
568
+ if (!isUntil(piece)) return piece;
569
+ const token = {};
570
+ untils[j] = token;
571
+ const now = untilValueNow(/** @type any */ (piece).args, () => untils[j] === token, (v) => {
572
+ if (v === nothing) { part.el.removeAttribute(part.name); return; }
573
+ assertNotFunctionActionAttr(v, part.name, part.el.localName);
574
+ committed[j] = String(v ?? '');
575
+ let text = mp.statics[0];
576
+ for (let k = 0; k < mp.group.length; k++) text += (committed[k] ?? '') + (mp.statics[k + 1] || '');
577
+ part.el.setAttribute(part.name, text);
578
+ });
579
+ return now.has ? now.value : (committed[j] ?? '');
580
+ }
581
+
196
582
  /**
197
583
  * @param {BoundPart} part
198
584
  * @param {unknown} value
199
585
  * @param {unknown} _prev
200
586
  */
201
587
  export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb) {
202
- if (value === noChange) return;
588
+ // An interpolated attribute is rebuilt from ALL its pieces, so a `noChange`
589
+ // in the anchor position must still reach the attr-mixed branch, which
590
+ // resolves each piece on its own. Every other part keeps its value.
591
+ if (value === noChange && part.kind !== 'attr-mixed') return;
592
+ // A custom directive on an element, attribute, property, boolean or event
593
+ // part resolves to the value it renders before anything else looks at the
594
+ // value. A child part resolves in applyChild (around its own directive
595
+ // state); an interpolated attribute resolves per piece below.
596
+ if (part.kind !== 'child' && part.kind !== 'attr-mixed' && part.kind !== 'slot' && part.kind !== 'noop'
597
+ && (/** @type any */ (part).__directive || isDirectiveResult(value))) {
598
+ value = resolveDirectiveChain(part, part, '__directive', value, (v) => {
599
+ const p = /** @type any */ (part);
600
+ p.__settingValue = true;
601
+ try { applyPart(part, v, undefined, undefined, reconcileFormActionsCb); } finally { p.__settingValue = false; }
602
+ }, true);
603
+ if (value === noChange) return;
604
+ }
605
+ // A bare `${signal}` in an attribute or boolean hole: bind the part to the
606
+ // signal so a change rewrites just this attribute, with no host re-render
607
+ // (lit's signals package does the same). An interpolated attribute tracks
608
+ // every signal piece under one part-level watcher. A `.prop` hole is left
609
+ // alone, so the property receives the signal object itself.
610
+ const pAny = /** @type any */ (part);
611
+ if ((part.kind === 'attr' || part.kind === 'bool' || part.kind === 'attr-mixed') && !pAny.__applyingSignal) {
612
+ const holdsSignal = part.kind === 'attr-mixed'
613
+ ? /** @type any */ (part).group.some((/** @type {number} */ i) => isSignalLike(allValues ? allValues[i] : value))
614
+ : isSignalLike(value);
615
+ if (holdsSignal) {
616
+ bindSignalPart(part, () => {
617
+ pAny.__applyingSignal = true;
618
+ try {
619
+ applyPart(part, part.kind === 'attr-mixed' ? value : readSignalLike(value), _prev, allValues, reconcileFormActionsCb);
620
+ } finally {
621
+ pAny.__applyingSignal = false;
622
+ }
623
+ });
624
+ return;
625
+ }
626
+ if (pAny.__sigSub) teardownSignalPart(pAny);
627
+ }
203
628
  if (isAttributeMap(value)) { applyAttributeMap(part, value); return; }
204
629
  part.__mapKind = undefined;
630
+ // The value-carrying built-in directives outside a child position, as in
631
+ // Lit (#1686): `guard` commits its callback's result (re-run only when its
632
+ // deps change), `keyed` and `cache` their value, and `until` its first
633
+ // non-promise argument now and a higher-priority promise's value once it
634
+ // settles. They used to be written as "[object Object]". The pieces of an
635
+ // interpolated attribute take the same unwrap in the 'attr-mixed' case.
636
+ if (part.kind === 'attr' || part.kind === 'prop' || part.kind === 'bool' || part.kind === 'event') {
637
+ if (!pAny.__untilApplying) {
638
+ pAny.__attrUntil = null;
639
+ if (isGuard(value)) {
640
+ const deps = /** @type any */ (value).deps;
641
+ if (sameGuardDeps(pAny.__attrGuardDeps, deps)) return;
642
+ pAny.__attrGuardDeps = deps;
643
+ value = /** @type any */ (value).fn();
644
+ } else {
645
+ pAny.__attrGuardDeps = undefined;
646
+ }
647
+ }
648
+ while (isKeyed(value) || isCache(value)) value = /** @type any */ (value).value;
649
+ if (isUntil(value)) {
650
+ const token = {};
651
+ pAny.__attrUntil = token;
652
+ const now = untilValueNow(/** @type any */ (value).args, () => pAny.__attrUntil === token, (v) => {
653
+ pAny.__untilApplying = true;
654
+ try { applyPart(part, v, undefined, allValues, reconcileFormActionsCb); } finally { pAny.__untilApplying = false; }
655
+ });
656
+ if (!now.has) return;
657
+ value = now.value;
658
+ }
659
+ }
205
660
  // Unwrap live() to dirty-check against the live DOM value, not the
206
661
  // last rendered value. Essential for <input> two-way binding.
207
662
  if (isLive(value)) {
208
663
  const liveVal = /** @type any */ (value).value;
664
+ // live(noChange) keeps whatever the element holds now, as in Lit (#1686).
665
+ if (liveVal === noChange) return;
209
666
  if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
210
667
  if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
211
668
  if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
@@ -290,8 +747,36 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
290
747
  // Reconstruct the attribute from static pieces + all dynamic values.
291
748
  const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
292
749
  let val = mp.statics[0];
750
+ // The text each piece last committed. A `noChange` piece reuses it (empty
751
+ // on the first render, which is what SSR serves), so it never stringifies
752
+ // the sentinel itself.
753
+ const committed = /** @type any */ (part).__pieces ??= [];
754
+ // A lone hole (`a="${x}"`) is a whole-value binding, as in lit, so a
755
+ // first-render `noChange` leaves the attribute absent rather than empty.
756
+ if (mp.group.length === 1 && !mp.statics[0] && !mp.statics[1] && !committed.length
757
+ && (allValues ? allValues[mp.group[0]] : value) === noChange) break;
758
+ let hasLive = false;
293
759
  for (let j = 0; j < mp.group.length; j++) {
294
760
  let piece = allValues ? allValues[mp.group[j]] : value;
761
+ // A custom directive in this piece: one instance per piece, its
762
+ // `setValue()` rewriting just this piece of the attribute.
763
+ const pieceRecs = /** @type any */ (part).__pieceDirectives;
764
+ if (isDirectiveResult(piece) || (pieceRecs && pieceRecs[j])) {
765
+ const holder = (/** @type any */ (part).__pieceDirectives ??= {});
766
+ const pieceIndex = j;
767
+ piece = resolveDirectiveChain(part, holder, String(j), piece, (v) => {
768
+ if (v === noChange) return;
769
+ if (v === nothing) { part.el.removeAttribute(part.name); return; }
770
+ committed[pieceIndex] = String(v ?? '');
771
+ let text = mp.statics[0];
772
+ for (let k = 0; k < mp.group.length; k++) text += (committed[k] ?? '') + (mp.statics[k + 1] || '');
773
+ part.el.setAttribute(part.name, text);
774
+ }, true, j);
775
+ }
776
+ // A signal piece reads its current value; the part-level watcher
777
+ // applyPart installed records the read, so a change rebuilds this attribute.
778
+ if (isSignalLike(piece)) piece = readSignalLike(piece);
779
+ if (piece === noChange) piece = committed[j] ?? '';
295
780
  // Unwrap live() PER PIECE (#1443). The top-of-function unwrap only
296
781
  // sees the anchor hole's own value, and every hole inside a QUOTED
297
782
  // attribute lands here (single-hole included, the compiler classifies
@@ -299,13 +784,22 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
299
784
  // server now emits correctly would be rewritten to the wrapper's
300
785
  // stringification on the next client commit. Mirrors the reconciler's
301
786
  // effectiveFormAttr, which already unwraps each group piece.
302
- if (isLive(piece)) piece = /** @type any */ (piece).value;
787
+ if (isLive(piece)) {
788
+ piece = /** @type any */ (piece).value;
789
+ hasLive = true;
790
+ if (piece === noChange) piece = committed[j] ?? '';
791
+ }
792
+ piece = mixedPieceValue(part, j, piece, committed, mp);
303
793
  // #1154: same function guard for each piece of a mixed attribute.
304
794
  assertNotFunctionActionAttr(piece, part.name, part.el.localName);
305
795
  if (piece === nothing) { part.el.removeAttribute(part.name); return; }
306
- val += String(piece ?? '');
796
+ committed[j] = String(piece ?? '');
797
+ val += committed[j];
307
798
  val += mp.statics[j + 1] || '';
308
799
  }
800
+ // A live() piece checks the DOM, not the last render: an identical value
801
+ // is not written again (Lit's live, #1686).
802
+ if (hasLive && part.el.getAttribute(part.name) === val) break;
309
803
  part.el.setAttribute(part.name, val);
310
804
  break;
311
805
  }
@@ -460,7 +954,7 @@ export function applyElement(part, value) {
460
954
 
461
955
  if (refChanged && prevTarget) {
462
956
  if (typeof prevTarget === 'function') {
463
- try { prevTarget(undefined); } catch {}
957
+ try { prevTarget.call(partAny.refHost, undefined); } catch {}
464
958
  } else if (typeof prevTarget === 'object') {
465
959
  prevTarget.value = undefined;
466
960
  }
@@ -473,9 +967,9 @@ export function applyElement(part, value) {
473
967
  // Same callback now pointing at a different element: deliver
474
968
  // an `undefined` cleanup for the prior element first.
475
969
  if (!refChanged && partAny.__refElement !== undefined) {
476
- try { nextTarget(undefined); } catch {}
970
+ try { nextTarget.call(partAny.refHost, undefined); } catch {}
477
971
  }
478
- try { nextTarget(part.el); } catch {}
972
+ try { nextTarget.call(partAny.refHost, part.el); } catch {}
479
973
  } else if (typeof nextTarget === 'object') {
480
974
  nextTarget.value = part.el;
481
975
  }
@@ -495,6 +989,18 @@ export function applyChild(part, value, reconcileFormActionsCb) {
495
989
  // directive handlers recurse via applyChildInner (no re-clear) so
496
990
  // their own state survives the recursion.
497
991
  if (value === noChange) return;
992
+ // A custom directive resolves first, so the per-part state of the built-in
993
+ // directive it may return (until, guard, cache, watch) is judged against
994
+ // what actually renders here. Its own record is released when a plain
995
+ // value takes the part over, but kept while a wrapper (keyed / guard /
996
+ // cache / live) passes an inner value on, since that inner value is
997
+ // resolved against the same record further down.
998
+ const partAny = /** @type any */ (part);
999
+ if (partAny.__directive || isDirectiveResult(value)) {
1000
+ value = resolveDirectiveChain(part, part, '__directive', value,
1001
+ (v) => applyChildInner(part, v, reconcileFormActionsCb), !isWrapperDirective(value));
1002
+ if (value === noChange) return;
1003
+ }
498
1004
  clearStaleDirectiveState(part, value);
499
1005
  return applyChildInner(part, value, reconcileFormActionsCb);
500
1006
  }
@@ -518,11 +1024,13 @@ export function applyChildInner(part, value, reconcileFormActionsCb) {
518
1024
  // marked as renderer writes rather than authored content.
519
1025
  const previousHost = currentRenderHost;
520
1026
  currentRenderHost = part.__eventHost;
1027
+ const previousLogical = logicalParent;
1028
+ logicalParent = logicalParentOf(part.marker && part.marker.parentNode);
521
1029
  try {
522
1030
  return commitInto(part.marker && part.marker.parentNode, () =>
523
1031
  applyChildInnerRaw(part, value, reconcileFormActionsCb),
524
1032
  );
525
- } finally { currentRenderHost = previousHost; }
1033
+ } finally { currentRenderHost = previousHost; logicalParent = previousLogical; }
526
1034
  }
527
1035
 
528
1036
  /**
@@ -532,6 +1040,15 @@ export function applyChildInner(part, value, reconcileFormActionsCb) {
532
1040
  export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
533
1041
  const marker = part.marker;
534
1042
 
1043
+ // A custom directive a built-in directive passed on (`keyed(k, mine())`,
1044
+ // `guard(deps, () => mine())`): resolved against the part's record, which
1045
+ // applyChild kept for exactly this.
1046
+ if (isDirectiveResult(value)) {
1047
+ value = resolveDirectiveChain(part, part, '__directive', value,
1048
+ (v) => applyChildInner(part, v, reconcileFormActionsCb), false);
1049
+ if (value === noChange) return;
1050
+ }
1051
+
535
1052
  // live() in a CHILD position (#1443). applyPart's top-of-function unwrap only
536
1053
  // sees the hole's own value, so a live() nested inside an ARRAY child arrives
537
1054
  // here still wrapped and would stringify to "[object Object]". The server
@@ -548,15 +1065,29 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
548
1065
 
549
1066
  // unsafeHTML directive: inject raw HTML string as DOM nodes.
550
1067
  if (isUnsafeHTML(value)) {
551
- teardownChild(part);
552
1068
  const htmlStr = String(/** @type any */ (value).value ?? '');
1069
+ const ns = /** @type any */ (value).ns;
1070
+ // Dirty-checked against the markup last committed, as in Lit (#1686): the
1071
+ // same string again keeps the nodes, and anything the page did to them,
1072
+ // rather than re-parsing (each render builds a new directive object).
1073
+ const last = /** @type any */ (part).__unsafeCommitted;
1074
+ if (last && last.str === htmlStr && last.ns === ns && part.child === last.nodes) return;
1075
+ teardownChild(part);
553
1076
  const template = document.createElement('template');
554
- template.innerHTML = htmlStr;
1077
+ // unsafeSVG / unsafeMathML: parse inside the namespace's root element so
1078
+ // the nodes are SVG / MathML elements, then lift them out of the wrapper.
1079
+ const wrapTag = ns === 'svg' ? 'svg' : ns === 'mathml' ? 'math' : null;
1080
+ template.innerHTML = wrapTag ? `<${wrapTag}>${htmlStr}</${wrapTag}>` : htmlStr;
1081
+ if (wrapTag) {
1082
+ const wrapper = /** @type {Element} */ (template.content.firstChild);
1083
+ wrapper.replaceWith(...wrapper.childNodes);
1084
+ }
555
1085
  const nodes = [...template.content.childNodes];
556
1086
  const frag = document.createDocumentFragment();
557
1087
  for (const n of nodes) frag.appendChild(n);
558
1088
  marker.parentNode?.insertBefore(frag, marker);
559
1089
  part.child = nodes;
1090
+ /** @type any */ (part).__unsafeCommitted = { str: htmlStr, ns, nodes };
560
1091
  return;
561
1092
  }
562
1093
 
@@ -611,7 +1142,7 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
611
1142
  teardownChild(part);
612
1143
  const tpl = /** @type any */ (value).template;
613
1144
  if (tpl && tpl.content) {
614
- const frag = tpl.content.cloneNode(true);
1145
+ const frag = document.importNode(tpl.content, true);
615
1146
  const nodes = [...frag.childNodes];
616
1147
  marker.parentNode?.insertBefore(frag, marker);
617
1148
  part.child = nodes;
@@ -654,6 +1185,11 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
654
1185
  if (isWatch(value)) {
655
1186
  return applyWatch(part, /** @type any */ (value).signal, reconcileFormActionsCb);
656
1187
  }
1188
+ // A bare `${signal}` in a child position binds exactly like `watch(signal)`
1189
+ // (lit's signals package renders a bare signal the same way).
1190
+ if (isSignal(value)) {
1191
+ return applyWatch(part, /** @type any */ (value), reconcileFormActionsCb);
1192
+ }
657
1193
 
658
1194
  // asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
659
1195
  // yielded value is mapped (optional) and appended (asyncAppend) or
@@ -689,6 +1225,17 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
689
1225
  // flipping one item's binding rebuilt the WHOLE list and detached every
690
1226
  // node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
691
1227
  // reordering.
1228
+ //
1229
+ // Any other iterable (a generator from `map()` / `join()` / `range()`, a
1230
+ // Set) renders exactly like an array, as in lit, so it is drained into one
1231
+ // first. A string never reaches here as an iterable (it is checked by
1232
+ // `typeof`), and directive markers are plain objects with no iterator.
1233
+ // A DOM node is committed as itself (below), even one that is also iterable
1234
+ // (a `<form>`, a `<select>`), as in lit.
1235
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && !isDomNode(value)
1236
+ && typeof (/** @type any */ (value))[Symbol.iterator] === 'function') {
1237
+ value = Array.from(/** @type {Iterable<unknown>} */ (value));
1238
+ }
692
1239
  if (Array.isArray(value)) {
693
1240
  if (part.child && /** @type any */ (part.child).kind === 'array') {
694
1241
  reconcileArray(part, value, reconcileFormActionsCb);
@@ -701,6 +1248,10 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
701
1248
  return;
702
1249
  }
703
1250
 
1251
+ // The same node again keeps it where it is, as lit's dirty check does, so
1252
+ // its focus and state are not disturbed by a re-insert.
1253
+ if (isDomNode(value) && Array.isArray(part.child) && part.child.length === 1 && part.child[0] === value) return;
1254
+
704
1255
  // Remove previously rendered nodes between marker and its next sibling we own.
705
1256
  if (part.child) {
706
1257
  const c = /** @type any */ (part.child);
@@ -720,6 +1271,10 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
720
1271
  updateInstance(inst, /** @type any */ (value).values, reconcileFormActionsCb);
721
1272
  return;
722
1273
  }
1274
+ // A full teardown, as `clear()` does: a ref on an element of the
1275
+ // outgoing template is unset (its callback gets `undefined`), the way
1276
+ // Lit's ref directive is on disconnection, before the new one binds.
1277
+ disposeInstance(inst);
723
1278
  removeBetween(inst.startNode, inst.endNode);
724
1279
  part.child = undefined;
725
1280
  } else {
@@ -731,12 +1286,13 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
731
1286
  }
732
1287
  }
733
1288
 
734
- if (value == null || value === nothing || value === noChange) return;
1289
+ // An empty string renders no node at all, as lit treats it like `nothing`.
1290
+ if (value == null || value === nothing || value === noChange || value === '') return;
735
1291
 
736
1292
  if (isTemplate(value)) {
737
1293
  const tr = /** @type any */ (value);
738
1294
  const { templateEl, parts, formActions } = compile(tr);
739
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1295
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
740
1296
  const startNode = document.createComment(`${MARKER}s`);
741
1297
  const endNode = document.createComment(`${MARKER}e`);
742
1298
  const bound = parts.map((p) => bindPart(p, frag));
@@ -760,17 +1316,44 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
760
1316
  return;
761
1317
  }
762
1318
 
763
- const node = document.createTextNode(String(value));
1319
+ // A DOM node renders as itself; anything else as text.
1320
+ const node = isDomNode(value) ? /** @type {ChildNode} */ (value) : document.createTextNode(String(value));
764
1321
  marker.parentNode?.insertBefore(node, marker);
765
1322
  part.child = [node];
766
1323
  }
767
1324
 
1325
+ /**
1326
+ * A DOM node a child binding renders as itself (lit's `_commitNode`). Checked
1327
+ * by shape rather than `instanceof Node`, so a node from another realm (an
1328
+ * iframe's document) is recognised too.
1329
+ * @param {unknown} v
1330
+ * @returns {boolean}
1331
+ */
1332
+ export function isDomNode(v) {
1333
+ return v !== null && typeof v === 'object' && typeof (/** @type any */ (v)).nodeType === 'number'
1334
+ && typeof (/** @type any */ (v)).cloneNode === 'function';
1335
+ }
1336
+
768
1337
  export function updateInstance(inst, values, reconcileFormActionsCb) {
769
- values = values.map((value, i) => value === noChange ? (inst.lastValues[i] ?? nothing) : value);
1338
+ // `noChange` keeps whatever this hole last committed, so it maps to the
1339
+ // previous value and the `Object.is` skip below leaves the DOM alone. It must
1340
+ // not fall back to `nothing` when that value was nullish: a hole that rendered
1341
+ // `title=""` from `undefined` keeps its attribute, as in Lit.
1342
+ values = values.map((value, i) => value === noChange ? inst.lastValues[i] : value);
1343
+ /** Interpolated attributes already rebuilt this pass, so each is built once. */
1344
+ /** @type {Set<number> | null} */
1345
+ let rebuilt = null;
770
1346
  for (let i = 0; i < values.length; i++) {
771
1347
  const next = values[i];
772
- if (Object.is(next, inst.lastValues[i])) continue;
1348
+ // A custom directive runs on every render even when handed the same
1349
+ // result object, as in lit, which resolves a directive before it compares.
1350
+ if (Object.is(next, inst.lastValues[i]) && !isDirectiveResult(next)) continue;
773
1351
  const bp = inst.bound[i];
1352
+ const anchorIdx = bp.kind === 'attr-mixed' ? i : /** @type any */ (bp).mixedAnchor;
1353
+ if (anchorIdx != null) {
1354
+ if (rebuilt?.has(anchorIdx)) { inst.lastValues[i] = next; continue; }
1355
+ (rebuilt ??= new Set()).add(anchorIdx);
1356
+ }
774
1357
  // A hole that belongs to a mixed attribute (`class="a ${x} b ${y}"`) is a
775
1358
  // `noop` pointing at the attribute's anchor part; re-apply the anchor so the
776
1359
  // whole attribute is rebuilt from every hole's current value, not just the
@@ -876,7 +1459,7 @@ export function nodesToFrag(nodes) {
876
1459
  */
877
1460
  export function buildDetached(tr, reconcileFormActionsCb) {
878
1461
  const { templateEl, parts, formActions } = compile(tr);
879
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1462
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
880
1463
  const startNode = document.createComment(`${MARKER}s`);
881
1464
  const endNode = document.createComment(`${MARKER}e`);
882
1465
  const bound = parts.map((p) => bindPart(p, frag));
@@ -908,6 +1491,9 @@ export function buildDetached(tr, reconcileFormActionsCb) {
908
1491
 
909
1492
  /** @param {TemplateInstance} inst */
910
1493
  export function disposeInstance(inst) {
1494
+ // Custom directives in the instance (and in the regions its child parts
1495
+ // rendered) are released first; an async one runs its `disconnected()`.
1496
+ releaseInstanceDirectives(inst);
911
1497
  for (const p of inst.bound) {
912
1498
  if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher, p.handler);
913
1499
  if (p.kind === 'element') {
@@ -938,7 +1524,7 @@ export function disposeInstance(inst) {
938
1524
  const prev = /** @type any */ (p).lastTarget;
939
1525
  if (prev) {
940
1526
  if (typeof prev === 'function') {
941
- try { prev(undefined); } catch {}
1527
+ try { prev.call(/** @type any */ (p).refHost, undefined); } catch {}
942
1528
  } else if (typeof prev === 'object') {
943
1529
  try { prev.value = undefined; } catch {}
944
1530
  }
@@ -962,8 +1548,9 @@ function applyRepeatFresh(marker, state, value, reconcileFormActionsCb) {
962
1548
  const parent = marker.parentNode;
963
1549
  if (!parent) return;
964
1550
  const bulk = document.createDocumentFragment();
1551
+ const seen = new Map();
965
1552
  for (let i = 0; i < items.length; i++) {
966
- const key = keyFn(items[i], i);
1553
+ const key = slotKey(state, keyFn(items[i], i), seen);
967
1554
  const tr = templateFn(items[i], i);
968
1555
  if (!isTemplate(tr)) continue;
969
1556
  const { inst, frag } = buildDetached(/** @type any */ (tr), reconcileFormActionsCb);
@@ -973,6 +1560,28 @@ function applyRepeatFresh(marker, state, value, reconcileFormActionsCb) {
973
1560
  parent.insertBefore(bulk, marker);
974
1561
  }
975
1562
 
1563
+ /**
1564
+ * The map key for one occurrence of `key` in a pass (#1686). The first
1565
+ * occurrence is the key itself; the nth repeat of it is a placeholder object
1566
+ * kept on the state, the same one every pass, so a list with duplicate keys
1567
+ * gives each row its own entry. A plain key map let the second row overwrite
1568
+ * the first, whose nodes then stayed in the document tracked by nothing, and
1569
+ * each re-render added another copy. Lit leaves duplicate keys undefined but
1570
+ * never leaks a row.
1571
+ * @param {any} state
1572
+ * @param {unknown} key
1573
+ * @param {Map<unknown, number>} seen occurrences so far in this pass
1574
+ */
1575
+ function slotKey(state, key, seen) {
1576
+ const n = seen.get(key) ?? 0;
1577
+ seen.set(key, n + 1);
1578
+ if (n === 0) return key;
1579
+ const dups = (state.dups ??= new Map());
1580
+ let list = dups.get(key);
1581
+ if (!list) dups.set(key, (list = []));
1582
+ return (list[n - 1] ??= { duplicateOf: key, n });
1583
+ }
1584
+
976
1585
  /**
977
1586
  * Keyed reconciliation. For each key in the new list:
978
1587
  * - hit: update the existing instance in place (if template shape matches),
@@ -990,11 +1599,12 @@ function reconcileRepeat(part, value, reconcileFormActionsCb) {
990
1599
  const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
991
1600
  const { items, keyFn, templateFn } = value;
992
1601
  const newMap = new Map();
1602
+ const seen = new Map();
993
1603
 
994
1604
  try {
995
1605
  // Walk the new list and position each item's nodes immediately before the marker.
996
1606
  for (let i = 0; i < items.length; i++) {
997
- const key = keyFn(items[i], i);
1607
+ const key = slotKey(state, keyFn(items[i], i), seen);
998
1608
  const tr = templateFn(items[i], i);
999
1609
  if (!isTemplate(tr)) continue;
1000
1610
  const existing = state.map.get(key);
@@ -1128,22 +1738,29 @@ function teardownRepeat(state) {
1128
1738
  * instance (bookended by its own markers), a `text` carries a single text
1129
1739
  * node, and an `empty` slot (a nullish / boolean element) renders nothing
1130
1740
  * but still holds the position so index-based reconciliation stays aligned.
1131
- * @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'empty' }} ArrayItem
1741
+ * A `node` is a DOM node the array held, rendered as itself.
1742
+ * @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'node', node: ChildNode } | { type: 'empty' } | { type: 'part', start: Comment, sub: any }} ArrayItem
1132
1743
  */
1133
1744
 
1134
1745
  /** @param {ArrayItem} item @returns {ChildNode | null} */
1135
1746
  function arrayItemFirstNode(item) {
1747
+ if (item.type === 'part') return item.start;
1136
1748
  if (item.type === 'tpl') return item.inst.startNode;
1137
- if (item.type === 'text') return item.node;
1749
+ if (item.type === 'text' || item.type === 'node') return item.node;
1138
1750
  return null;
1139
1751
  }
1140
1752
 
1141
1753
  /** @param {ArrayItem} item */
1142
1754
  function removeArrayItem(item) {
1143
- if (item.type === 'tpl') {
1755
+ if (item.type === 'part') {
1756
+ // Its directive and any signal subscription go with it, as a hole's do.
1757
+ releasePartDirectives(item.sub);
1758
+ teardownChild(item.sub);
1759
+ removeBetween(item.start, item.sub.marker);
1760
+ } else if (item.type === 'tpl') {
1144
1761
  disposeInstance(item.inst);
1145
1762
  removeBetween(item.inst.startNode, item.inst.endNode);
1146
- } else if (item.type === 'text') {
1763
+ } else if (item.type === 'text' || item.type === 'node') {
1147
1764
  item.node.parentNode?.removeChild(item.node);
1148
1765
  }
1149
1766
  }
@@ -1159,11 +1776,24 @@ function buildArrayItem(v, reconcileFormActionsCb) {
1159
1776
  // #1443: unwrap live() per ITEM. applyPart unwraps the hole's own value, so
1160
1777
  // an array child arrives unwrapped at the top and its ITEMS do not.
1161
1778
  if (isLive(v)) v = /** @type any */ (v).value;
1779
+ if (isPartItem(v)) {
1780
+ // A directive or a nested array gets a child part of its own between two
1781
+ // comments, as each item is in Lit, so it renders and keeps its directive
1782
+ // state exactly as it would in a hole. It is filled once its comments are
1783
+ // in the document (`fill`), because a part commits relative to its parent.
1784
+ const start = document.createComment('');
1785
+ const marker = document.createComment('');
1786
+ const frag = document.createDocumentFragment();
1787
+ frag.append(start, marker);
1788
+ const sub = { kind: 'child', marker, __eventHost: currentRenderHost };
1789
+ return { item: { type: 'part', start, sub }, frag, fill: () => applyChild(/** @type any */ (sub), v, reconcileFormActionsCb) };
1790
+ }
1162
1791
  if (isTemplate(v)) {
1163
1792
  const { inst, frag } = buildDetached(/** @type any */ (v), reconcileFormActionsCb);
1164
1793
  return { item: { type: 'tpl', inst }, frag };
1165
1794
  }
1166
- if (v != null && v !== nothing && v !== noChange) {
1795
+ if (isDomNode(v)) return { item: { type: 'node', node: /** @type any */ (v) }, frag: /** @type any */ (v) };
1796
+ if (v != null && v !== nothing && v !== noChange && v !== '') {
1167
1797
  const node = document.createTextNode(String(v));
1168
1798
  return { item: { type: 'text', node }, frag: node };
1169
1799
  }
@@ -1182,13 +1812,27 @@ function applyArrayFresh(marker, state, value, reconcileFormActionsCb) {
1182
1812
  const bulk = document.createDocumentFragment();
1183
1813
  /** @type {ArrayItem[]} */
1184
1814
  const items = [];
1815
+ /** @type {Array<() => void>} */
1816
+ const fills = [];
1185
1817
  for (const v of value) {
1186
- const { item, frag } = buildArrayItem(v, reconcileFormActionsCb);
1818
+ const { item, frag, fill } = buildArrayItem(v, reconcileFormActionsCb);
1187
1819
  if (frag) bulk.appendChild(frag);
1820
+ if (fill) fills.push(fill);
1188
1821
  items.push(item);
1189
1822
  }
1190
1823
  parent.insertBefore(bulk, marker);
1191
1824
  state.items = items;
1825
+ for (const fill of fills) fill();
1826
+ }
1827
+
1828
+ /**
1829
+ * Whether an array item renders through a child part of its own: a directive
1830
+ * result or a nested array / iterable, anything that is not a template, a node
1831
+ * or a primitive (which the item types below handle without one).
1832
+ * @param {unknown} v
1833
+ */
1834
+ function isPartItem(v) {
1835
+ return v !== null && typeof v === 'object' && !isTemplate(v) && !isDomNode(v);
1192
1836
  }
1193
1837
 
1194
1838
  /**
@@ -1222,7 +1866,14 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
1222
1866
  const raw = value[i];
1223
1867
  const v = isLive(raw) ? /** @type any */ (raw).value : raw;
1224
1868
  const o = old[i];
1225
- if (isTemplate(v)) {
1869
+ if (isPartItem(v)) {
1870
+ if (o && o.type === 'part') {
1871
+ applyChild(o.sub, v, reconcileFormActionsCb);
1872
+ next.push(o);
1873
+ consumed = i + 1;
1874
+ continue;
1875
+ }
1876
+ } else if (isTemplate(v)) {
1226
1877
  const tr = /** @type any */ (v);
1227
1878
  if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
1228
1879
  updateInstance(o.inst, tr.values, reconcileFormActionsCb);
@@ -1230,7 +1881,13 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
1230
1881
  consumed = i + 1;
1231
1882
  continue;
1232
1883
  }
1233
- } else if (v != null && v !== nothing && v !== noChange) {
1884
+ } else if (isDomNode(v)) {
1885
+ if (o && o.type === 'node' && o.node === v) {
1886
+ next.push(o);
1887
+ consumed = i + 1;
1888
+ continue;
1889
+ }
1890
+ } else if (v != null && v !== nothing && v !== noChange && v !== '') {
1234
1891
  if (o && o.type === 'text') {
1235
1892
  const str = String(v);
1236
1893
  if (o.node.data !== str) o.node.data = str;
@@ -1259,9 +1916,10 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
1259
1916
  // The push sits BEFORE the removal, which is a pure reordering on the
1260
1917
  // success path and means a slot that has already been built and
1261
1918
  // inserted is never untracked at any throw point.
1262
- const { item, frag } = buildArrayItem(v, reconcileFormActionsCb);
1919
+ const { item, frag, fill } = buildArrayItem(v, reconcileFormActionsCb);
1263
1920
  if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
1264
1921
  next.push(item);
1922
+ if (fill) fill();
1265
1923
  if (o) removeArrayItem(o);
1266
1924
  consumed = i + 1;
1267
1925
  }
@@ -1436,7 +2094,7 @@ export function clearStaleDirectiveState(part, value) {
1436
2094
  if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
1437
2095
  partAny.__keyedKey = undefined;
1438
2096
  }
1439
- if (partAny.__watchSub && !isWatch(value)) {
2097
+ if (partAny.__watchSub && !isWatch(value) && !isSignal(value)) {
1440
2098
  teardownWatch(partAny);
1441
2099
  }
1442
2100
  }
@@ -1785,6 +2443,62 @@ function applyWatch(part, sig, reconcileFormActionsCb) {
1785
2443
  applyChildInner(part, initial, reconcileFormActionsCb);
1786
2444
  }
1787
2445
 
2446
+ /**
2447
+ * Bind an attribute-shaped part (attribute, boolean, interpolated attribute)
2448
+ * to the signals it renders. `run` applies the part's value, reading each
2449
+ * signal inside the part's own `Signal.subtle.Watcher`, so a change re-runs
2450
+ * `run` in a microtask and rewrites only this attribute; the host's render
2451
+ * watcher does not see the read (the commit runs untracked), which is what
2452
+ * keeps a signal change from re-rendering the whole component.
2453
+ *
2454
+ * The watcher is rebuilt on every render of the part, so the read set always
2455
+ * matches the values the latest render bound.
2456
+ *
2457
+ * @param {any} part
2458
+ * @param {() => void} run
2459
+ */
2460
+ function bindSignalPart(part, run) {
2461
+ teardownSignalPart(part);
2462
+ if (currentRenderRoot) part.__commitOwner = boundaryOwnerOf(currentRenderRoot);
2463
+ const watcher = new Signal.subtle.Watcher(() => {
2464
+ queueMicrotask(() => {
2465
+ if (part.__sigSub !== watcher) return;
2466
+ try {
2467
+ commitOutOfBand(part, () => watcher.observe(run));
2468
+ } catch (err) {
2469
+ reportOutOfBandCommitError(part, err);
2470
+ }
2471
+ });
2472
+ });
2473
+ part.__sigSub = watcher;
2474
+ watcher.observe(run);
2475
+ }
2476
+
2477
+ /**
2478
+ * Whether an attribute-shaped hole is bound to a signal: a bare signal, or a
2479
+ * `watch(signal)` marker (which means the same thing outside a child hole).
2480
+ * @param {unknown} v
2481
+ */
2482
+ function isSignalLike(v) {
2483
+ return isSignal(v) || isWatch(v);
2484
+ }
2485
+
2486
+ /** @param {any} v */
2487
+ function readSignalLike(v) {
2488
+ return isWatch(v) ? v.signal.get() : v.get();
2489
+ }
2490
+
2491
+ /**
2492
+ * Dispose an attribute-shaped part's signal watcher.
2493
+ * @param {any} part
2494
+ */
2495
+ function teardownSignalPart(part) {
2496
+ if (part.__sigSub) {
2497
+ part.__sigSub.dispose();
2498
+ part.__sigSub = undefined;
2499
+ }
2500
+ }
2501
+
1788
2502
  /**
1789
2503
  * Dispose a `watch` directive's per-part watcher. Called from
1790
2504
  * `teardownChild` and from `clearStaleDirectiveState` when the value
@@ -2029,7 +2743,7 @@ function renderToNodes(value, reconcileFormActionsCb) {
2029
2743
  if (isTemplate(value)) {
2030
2744
  const tr = /** @type any */ (value);
2031
2745
  const { templateEl, parts, formActions } = compile(tr);
2032
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
2746
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
2033
2747
  const bound = parts.map((p) => bindPart(p, frag));
2034
2748
  for (let i = 0; i < tr.values.length; i++) {
2035
2749
  applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActionsCb);