@webjsdev/core 0.7.65 → 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 (92) 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 +22 -21
  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 +18 -9
  9. package/index.d.ts +26 -10
  10. package/index.js +21 -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-directives.d.ts +6 -0
  19. package/src/attribute-directives.js +49 -0
  20. package/src/attribute-reader.js +213 -25
  21. package/src/binding-prefixes.js +10 -53
  22. package/src/component/lifecycle.js +921 -252
  23. package/src/component/reactive.js +7 -14
  24. package/src/component/server-element.js +23 -1
  25. package/src/component.d.ts +160 -44
  26. package/src/context.d.ts +94 -18
  27. package/src/context.js +341 -217
  28. package/src/converters.d.ts +7 -0
  29. package/src/converters.js +38 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +40 -1
  35. package/src/directives.js +160 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +8 -0
  40. package/src/html.js +55 -2
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +250 -0
  51. package/src/render-client/parts.js +790 -42
  52. package/src/render-client/reconciler.js +84 -23
  53. package/src/render-client/template-compiler.js +152 -43
  54. package/src/render-client.d.ts +6 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +50 -2
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +100 -20
  59. package/src/render-server/served-attrs.js +78 -0
  60. package/src/render-server/walk.js +177 -5
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/anchors.js +3 -1
  66. package/src/router-client/boundaries.js +15 -1
  67. package/src/router-client/constants.js +30 -0
  68. package/src/router-client/dom-differ.js +17 -0
  69. package/src/router-client/fetch-apply.js +107 -5
  70. package/src/router-client/frames.js +6 -3
  71. package/src/router-client/head-merge.js +58 -10
  72. package/src/router-client/navigator.js +49 -9
  73. package/src/router-client/owned-styles.js +372 -0
  74. package/src/router-client/prefetch.js +6 -4
  75. package/src/router-client/root-attrs.js +43 -0
  76. package/src/router-client/stream.js +7 -0
  77. package/src/router-client/stylesheets.js +191 -0
  78. package/src/router-client/swap.js +53 -10
  79. package/src/routes.d.ts +75 -18
  80. package/src/sentinels.d.ts +2 -0
  81. package/src/sentinels.js +4 -0
  82. package/src/serialize.d.ts +2 -0
  83. package/src/serialize.js +35 -4
  84. package/src/signal.d.ts +7 -0
  85. package/src/signal.js +26 -6
  86. package/src/slot/polyfills.js +3 -3
  87. package/src/slot.js +4 -8
  88. package/src/static-html.d.ts +22 -0
  89. package/src/static-html.js +144 -0
  90. package/src/task.d.ts +63 -20
  91. package/src/task.js +326 -293
  92. package/src/webjs-config.d.ts +2 -9
@@ -1,8 +1,12 @@
1
+ import { isAttributeMap, applyAttributeMap } from '../attribute-directives.js';
2
+ import { nothing, noChange } from '../sentinels.js';
1
3
  import { isTemplate, MARKER } from '../html.js';
2
4
  import {
3
5
  assertNotFunctionActionAttr, assertNotFunctionReflectedActionProp, isBoundFormAction,
4
6
  } from '../form-action.js';
5
7
  import { isRepeat } from '../repeat.js';
8
+ import { isDirectiveResult, PartType } from '../directive.js';
9
+ import { isSignal } from '../signal.js';
6
10
  import {
7
11
  isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch
8
12
  } from '../directives.js';
@@ -11,6 +15,7 @@ import {
11
15
  SLOT_FALLBACK_FRAG, SLOT_STATE, SLOT_OWNER, applySlotAssignments, withRendererWrites,
12
16
  } from '../slot.js';
13
17
  import { compile, templateCache } from './template-compiler.js';
18
+ import { adoptedSlots } from './adopt-slots.js';
14
19
  import { attrHoleValue } from '../binding-prefixes.js';
15
20
 
16
21
  /**
@@ -23,12 +28,37 @@ import { attrHoleValue } from '../binding-prefixes.js';
23
28
  * first. Same reason `SLOT_OWNER` exists.
24
29
  * @type {any}
25
30
  */
31
+ export let currentRenderHost;
32
+ export function setCurrentRenderHost(host) { currentRenderHost = host; }
26
33
  export let currentRenderRoot = null;
27
34
 
28
35
  export function setCurrentRenderRoot(root) {
29
36
  currentRenderRoot = root;
30
37
  }
31
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
+
32
62
  /**
33
63
  * Open the renderer-write window on a light-DOM host while `fn` commits into
34
64
  * it, so the host's patched slot-interception methods delegate to native and
@@ -132,6 +162,8 @@ export function boundaryOwnerOf(root) {
132
162
  export function commitOutOfBand(part, fn) {
133
163
  const owner = /** @type any */ (part).__commitOwner;
134
164
  const prev = currentRenderRoot;
165
+ const prevHost = currentRenderHost;
166
+ currentRenderHost = part.__eventHost;
135
167
  if (owner) currentRenderRoot = owner;
136
168
  try {
137
169
  fn();
@@ -150,7 +182,7 @@ export function bindPart(p, root) {
150
182
  let node = /** @type Node */ (root);
151
183
  for (const i of p.path) node = node.childNodes[i];
152
184
  if (p.kind === 'child') {
153
- return { kind: 'child', marker: /** @type Comment */ (node) };
185
+ return { kind: 'child', marker: /** @type Comment */ (node), __eventHost: currentRenderHost };
154
186
  }
155
187
  const el = /** @type Element */ (node);
156
188
  if (p.kind === 'event') {
@@ -160,17 +192,20 @@ export function bindPart(p, root) {
160
192
  el,
161
193
  name: p.name || '',
162
194
  handler: null,
195
+ eventHost: currentRenderHost,
163
196
  // The dispatcher is the registered listener; handler swaps behind it.
164
- dispatcher(ev) { part.handler?.(ev); },
197
+ dispatcher(ev) {
198
+ if (typeof part.handler === 'function') part.handler.call(part.eventHost ?? part.el, ev);
199
+ else part.handler?.handleEvent(ev);
200
+ },
165
201
  };
166
- el.addEventListener(part.name, part.dispatcher);
167
202
  return part;
168
203
  }
169
204
  if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
170
205
  if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
171
206
  if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
172
207
  if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
173
- if (p.kind === 'element') return { kind: 'element', el };
208
+ if (p.kind === 'element') return { kind: 'element', el, refHost: currentRenderHost };
174
209
  if (p.kind === 'slot') {
175
210
  const slotEl = /** @type {HTMLSlotElement} */ (el);
176
211
  // Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
@@ -183,16 +218,451 @@ export function bindPart(p, root) {
183
218
  throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
184
219
  }
185
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
+
186
582
  /**
187
583
  * @param {BoundPart} part
188
584
  * @param {unknown} value
189
585
  * @param {unknown} _prev
190
586
  */
191
587
  export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb) {
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
+ }
628
+ if (isAttributeMap(value)) { applyAttributeMap(part, value); return; }
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
+ }
192
660
  // Unwrap live() to dirty-check against the live DOM value, not the
193
661
  // last rendered value. Essential for <input> two-way binding.
194
662
  if (isLive(value)) {
195
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;
196
666
  if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
197
667
  if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
198
668
  if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
@@ -247,21 +717,28 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
247
717
  // first and is left alone, so it still writes whatever the author
248
718
  // returns for a function.
249
719
  assertNotFunctionReflectedActionProp(value, part.name, part.el.localName);
250
- /** @type any */ (part.el)[part.name] = value;
720
+ /** @type any */ (part.el)[part.name] = value === nothing ? undefined : value;
251
721
  break;
252
722
  case 'bool':
253
723
  // #1154: never leaked (a boolean binding stringifies nothing), but
254
724
  // `?action=${fn}` is meaningless in every case, and refusing it keeps
255
725
  // this renderer agreeing with both SSR machines.
256
726
  assertNotFunctionActionAttr(value, part.name, part.el.localName);
257
- if (value) part.el.setAttribute(part.name, '');
727
+ if (value && value !== nothing) part.el.setAttribute(part.name, '');
258
728
  else part.el.removeAttribute(part.name);
259
729
  break;
260
730
  case 'event':
261
731
  // NOT guarded, deliberately. An event binding never stringifies its
262
732
  // value, and a function is the legitimate thing to pass one, so
263
733
  // `<my-el @action=${handler}>` has to keep working.
264
- part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
734
+ {
735
+ const old = part.handler;
736
+ const next = value == null || value === nothing ? null : value;
737
+ const changed = old?.capture !== next?.capture || old?.once !== next?.once || old?.passive !== next?.passive;
738
+ if (old && (!next || changed)) part.el.removeEventListener(part.name, part.dispatcher, old);
739
+ if (next && (!old || changed)) part.el.addEventListener(part.name, part.dispatcher, next);
740
+ part.handler = next;
741
+ }
265
742
  break;
266
743
  case 'element':
267
744
  applyElement(part, value);
@@ -270,8 +747,36 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
270
747
  // Reconstruct the attribute from static pieces + all dynamic values.
271
748
  const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
272
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;
273
759
  for (let j = 0; j < mp.group.length; j++) {
274
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] ?? '';
275
780
  // Unwrap live() PER PIECE (#1443). The top-of-function unwrap only
276
781
  // sees the anchor hole's own value, and every hole inside a QUOTED
277
782
  // attribute lands here (single-hole included, the compiler classifies
@@ -279,12 +784,22 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
279
784
  // server now emits correctly would be rewritten to the wrapper's
280
785
  // stringification on the next client commit. Mirrors the reconciler's
281
786
  // effectiveFormAttr, which already unwraps each group piece.
282
- 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);
283
793
  // #1154: same function guard for each piece of a mixed attribute.
284
794
  assertNotFunctionActionAttr(piece, part.name, part.el.localName);
285
- val += String(piece ?? '');
795
+ if (piece === nothing) { part.el.removeAttribute(part.name); return; }
796
+ committed[j] = String(piece ?? '');
797
+ val += committed[j];
286
798
  val += mp.statics[j + 1] || '';
287
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;
288
803
  part.el.setAttribute(part.name, val);
289
804
  break;
290
805
  }
@@ -333,9 +848,16 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
333
848
  // application is deferred one microtask so the documented
334
849
  // lifecycle timing holds: firstUpdated() sees the <slot>
335
850
  // element itself, the populated content lands right after.
336
- const frag = document.createDocumentFragment();
337
- while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
338
- /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
851
+ // A slot a hydration render ADOPTED (#1666) is the live SSR slot: its
852
+ // children are the projected nodes, left in place, and its holding
853
+ // fragment already carries the fresh clone's fallback.
854
+ if (adoptedSlots.has(slotEl)) {
855
+ adoptedSlots.delete(slotEl);
856
+ } else {
857
+ const frag = document.createDocumentFragment();
858
+ while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
859
+ /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
860
+ }
339
861
  queueMicrotask(() => applySlotAssignments(host));
340
862
  };
341
863
  const directHost = findSlotHost(slotEl);
@@ -432,7 +954,7 @@ export function applyElement(part, value) {
432
954
 
433
955
  if (refChanged && prevTarget) {
434
956
  if (typeof prevTarget === 'function') {
435
- try { prevTarget(undefined); } catch {}
957
+ try { prevTarget.call(partAny.refHost, undefined); } catch {}
436
958
  } else if (typeof prevTarget === 'object') {
437
959
  prevTarget.value = undefined;
438
960
  }
@@ -445,9 +967,9 @@ export function applyElement(part, value) {
445
967
  // Same callback now pointing at a different element: deliver
446
968
  // an `undefined` cleanup for the prior element first.
447
969
  if (!refChanged && partAny.__refElement !== undefined) {
448
- try { nextTarget(undefined); } catch {}
970
+ try { nextTarget.call(partAny.refHost, undefined); } catch {}
449
971
  }
450
- try { nextTarget(part.el); } catch {}
972
+ try { nextTarget.call(partAny.refHost, part.el); } catch {}
451
973
  } else if (typeof nextTarget === 'object') {
452
974
  nextTarget.value = part.el;
453
975
  }
@@ -466,6 +988,19 @@ export function applyChild(part, value, reconcileFormActionsCb) {
466
988
  // short-circuit, etc. Done once per outermost applyChild call; the
467
989
  // directive handlers recurse via applyChildInner (no re-clear) so
468
990
  // their own state survives the recursion.
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
+ }
469
1004
  clearStaleDirectiveState(part, value);
470
1005
  return applyChildInner(part, value, reconcileFormActionsCb);
471
1006
  }
@@ -487,9 +1022,15 @@ export function applyChildInner(part, value, reconcileFormActionsCb) {
487
1022
  // asyncReplace, streaming) re-enter here from a promise / microtask OUTSIDE
488
1023
  // any render() window, so this is where those commits into a light host are
489
1024
  // marked as renderer writes rather than authored content.
490
- return commitInto(part.marker && part.marker.parentNode, () =>
491
- applyChildInnerRaw(part, value, reconcileFormActionsCb),
492
- );
1025
+ const previousHost = currentRenderHost;
1026
+ currentRenderHost = part.__eventHost;
1027
+ const previousLogical = logicalParent;
1028
+ logicalParent = logicalParentOf(part.marker && part.marker.parentNode);
1029
+ try {
1030
+ return commitInto(part.marker && part.marker.parentNode, () =>
1031
+ applyChildInnerRaw(part, value, reconcileFormActionsCb),
1032
+ );
1033
+ } finally { currentRenderHost = previousHost; logicalParent = previousLogical; }
493
1034
  }
494
1035
 
495
1036
  /**
@@ -499,6 +1040,15 @@ export function applyChildInner(part, value, reconcileFormActionsCb) {
499
1040
  export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
500
1041
  const marker = part.marker;
501
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
+
502
1052
  // live() in a CHILD position (#1443). applyPart's top-of-function unwrap only
503
1053
  // sees the hole's own value, so a live() nested inside an ARRAY child arrives
504
1054
  // here still wrapped and would stringify to "[object Object]". The server
@@ -515,15 +1065,29 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
515
1065
 
516
1066
  // unsafeHTML directive: inject raw HTML string as DOM nodes.
517
1067
  if (isUnsafeHTML(value)) {
518
- teardownChild(part);
519
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);
520
1076
  const template = document.createElement('template');
521
- 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
+ }
522
1085
  const nodes = [...template.content.childNodes];
523
1086
  const frag = document.createDocumentFragment();
524
1087
  for (const n of nodes) frag.appendChild(n);
525
1088
  marker.parentNode?.insertBefore(frag, marker);
526
1089
  part.child = nodes;
1090
+ /** @type any */ (part).__unsafeCommitted = { str: htmlStr, ns, nodes };
527
1091
  return;
528
1092
  }
529
1093
 
@@ -578,7 +1142,7 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
578
1142
  teardownChild(part);
579
1143
  const tpl = /** @type any */ (value).template;
580
1144
  if (tpl && tpl.content) {
581
- const frag = tpl.content.cloneNode(true);
1145
+ const frag = document.importNode(tpl.content, true);
582
1146
  const nodes = [...frag.childNodes];
583
1147
  marker.parentNode?.insertBefore(frag, marker);
584
1148
  part.child = nodes;
@@ -621,6 +1185,11 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
621
1185
  if (isWatch(value)) {
622
1186
  return applyWatch(part, /** @type any */ (value).signal, reconcileFormActionsCb);
623
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
+ }
624
1193
 
625
1194
  // asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
626
1195
  // yielded value is mapped (optional) and appended (asyncAppend) or
@@ -656,6 +1225,17 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
656
1225
  // flipping one item's binding rebuilt the WHOLE list and detached every
657
1226
  // node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
658
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
+ }
659
1239
  if (Array.isArray(value)) {
660
1240
  if (part.child && /** @type any */ (part.child).kind === 'array') {
661
1241
  reconcileArray(part, value, reconcileFormActionsCb);
@@ -668,6 +1248,10 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
668
1248
  return;
669
1249
  }
670
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
+
671
1255
  // Remove previously rendered nodes between marker and its next sibling we own.
672
1256
  if (part.child) {
673
1257
  const c = /** @type any */ (part.child);
@@ -687,6 +1271,10 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
687
1271
  updateInstance(inst, /** @type any */ (value).values, reconcileFormActionsCb);
688
1272
  return;
689
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);
690
1278
  removeBetween(inst.startNode, inst.endNode);
691
1279
  part.child = undefined;
692
1280
  } else {
@@ -698,12 +1286,13 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
698
1286
  }
699
1287
  }
700
1288
 
701
- if (value == null || value === false || value === true) 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;
702
1291
 
703
1292
  if (isTemplate(value)) {
704
1293
  const tr = /** @type any */ (value);
705
1294
  const { templateEl, parts, formActions } = compile(tr);
706
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1295
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
707
1296
  const startNode = document.createComment(`${MARKER}s`);
708
1297
  const endNode = document.createComment(`${MARKER}e`);
709
1298
  const bound = parts.map((p) => bindPart(p, frag));
@@ -727,16 +1316,44 @@ export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
727
1316
  return;
728
1317
  }
729
1318
 
730
- 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));
731
1321
  marker.parentNode?.insertBefore(node, marker);
732
1322
  part.child = [node];
733
1323
  }
734
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
+
735
1337
  export function updateInstance(inst, values, reconcileFormActionsCb) {
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;
736
1346
  for (let i = 0; i < values.length; i++) {
737
1347
  const next = values[i];
738
- 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;
739
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
+ }
740
1357
  // A hole that belongs to a mixed attribute (`class="a ${x} b ${y}"`) is a
741
1358
  // `noop` pointing at the attribute's anchor part; re-apply the anchor so the
742
1359
  // whole attribute is rebuilt from every hole's current value, not just the
@@ -842,7 +1459,7 @@ export function nodesToFrag(nodes) {
842
1459
  */
843
1460
  export function buildDetached(tr, reconcileFormActionsCb) {
844
1461
  const { templateEl, parts, formActions } = compile(tr);
845
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
1462
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
846
1463
  const startNode = document.createComment(`${MARKER}s`);
847
1464
  const endNode = document.createComment(`${MARKER}e`);
848
1465
  const bound = parts.map((p) => bindPart(p, frag));
@@ -874,8 +1491,11 @@ export function buildDetached(tr, reconcileFormActionsCb) {
874
1491
 
875
1492
  /** @param {TemplateInstance} inst */
876
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);
877
1497
  for (const p of inst.bound) {
878
- if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
1498
+ if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher, p.handler);
879
1499
  if (p.kind === 'element') {
880
1500
  // Unbind any active ref so the user observes the element being
881
1501
  // removed (callback receives undefined / Ref.value cleared).
@@ -904,7 +1524,7 @@ export function disposeInstance(inst) {
904
1524
  const prev = /** @type any */ (p).lastTarget;
905
1525
  if (prev) {
906
1526
  if (typeof prev === 'function') {
907
- try { prev(undefined); } catch {}
1527
+ try { prev.call(/** @type any */ (p).refHost, undefined); } catch {}
908
1528
  } else if (typeof prev === 'object') {
909
1529
  try { prev.value = undefined; } catch {}
910
1530
  }
@@ -928,8 +1548,9 @@ function applyRepeatFresh(marker, state, value, reconcileFormActionsCb) {
928
1548
  const parent = marker.parentNode;
929
1549
  if (!parent) return;
930
1550
  const bulk = document.createDocumentFragment();
1551
+ const seen = new Map();
931
1552
  for (let i = 0; i < items.length; i++) {
932
- const key = keyFn(items[i], i);
1553
+ const key = slotKey(state, keyFn(items[i], i), seen);
933
1554
  const tr = templateFn(items[i], i);
934
1555
  if (!isTemplate(tr)) continue;
935
1556
  const { inst, frag } = buildDetached(/** @type any */ (tr), reconcileFormActionsCb);
@@ -939,6 +1560,28 @@ function applyRepeatFresh(marker, state, value, reconcileFormActionsCb) {
939
1560
  parent.insertBefore(bulk, marker);
940
1561
  }
941
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
+
942
1585
  /**
943
1586
  * Keyed reconciliation. For each key in the new list:
944
1587
  * - hit: update the existing instance in place (if template shape matches),
@@ -956,11 +1599,12 @@ function reconcileRepeat(part, value, reconcileFormActionsCb) {
956
1599
  const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
957
1600
  const { items, keyFn, templateFn } = value;
958
1601
  const newMap = new Map();
1602
+ const seen = new Map();
959
1603
 
960
1604
  try {
961
1605
  // Walk the new list and position each item's nodes immediately before the marker.
962
1606
  for (let i = 0; i < items.length; i++) {
963
- const key = keyFn(items[i], i);
1607
+ const key = slotKey(state, keyFn(items[i], i), seen);
964
1608
  const tr = templateFn(items[i], i);
965
1609
  if (!isTemplate(tr)) continue;
966
1610
  const existing = state.map.get(key);
@@ -1094,22 +1738,29 @@ function teardownRepeat(state) {
1094
1738
  * instance (bookended by its own markers), a `text` carries a single text
1095
1739
  * node, and an `empty` slot (a nullish / boolean element) renders nothing
1096
1740
  * but still holds the position so index-based reconciliation stays aligned.
1097
- * @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
1098
1743
  */
1099
1744
 
1100
1745
  /** @param {ArrayItem} item @returns {ChildNode | null} */
1101
1746
  function arrayItemFirstNode(item) {
1747
+ if (item.type === 'part') return item.start;
1102
1748
  if (item.type === 'tpl') return item.inst.startNode;
1103
- if (item.type === 'text') return item.node;
1749
+ if (item.type === 'text' || item.type === 'node') return item.node;
1104
1750
  return null;
1105
1751
  }
1106
1752
 
1107
1753
  /** @param {ArrayItem} item */
1108
1754
  function removeArrayItem(item) {
1109
- 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') {
1110
1761
  disposeInstance(item.inst);
1111
1762
  removeBetween(item.inst.startNode, item.inst.endNode);
1112
- } else if (item.type === 'text') {
1763
+ } else if (item.type === 'text' || item.type === 'node') {
1113
1764
  item.node.parentNode?.removeChild(item.node);
1114
1765
  }
1115
1766
  }
@@ -1125,11 +1776,24 @@ function buildArrayItem(v, reconcileFormActionsCb) {
1125
1776
  // #1443: unwrap live() per ITEM. applyPart unwraps the hole's own value, so
1126
1777
  // an array child arrives unwrapped at the top and its ITEMS do not.
1127
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
+ }
1128
1791
  if (isTemplate(v)) {
1129
1792
  const { inst, frag } = buildDetached(/** @type any */ (v), reconcileFormActionsCb);
1130
1793
  return { item: { type: 'tpl', inst }, frag };
1131
1794
  }
1132
- if (v != null && v !== false && v !== true) {
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 !== '') {
1133
1797
  const node = document.createTextNode(String(v));
1134
1798
  return { item: { type: 'text', node }, frag: node };
1135
1799
  }
@@ -1148,13 +1812,27 @@ function applyArrayFresh(marker, state, value, reconcileFormActionsCb) {
1148
1812
  const bulk = document.createDocumentFragment();
1149
1813
  /** @type {ArrayItem[]} */
1150
1814
  const items = [];
1815
+ /** @type {Array<() => void>} */
1816
+ const fills = [];
1151
1817
  for (const v of value) {
1152
- const { item, frag } = buildArrayItem(v, reconcileFormActionsCb);
1818
+ const { item, frag, fill } = buildArrayItem(v, reconcileFormActionsCb);
1153
1819
  if (frag) bulk.appendChild(frag);
1820
+ if (fill) fills.push(fill);
1154
1821
  items.push(item);
1155
1822
  }
1156
1823
  parent.insertBefore(bulk, marker);
1157
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);
1158
1836
  }
1159
1837
 
1160
1838
  /**
@@ -1188,7 +1866,14 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
1188
1866
  const raw = value[i];
1189
1867
  const v = isLive(raw) ? /** @type any */ (raw).value : raw;
1190
1868
  const o = old[i];
1191
- 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)) {
1192
1877
  const tr = /** @type any */ (v);
1193
1878
  if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
1194
1879
  updateInstance(o.inst, tr.values, reconcileFormActionsCb);
@@ -1196,7 +1881,13 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
1196
1881
  consumed = i + 1;
1197
1882
  continue;
1198
1883
  }
1199
- } else if (v != null && v !== false && v !== true) {
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 !== '') {
1200
1891
  if (o && o.type === 'text') {
1201
1892
  const str = String(v);
1202
1893
  if (o.node.data !== str) o.node.data = str;
@@ -1225,9 +1916,10 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
1225
1916
  // The push sits BEFORE the removal, which is a pure reordering on the
1226
1917
  // success path and means a slot that has already been built and
1227
1918
  // inserted is never untracked at any throw point.
1228
- const { item, frag } = buildArrayItem(v, reconcileFormActionsCb);
1919
+ const { item, frag, fill } = buildArrayItem(v, reconcileFormActionsCb);
1229
1920
  if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
1230
1921
  next.push(item);
1922
+ if (fill) fill();
1231
1923
  if (o) removeArrayItem(o);
1232
1924
  consumed = i + 1;
1233
1925
  }
@@ -1402,7 +2094,7 @@ export function clearStaleDirectiveState(part, value) {
1402
2094
  if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
1403
2095
  partAny.__keyedKey = undefined;
1404
2096
  }
1405
- if (partAny.__watchSub && !isWatch(value)) {
2097
+ if (partAny.__watchSub && !isWatch(value) && !isSignal(value)) {
1406
2098
  teardownWatch(partAny);
1407
2099
  }
1408
2100
  }
@@ -1751,6 +2443,62 @@ function applyWatch(part, sig, reconcileFormActionsCb) {
1751
2443
  applyChildInner(part, initial, reconcileFormActionsCb);
1752
2444
  }
1753
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
+
1754
2502
  /**
1755
2503
  * Dispose a `watch` directive's per-part watcher. Called from
1756
2504
  * `teardownChild` and from `clearStaleDirectiveState` when the value
@@ -1991,11 +2739,11 @@ function renderToNodes(value, reconcileFormActionsCb) {
1991
2739
  // #1443: the streamed / detached counterpart of the per-item unwrap above.
1992
2740
  // Recursion covers a nested array, since each level re-enters here.
1993
2741
  if (isLive(value)) value = /** @type any */ (value).value;
1994
- if (value == null || value === false || value === true) return [];
2742
+ if (value == null || value === nothing || value === noChange) return [];
1995
2743
  if (isTemplate(value)) {
1996
2744
  const tr = /** @type any */ (value);
1997
2745
  const { templateEl, parts, formActions } = compile(tr);
1998
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
2746
+ const frag = /** @type DocumentFragment */ (document.importNode(templateEl.content, true));
1999
2747
  const bound = parts.map((p) => bindPart(p, frag));
2000
2748
  for (let i = 0; i < tr.values.length; i++) {
2001
2749
  applyPart(bound[i], tr.values[i], undefined, tr.values, reconcileFormActionsCb);