kerfjs 1.0.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { captureRowBindings, isSafeHtml, listSafeHtml, newBindingContext, _setBindingContext, flattenWithoutListItems, wireBindings, collectLists, disposeRowBindings, flatten, wireRowBindings, granularListSafeHtml } from './chunk-QNYOMGI4.js';
2
- export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-QNYOMGI4.js';
3
- export { defineStore, resetAllStores } from './chunk-7SKIIA5P.js';
4
- import { warnIfInsideEffect, effect } from './chunk-4E26PO2C.js';
5
- export { batch, computed, effect, signal } from './chunk-4E26PO2C.js';
1
+ import { captureRowBindings, isSafeHtml, listSafeHtml, boundTextNodeOf, syncFormProp, newBindingContext, _setBindingContext, flattenWithoutListItems, wireBindings, collectLists, disposeRowBindings, flatten, LIST_MARKER_PREFIX, wireRowBindings, granularListSafeHtml, carryOrRewireRowBindings } from './chunk-GYRZQCSY.js';
2
+ export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-GYRZQCSY.js';
3
+ export { defineStore, resetAllStores } from './chunk-KFUDM3VP.js';
4
+ import { warnIfInsideEffect, effect, isDevMode } from './chunk-NU7YHYEV.js';
5
+ export { batch, computed, effect, signal } from './chunk-NU7YHYEV.js';
6
6
 
7
7
  // src/attrSelector.ts
8
8
  function cssEscapeIdent(value) {
@@ -94,33 +94,30 @@ function assertValidSelector(selector, fn) {
94
94
  );
95
95
  }
96
96
  }
97
- function delegate(rootEl, type, selector, handler) {
98
- assertValidSelector(selector, "delegate");
99
- warnIfInsideEffect("delegate");
100
- const listener = (event) => {
97
+ function makeListener(rootEl, selector, handler, match) {
98
+ return (event) => {
101
99
  const target = event.target;
102
100
  if (!(target instanceof Element)) return;
103
- const matched = target.closest(selector);
101
+ const matched = match === "direct" ? target.matches(selector) ? target : null : target.closest(selector);
104
102
  if (matched !== null && rootEl.contains(matched)) {
105
103
  handler(event, matched);
106
104
  }
107
105
  };
106
+ }
107
+ function delegate(rootEl, type, selector, handler, options) {
108
+ assertValidSelector(selector, "delegate");
109
+ warnIfInsideEffect("delegate");
110
+ const listener = makeListener(rootEl, selector, handler, options?.match ?? "closest");
108
111
  const capture = NON_BUBBLING.has(type);
109
112
  rootEl.addEventListener(type, listener, capture);
110
113
  return () => {
111
114
  rootEl.removeEventListener(type, listener, capture);
112
115
  };
113
116
  }
114
- function delegateCapture(rootEl, type, selector, handler) {
117
+ function delegateCapture(rootEl, type, selector, handler, options) {
115
118
  assertValidSelector(selector, "delegateCapture");
116
119
  warnIfInsideEffect("delegateCapture");
117
- const listener = (event) => {
118
- const target = event.target;
119
- if (!(target instanceof Element)) return;
120
- if (target.matches(selector) && rootEl.contains(target)) {
121
- handler(event, target);
122
- }
123
- };
120
+ const listener = makeListener(rootEl, selector, handler, options?.match ?? "closest");
124
121
  rootEl.addEventListener(type, listener, true);
125
122
  return () => {
126
123
  rootEl.removeEventListener(type, listener, true);
@@ -130,8 +127,8 @@ function delegateCapture(rootEl, type, selector, handler) {
130
127
  // src/dev-each-warn.ts
131
128
  var warnedIds = /* @__PURE__ */ new Set();
132
129
  function isOptedIn() {
130
+ if (!isDevMode()) return false;
133
131
  const proc = globalThis.process;
134
- if (proc?.env?.NODE_ENV === "production") return false;
135
132
  return proc?.env?.KERF_DEV_WARN_EACH_IN_MORPH_SKIP === "1";
136
133
  }
137
134
  function hasMorphSkipAncestor(el, root) {
@@ -153,8 +150,8 @@ function maybeWarnEachInMorphSkip(id, liveParent, rootEl) {
153
150
  }
154
151
  var warnedDupIds = /* @__PURE__ */ new Set();
155
152
  function isOptedInDupKeys() {
153
+ if (!isDevMode()) return false;
156
154
  const proc = globalThis.process;
157
- if (proc?.env?.NODE_ENV === "production") return false;
158
155
  return proc?.env?.KERF_DEV_WARN_DUPLICATE_EACH_KEYS === "1";
159
156
  }
160
157
  function maybeWarnDuplicateCacheKeys(id, segItems) {
@@ -173,6 +170,28 @@ function maybeWarnDuplicateCacheKeys(id, segItems) {
173
170
  }
174
171
  }
175
172
 
173
+ // src/list-render-state.ts
174
+ function deriveListRenderState(bindingCount) {
175
+ if (bindingCount === void 0) return "unbound";
176
+ return bindingCount === 0 ? "empty" : "bound";
177
+ }
178
+ function decideListPath(state, patches, snapshotLength, previousBindingCount) {
179
+ if (state === "unbound") return { path: "snapshot", reason: "first-render" };
180
+ if (state === "empty") return { path: "snapshot", reason: "empty-binding" };
181
+ if (patches.length === 0) return { path: "snapshot", reason: "no-patches" };
182
+ let netDelta = 0;
183
+ for (const p of patches) {
184
+ if (p.type === "insert") netDelta += 1;
185
+ else if (p.type === "remove") netDelta -= 1;
186
+ else if (p.type === "replace") return { path: "snapshot", reason: "replace" };
187
+ }
188
+ const count = previousBindingCount ?? 0;
189
+ if (count + netDelta !== snapshotLength) {
190
+ return { path: "snapshot", reason: "count-drift" };
191
+ }
192
+ return { path: "granular" };
193
+ }
194
+
176
195
  // src/each.ts
177
196
  var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
178
197
  function isArraySignal(value) {
@@ -204,18 +223,13 @@ function eachGranular(sig, render, cacheKey) {
204
223
  const previousBindingCount = ctx.bindingCounts.get(id);
205
224
  const patches = sig._consumePatches();
206
225
  const snapshot = sig.value;
207
- if (previousBindingCount === void 0 || previousBindingCount === 0 || patches.length === 0) {
208
- return eachSnapshotById(snapshot, render, cacheKey, id);
209
- }
210
- let netDelta = 0;
211
- for (const p of patches) {
212
- if (p.type === "insert") netDelta += 1;
213
- else if (p.type === "remove") netDelta -= 1;
214
- else if (p.type === "replace") {
215
- return eachSnapshotById(snapshot, render, cacheKey, id);
216
- }
217
- }
218
- if (previousBindingCount + netDelta !== snapshot.length) {
226
+ const decision = decideListPath(
227
+ deriveListRenderState(previousBindingCount),
228
+ patches,
229
+ snapshot.length,
230
+ previousBindingCount
231
+ );
232
+ if (decision.path === "snapshot") {
219
233
  return eachSnapshotById(snapshot, render, cacheKey, id);
220
234
  }
221
235
  if (cacheKey !== void 0) {
@@ -322,6 +336,11 @@ function getNodeKey(node) {
322
336
  }
323
337
  var EMPTY_OWNED = /* @__PURE__ */ new Set();
324
338
  function morph(liveRoot, template, ownedItems = EMPTY_OWNED) {
339
+ if (liveRoot == null) {
340
+ throw new Error(
341
+ 'morph: liveRoot is null/undefined \u2014 pass the live element, e.g. morph(document.getElementById("app")!, template). A common cause is a typo in the id or selector that returns null at runtime even though the TypeScript types say Element.'
342
+ );
343
+ }
325
344
  const templateEl = isElementNode(template) ? template : parseTemplate(liveRoot, template);
326
345
  morphChildren(liveRoot, templateEl, ownedItems);
327
346
  }
@@ -367,6 +386,12 @@ function morphChildren(fromParent, toParent, ownedItems) {
367
386
  if (matched === null && fromChild !== null && fromChild.nodeType === toChild.nodeType && (toChild.nodeType !== ELEMENT_NODE || fromChild.tagName === toChild.tagName && getNodeKey(fromChild) === void 0)) {
368
387
  matched = fromChild;
369
388
  fromChild = skipOwned(fromChild.nextSibling, ownedItems);
389
+ if (matched.nodeType === COMMENT_NODE && fromChild !== null) {
390
+ const owned = boundTextNodeOf(matched);
391
+ if (owned !== null && fromChild === owned) {
392
+ fromChild = skipOwned(owned.nextSibling, ownedItems);
393
+ }
394
+ }
370
395
  }
371
396
  if (matched !== null) {
372
397
  morphNode(matched, toChild, ownedItems);
@@ -415,7 +440,11 @@ function morphElement(fromEl, toEl, ownedItems) {
415
440
  }
416
441
  morphAttributes(fromEl, toEl);
417
442
  if (fromEl.dataset.morphSkipChildren !== void 0) return;
443
+ const syncTextareaValue = fromEl.tagName === "TEXTAREA" && fromEl !== document.activeElement && fromEl.textContent !== toEl.textContent;
418
444
  morphChildren(fromEl, toEl, ownedItems);
445
+ if (syncTextareaValue) {
446
+ fromEl.value = toEl.textContent;
447
+ }
419
448
  }
420
449
  function isUserAgentOwnedAttr(tagName, name) {
421
450
  return name === "open" && (tagName === "DETAILS" || tagName === "DIALOG");
@@ -433,6 +462,7 @@ function morphAttributes(fromEl, toEl) {
433
462
  }
434
463
  } else if (fromEl.getAttribute(name) !== value) {
435
464
  fromEl.setAttribute(name, value);
465
+ syncFormProp(fromEl, name, value, true);
436
466
  }
437
467
  }
438
468
  const fromAttrs = fromEl.attributes;
@@ -445,6 +475,7 @@ function morphAttributes(fromEl, toEl) {
445
475
  if (!toEl.hasAttributeNS(ns, name)) fromEl.removeAttributeNS(ns, name);
446
476
  } else if (!toEl.hasAttribute(name) && !isUserAgentOwnedAttr(fromTag, name)) {
447
477
  fromEl.removeAttribute(name);
478
+ syncFormProp(fromEl, name, "", false);
448
479
  }
449
480
  }
450
481
  }
@@ -468,13 +499,37 @@ function preserveTextEntryState(fromEl, toEl) {
468
499
  }
469
500
  }
470
501
 
502
+ // src/dev-binding-warn.ts
503
+ var warnedHoles = /* @__PURE__ */ new Set();
504
+ function isOptedIn2() {
505
+ if (!isDevMode()) return false;
506
+ const proc = globalThis.process;
507
+ return proc?.env?.KERF_DEV_WARN_STALE_BINDING === "1";
508
+ }
509
+ function describeHole(b) {
510
+ return b.kind === "attr" ? `attr '${b.attr}' (id '${b.id}')` : `text (id '${b.id}')`;
511
+ }
512
+ function maybeWarnStaleBinding(prevWired, current) {
513
+ if (!isOptedIn2()) return;
514
+ const n = Math.min(prevWired.length, current.length);
515
+ for (let i = 0; i < n; i++) {
516
+ const cur = current[i];
517
+ if (prevWired[i].signal === cur.signal) continue;
518
+ if (warnedHoles.has(cur.id)) continue;
519
+ warnedHoles.add(cur.id);
520
+ console.warn(
521
+ `kerf: fine-grained binding ${describeHole(cur)} switched to a different signal instance on a render whose static-surrounds HTML was byte-for-byte unchanged. On that fast path kerf keeps the original binding effect and does NOT re-bind, so this hole is now stale \u2014 it still tracks the FIRST signal instance and will not reflect the new one. Bind one computed that switches internally (e.g. class={computed(() => cond.value ? sigA.value : sigB.value)}) instead of switching which signal instance you bind (see docs/2-reactivity \xA72.9). Set KERF_DEV_WARN_STALE_BINDING=0 (or unset it) to silence this warning.`
522
+ );
523
+ }
524
+ }
525
+
471
526
  // src/dev-listener-warn.ts
472
527
  var LISTENER_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.devListener");
473
528
  var patched = false;
474
529
  var warned = false;
475
- function isOptedIn2() {
530
+ function isOptedIn3() {
531
+ if (!isDevMode()) return false;
476
532
  const proc = globalThis.process;
477
- if (proc?.env?.NODE_ENV === "production") return false;
478
533
  return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === "1";
479
534
  }
480
535
  function findAddEventListenerProto() {
@@ -516,7 +571,7 @@ function emitWarning() {
516
571
  );
517
572
  }
518
573
  function installListenerRebuildWarn(rootEl) {
519
- if (!isOptedIn2()) return null;
574
+ if (!isOptedIn3()) return null;
520
575
  patchAddEventListenerOnce();
521
576
  const observer = new MutationObserver((mutations) => {
522
577
  if (warned) return;
@@ -535,6 +590,47 @@ function installListenerRebuildWarn(rootEl) {
535
590
  return observer;
536
591
  }
537
592
 
593
+ // src/dev-rerender-warn.ts
594
+ function isOptedIn4() {
595
+ const proc = globalThis.process;
596
+ if (proc?.env?.KERF_DEV_WARN_VALUE_ONLY_RERENDER !== "1") return false;
597
+ return isDevMode();
598
+ }
599
+ var ELEMENT_NODE2 = 1;
600
+ var TEXT_NODE2 = 3;
601
+ var COMMENT_NODE2 = 8;
602
+ function _isValueOnlyDiff(a, b) {
603
+ const an = a.childNodes;
604
+ const bn = b.childNodes;
605
+ if (an.length !== bn.length) return false;
606
+ for (let i = 0; i < an.length; i++) {
607
+ const x = an[i];
608
+ const y = bn[i];
609
+ if (x.nodeType !== y.nodeType) return false;
610
+ if (x.nodeType === ELEMENT_NODE2) {
611
+ if (x.tagName !== y.tagName) return false;
612
+ if (!_isValueOnlyDiff(x, y)) return false;
613
+ } else if (x.nodeType === COMMENT_NODE2) {
614
+ if (x.data !== y.data) return false;
615
+ } else if (x.nodeType !== TEXT_NODE2) {
616
+ return false;
617
+ }
618
+ }
619
+ return true;
620
+ }
621
+ function maybeWarnValueOnlyRerender(prevHtml, nextHtml, ctx) {
622
+ if (ctx.warned || !isOptedIn4()) return;
623
+ const a = document.createElement("template");
624
+ const b = document.createElement("template");
625
+ a.innerHTML = prevHtml;
626
+ b.innerHTML = nextHtml;
627
+ if (!_isValueOnlyDiff(a.content, b.content)) return;
628
+ ctx.warned = true;
629
+ console.warn(
630
+ "kerf: this re-render changed only text content and attribute values \u2014 no structural change \u2014 so every changed hole could be a fine-grained binding instead. Values bind, structure re-renders: pass the signal/computed itself ({count}, class={sig}) rather than reading .value in the hole, and each change updates just that node with no render re-run (a mount whose render reads no .value never re-renders at all). See docs/2-reactivity \xA72.9. Set KERF_DEV_WARN_VALUE_ONLY_RERENDER=0 (or unset it) to silence this warning."
631
+ );
632
+ }
633
+
538
634
  // src/list-binding.ts
539
635
  function endAnchor(binding) {
540
636
  if (binding.items.length > 0) {
@@ -551,8 +647,8 @@ var SQUOTE = 39;
551
647
  var AMP = 38;
552
648
  var EQ = 61;
553
649
  var SLASH = 47;
554
- var TEXT_NODE2 = 3;
555
- var ELEMENT_NODE2 = 1;
650
+ var TEXT_NODE3 = 3;
651
+ var ELEMENT_NODE3 = 1;
556
652
  function isWhitespace(cc) {
557
653
  return cc === 32 || cc === 9 || cc === 10 || cc === 13;
558
654
  }
@@ -611,6 +707,7 @@ function tryTextContentFastPath(liveNode, oldHtml, newHtml) {
611
707
  const newTextEnd = textEnd + (newHtml.length - oldHtml.length);
612
708
  const oldText = oldHtml.slice(textStart + 1, textEnd);
613
709
  const newText = newHtml.slice(textStart + 1, newTextEnd);
710
+ if (oldHtml.lastIndexOf("<!--kfb", textStart) !== -1) return false;
614
711
  const textIdx = countTextNodesBefore(oldHtml, textStart + 1);
615
712
  const targetNode = nthTextNodeDescendant(liveNode, textIdx);
616
713
  if (targetNode === null) return false;
@@ -655,13 +752,13 @@ function nthTextNodeDescendant(root, n) {
655
752
  function walk(node) {
656
753
  for (let c = node.firstChild; c !== null; c = c.nextSibling) {
657
754
  if (result !== null) return;
658
- if (c.nodeType === TEXT_NODE2) {
755
+ if (c.nodeType === TEXT_NODE3) {
659
756
  if (count === n) {
660
757
  result = c;
661
758
  return;
662
759
  }
663
760
  count++;
664
- } else if (c.nodeType === ELEMENT_NODE2) {
761
+ } else if (c.nodeType === ELEMENT_NODE3) {
665
762
  walk(c);
666
763
  }
667
764
  }
@@ -760,6 +857,20 @@ function parseRowTemplate(html) {
760
857
  tpl.innerHTML = html;
761
858
  return { tpl, count: tpl.content.children.length };
762
859
  }
860
+ function parseSingleRow(html, index) {
861
+ const { tpl, count } = parseRowTemplate(html);
862
+ if (count !== 1) throw rowContractError(index, html);
863
+ return tpl.content.firstElementChild;
864
+ }
865
+ function collectTemplateChildren(tpl, n) {
866
+ const nodes = new Array(n);
867
+ let child = tpl.content.firstElementChild;
868
+ for (let k = 0; k < n; k++) {
869
+ nodes[k] = child;
870
+ child = child.nextElementSibling;
871
+ }
872
+ return nodes;
873
+ }
763
874
  function rowContractError(index, html) {
764
875
  const { count } = parseRowTemplate(html);
765
876
  const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
@@ -767,17 +878,13 @@ function rowContractError(index, html) {
767
878
  `each(): row render at index ${index} ${reason}. Each item's render must return exactly one element \u2014 wrap multiple roots in a single parent (e.g. <li>...</li>). Got HTML: ${JSON.stringify(truncateRowHtml(html))}`
768
879
  );
769
880
  }
770
- function isDevMode() {
771
- const proc = globalThis.process;
772
- return proc?.env?.NODE_ENV !== "production";
773
- }
774
- function maybeWarnMissingRowKey(rowEl, rowIndex, rowHtml, binding) {
881
+ function maybeWarnMissingRowKey(rowEl, rowHtml, binding) {
775
882
  if (!isDevMode()) return;
776
883
  if (binding.warnedMissingKey === true) return;
777
884
  binding.warnedMissingKey = true;
778
885
  if (rowEl.id !== "" || rowEl.hasAttribute("data-key")) return;
779
886
  console.warn(
780
- `kerf each(): row at index ${rowIndex} has no \`id\` or \`data-key\` attribute. Without one, rows match positionally \u2014 an insert/remove at the head shifts every row's identity, so focused inputs jump to the wrong row, mid-edit textareas swap content with their neighbor, and any per-row state silently follows the wrong item. Add \`data-key={item.id}\` (or set \`id\`) to the top-level element returned by the row render. Row HTML: ${JSON.stringify(truncateRowHtml(rowHtml))}`
887
+ `kerf each(): the first row has no \`id\` or \`data-key\` attribute. Without one, rows match positionally \u2014 an insert/remove at the head shifts every row's identity, so focused inputs jump to the wrong row, mid-edit textareas swap content with their neighbor, and any per-row state silently follows the wrong item. Add \`data-key={item.id}\` (or set \`id\`) to the top-level element returned by the row render. Row HTML: ${JSON.stringify(truncateRowHtml(rowHtml))}`
781
888
  );
782
889
  }
783
890
 
@@ -843,12 +950,12 @@ function reconcileGranular(binding, patches) {
843
950
  }
844
951
  if (focusSnap !== null) restoreFocus(focusSnap);
845
952
  if (items.length > 0) {
846
- maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
953
+ maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
847
954
  }
848
955
  }
849
956
  function applySingleInsert(liveParent, items, patch, tailAnchor) {
850
957
  const { html } = patch;
851
- const newNode = parseSingleRow(html);
958
+ const newNode = parseSingleRow(html, patch.index);
852
959
  const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;
853
960
  liveParent.insertBefore(newNode, anchor);
854
961
  items.splice(patch.index, 0, {
@@ -867,12 +974,19 @@ function wireRowIfBound(node, bindings) {
867
974
  function applySingleUpdate(liveParent, items, patch) {
868
975
  const { html } = patch;
869
976
  const oldEntry = items[patch.index];
870
- if (html === oldEntry.html) return;
977
+ if (html === oldEntry.html) {
978
+ items[patch.index] = reuseBound(patch, html, oldEntry);
979
+ return;
980
+ }
871
981
  if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, html)) {
872
982
  items[patch.index] = reuseBound(patch, html, oldEntry);
873
983
  return;
874
984
  }
875
- const newNode = parseSingleRow(html);
985
+ const newNode = parseSingleRow(html, patch.index);
986
+ applyParsedRowUpdate(liveParent, items, patch, html, newNode);
987
+ }
988
+ function applyParsedRowUpdate(liveParent, items, patch, html, newNode) {
989
+ const oldEntry = items[patch.index];
876
990
  if (oldEntry.node.tagName === newNode.tagName) {
877
991
  _morphElement(oldEntry.node, newNode);
878
992
  items[patch.index] = reuseBound(patch, html, oldEntry);
@@ -890,13 +1004,19 @@ function applySingleUpdate(liveParent, items, patch) {
890
1004
  }
891
1005
  }
892
1006
  function reuseBound(patch, html, oldEntry) {
1007
+ const kept = carryOrRewireRowBindings(
1008
+ oldEntry.node,
1009
+ oldEntry.bindings,
1010
+ oldEntry.bindingDisposers,
1011
+ patch.bindings
1012
+ );
893
1013
  return {
894
1014
  ref: patch.item,
895
1015
  cacheKey: void 0,
896
1016
  html,
897
1017
  node: oldEntry.node,
898
- bindings: oldEntry.bindings,
899
- bindingDisposers: oldEntry.bindingDisposers
1018
+ bindings: kept.bindings,
1019
+ bindingDisposers: kept.bindingDisposers
900
1020
  };
901
1021
  }
902
1022
  function applyBulkUpdate(liveParent, items, patches, start, end) {
@@ -904,7 +1024,10 @@ function applyBulkUpdate(liveParent, items, patches, start, end) {
904
1024
  for (let k = start; k < end; k++) {
905
1025
  const p = patches[k];
906
1026
  const oldEntry = items[p.index];
907
- if (p.html === oldEntry.html) continue;
1027
+ if (p.html === oldEntry.html) {
1028
+ items[p.index] = reuseBound(p, p.html, oldEntry);
1029
+ continue;
1030
+ }
908
1031
  if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, p.html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, p.html)) {
909
1032
  items[p.index] = reuseBound(p, p.html, oldEntry);
910
1033
  continue;
@@ -916,50 +1039,24 @@ function applyBulkUpdate(liveParent, items, patches, start, end) {
916
1039
  if (count !== morphChanges.length) {
917
1040
  throw findOffendingChange(patches, morphChanges);
918
1041
  }
919
- const newNodes = new Array(morphChanges.length);
920
- let child = tpl.content.firstElementChild;
921
- for (let k = 0; k < newNodes.length; k++) {
922
- newNodes[k] = child;
923
- child = child.nextElementSibling;
924
- }
1042
+ const newNodes = collectTemplateChildren(tpl, morphChanges.length);
925
1043
  for (let k = 0; k < morphChanges.length; k++) {
926
1044
  const c = morphChanges[k];
927
1045
  const p = patches[c.patchIdx];
928
- const oldEntry = items[p.index];
929
- if (oldEntry.node.tagName === newNodes[k].tagName) {
930
- _morphElement(oldEntry.node, newNodes[k]);
931
- items[p.index] = reuseBound(p, c.html, oldEntry);
932
- } else {
933
- disposeRowBindings(oldEntry.bindingDisposers);
934
- liveParent.replaceChild(newNodes[k], oldEntry.node);
935
- items[p.index] = {
936
- ref: p.item,
937
- cacheKey: void 0,
938
- html: c.html,
939
- node: newNodes[k],
940
- bindings: p.bindings,
941
- bindingDisposers: wireRowIfBound(newNodes[k], p.bindings)
942
- };
943
- }
1046
+ applyParsedRowUpdate(liveParent, items, p, c.html, newNodes[k]);
944
1047
  }
945
1048
  }
946
1049
  function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
947
1050
  const startIdx = patches[start].index;
948
1051
  const htmls = new Array(end - start);
949
1052
  for (let k = start; k < end; k++) {
950
- const p = patches[k];
951
- htmls[k - start] = p.html;
1053
+ htmls[k - start] = patches[k].html;
952
1054
  }
953
1055
  const { tpl, count } = parseRowTemplate(htmls.join(""));
954
1056
  if (count !== htmls.length) {
955
1057
  throw findOffendingInsert(patches, start, htmls);
956
1058
  }
957
- const newNodes = new Array(end - start);
958
- let child = tpl.content.firstElementChild;
959
- for (let k = 0; k < newNodes.length; k++) {
960
- newNodes[k] = child;
961
- child = child.nextElementSibling;
962
- }
1059
+ const newNodes = collectTemplateChildren(tpl, end - start);
963
1060
  const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;
964
1061
  liveParent.insertBefore(tpl.content, anchor);
965
1062
  const newEntries = new Array(end - start);
@@ -977,21 +1074,10 @@ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
977
1074
  }
978
1075
  items.splice(startIdx, 0, ...newEntries);
979
1076
  }
980
- function parseSingleRow(html) {
981
- const { tpl, count } = parseRowTemplate(html);
982
- if (count !== 1) {
983
- const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
984
- throw new Error(
985
- `each() granular reconcile: row render ${reason}. Each item's render must return exactly one element. Got HTML: ${JSON.stringify(truncateRowHtml(html))}`
986
- );
987
- }
988
- return tpl.content.firstElementChild;
989
- }
990
1077
  function findOffendingInsert(patches, start, htmls) {
991
1078
  for (let i = 0; i < htmls.length; i++) {
992
1079
  if (parseRowTemplate(htmls[i]).count !== 1) {
993
- const p = patches[start + i];
994
- return rowContractError(p.index, htmls[i]);
1080
+ return rowContractError(patches[start + i].index, htmls[i]);
995
1081
  }
996
1082
  }
997
1083
  return new Error("each(): bulk-insert mismatch with no per-row offender (kerf bug).");
@@ -999,8 +1085,7 @@ function findOffendingInsert(patches, start, htmls) {
999
1085
  function findOffendingChange(patches, changes) {
1000
1086
  for (const c of changes) {
1001
1087
  if (parseRowTemplate(c.html).count !== 1) {
1002
- const p = patches[c.patchIdx];
1003
- return rowContractError(p.index, c.html);
1088
+ return rowContractError(patches[c.patchIdx].index, c.html);
1004
1089
  }
1005
1090
  }
1006
1091
  return new Error("each(): bulk-update mismatch with no per-row offender (kerf bug).");
@@ -1023,40 +1108,45 @@ function tryInPlaceContentUpdate(binding, listSeg) {
1023
1108
  }
1024
1109
  if (focusSnap !== null) restoreFocus(focusSnap);
1025
1110
  binding.items = newRecord;
1026
- maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
1111
+ maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1027
1112
  return true;
1028
1113
  }
1029
1114
  function updateRowInPlace(liveParent, old, ni, index) {
1030
1115
  if (old.html === ni.html || tryAttributeOnlyFastPath(old.node, old.html, ni.html) || tryTextContentFastPath(old.node, old.html, ni.html)) {
1116
+ const kept = carryOrRewireRowBindings(old.node, old.bindings, old.bindingDisposers, ni.bindings);
1031
1117
  return {
1032
1118
  ref: ni.ref,
1033
1119
  cacheKey: ni.cacheKey,
1034
1120
  html: ni.html,
1035
1121
  node: old.node,
1036
- bindings: old.bindings,
1037
- bindingDisposers: old.bindingDisposers
1122
+ bindings: kept.bindings,
1123
+ bindingDisposers: kept.bindingDisposers
1038
1124
  };
1039
1125
  }
1040
- const newNode = parseSingleRow2(ni.html, index);
1126
+ const newNode = parseSingleRow(ni.html, index);
1041
1127
  if (old.node.tagName === newNode.tagName) {
1042
1128
  _morphElement(old.node, newNode);
1129
+ const kept = carryOrRewireRowBindings(old.node, old.bindings, old.bindingDisposers, ni.bindings);
1043
1130
  return {
1044
1131
  ref: ni.ref,
1045
1132
  cacheKey: ni.cacheKey,
1046
1133
  html: ni.html,
1047
1134
  node: old.node,
1048
- bindings: old.bindings,
1049
- bindingDisposers: old.bindingDisposers
1135
+ bindings: kept.bindings,
1136
+ bindingDisposers: kept.bindingDisposers
1050
1137
  };
1051
1138
  }
1052
1139
  disposeRowBindings(old.bindingDisposers);
1053
1140
  liveParent.replaceChild(newNode, old.node);
1054
- return { ref: ni.ref, cacheKey: ni.cacheKey, html: ni.html, node: newNode };
1055
- }
1056
- function parseSingleRow2(html, index) {
1057
- const { tpl, count } = parseRowTemplate(html);
1058
- if (count !== 1) throw rowContractError(index, html);
1059
- return tpl.content.firstElementChild;
1141
+ const fresh = carryOrRewireRowBindings(newNode, void 0, void 0, ni.bindings);
1142
+ return {
1143
+ ref: ni.ref,
1144
+ cacheKey: ni.cacheKey,
1145
+ html: ni.html,
1146
+ node: newNode,
1147
+ bindings: fresh.bindings,
1148
+ bindingDisposers: fresh.bindingDisposers
1149
+ };
1060
1150
  }
1061
1151
 
1062
1152
  // src/list-reconcile-snapshot.ts
@@ -1072,7 +1162,7 @@ function reconcileSnapshot(binding, listSeg) {
1072
1162
  if (focusSnap !== null) restoreFocus(focusSnap);
1073
1163
  binding.items = newRecord;
1074
1164
  if (newRecord.length > 0) {
1075
- maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
1165
+ maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1076
1166
  }
1077
1167
  }
1078
1168
  function classifyItems(oldItems, listSeg) {
@@ -1098,6 +1188,8 @@ function classifyItems(oldItems, listSeg) {
1098
1188
  removedItems.push(oi[0]);
1099
1189
  }
1100
1190
  newRecord[i] = {
1191
+ // `node` placeholder is filled by `buildFreshNodes`; its parse-count
1192
+ // check guarantees every fresh index gets a real element before use.
1101
1193
  ref: ni.ref,
1102
1194
  cacheKey: ni.cacheKey,
1103
1195
  html: ni.html,
@@ -1192,37 +1284,39 @@ function reconcileList(binding, listSeg) {
1192
1284
  }
1193
1285
 
1194
1286
  // src/mount.ts
1195
- var LIST_MARKER_PREFIX = "kf-list:";
1196
1287
  var MOUNTED_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.mounted");
1288
+ var NESTED_MOUNT_MSG = "mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \u2014 compose with plain functions that return JSX instead of nesting mounts.";
1289
+ function isMounted(el) {
1290
+ return el[MOUNTED_MARKER] === true;
1291
+ }
1292
+ function setMounted(el, on) {
1293
+ if (on) {
1294
+ el[MOUNTED_MARKER] = true;
1295
+ } else {
1296
+ delete el[MOUNTED_MARKER];
1297
+ }
1298
+ }
1197
1299
  function describeEl(el) {
1198
1300
  const tag = el.tagName.toLowerCase();
1199
1301
  const id = el.id ? `#${el.id}` : "";
1200
1302
  return `<${tag}${id}>`;
1201
1303
  }
1202
1304
  function assertNotInsideMountedTree(rootEl) {
1203
- if (rootEl[MOUNTED_MARKER] === true) {
1305
+ if (isMounted(rootEl)) {
1204
1306
  throw new Error(
1205
1307
  `mount: ${describeEl(rootEl)} is already mounted. Call the disposer returned by the first mount() before mounting again. kerf supports one mount per element \u2014 compose with plain functions that return JSX instead of nesting mounts.`
1206
1308
  );
1207
1309
  }
1208
1310
  let ancestor = rootEl.parentElement;
1209
1311
  while (ancestor !== null) {
1210
- if (ancestor[MOUNTED_MARKER] === true) {
1211
- throw new Error(
1212
- "mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \u2014 compose with plain functions that return JSX instead of nesting mounts."
1213
- );
1214
- }
1312
+ if (isMounted(ancestor)) throw new Error(NESTED_MOUNT_MSG);
1215
1313
  ancestor = ancestor.parentElement;
1216
1314
  }
1217
1315
  const stack = [];
1218
1316
  for (let i = 0; i < rootEl.children.length; i++) stack.push(rootEl.children[i]);
1219
1317
  while (stack.length > 0) {
1220
1318
  const cur = stack.pop();
1221
- if (cur[MOUNTED_MARKER] === true) {
1222
- throw new Error(
1223
- "mount: rootEl is already inside (or contains) a mounted tree. kerf supports one mount per tree \u2014 compose with plain functions that return JSX instead of nesting mounts."
1224
- );
1225
- }
1319
+ if (isMounted(cur)) throw new Error(NESTED_MOUNT_MSG);
1226
1320
  for (let i = 0; i < cur.children.length; i++) stack.push(cur.children[i]);
1227
1321
  }
1228
1322
  }
@@ -1237,7 +1331,7 @@ function mount(rootEl, render) {
1237
1331
  if (owner.defaultView === null) document.adoptNode(rootEl);
1238
1332
  }
1239
1333
  assertNotInsideMountedTree(rootEl);
1240
- rootEl[MOUNTED_MARKER] = true;
1334
+ setMounted(rootEl, true);
1241
1335
  const listenerWarnObserver = installListenerRebuildWarn(rootEl);
1242
1336
  const bindings = /* @__PURE__ */ new Map();
1243
1337
  const renderCtx = {
@@ -1247,8 +1341,10 @@ function mount(rootEl, render) {
1247
1341
  };
1248
1342
  const bindingCtx = newBindingContext();
1249
1343
  let bindingDisposers = [];
1344
+ let prevWiredBindings = [];
1250
1345
  let isFirst = true;
1251
1346
  let prevStaticHtml = "";
1347
+ const valueOnlyWarnCtx = { warned: false };
1252
1348
  const disposeEffect = effect(() => {
1253
1349
  renderCtx.counter = 0;
1254
1350
  bindingCtx.counter = 0;
@@ -1267,16 +1363,32 @@ function mount(rootEl, render) {
1267
1363
  runFirstRender(rootEl, segment, bindings);
1268
1364
  prevStaticHtml = flattenWithoutListItems(segment);
1269
1365
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1366
+ if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1270
1367
  isFirst = false;
1271
1368
  } else {
1272
- const nextStaticHtml = runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml);
1369
+ const nextStaticHtml = runSubsequentRender(
1370
+ rootEl,
1371
+ segment,
1372
+ bindings,
1373
+ renderCtx,
1374
+ prevStaticHtml,
1375
+ valueOnlyWarnCtx
1376
+ );
1273
1377
  if (nextStaticHtml !== prevStaticHtml) {
1274
1378
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1379
+ if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1380
+ } else {
1381
+ maybeWarnStaleBinding(prevWiredBindings, bindingCtx.list);
1275
1382
  }
1276
1383
  prevStaticHtml = nextStaticHtml;
1277
1384
  }
1278
1385
  for (const listSeg of collectLists(segment).values()) {
1279
1386
  const binding = bindings.get(listSeg.id);
1387
+ if (binding === void 0) {
1388
+ throw new Error(
1389
+ "mount: an each() list appeared in the render output but its marker never reached the live DOM. The most common cause is an each() introduced inside a data-morph-skip subtree on a re-render \u2014 the morph leaves that subtree untouched, so the list can never bind. Move the each() outside the skipped subtree, or remove data-morph-skip from its ancestor."
1390
+ );
1391
+ }
1280
1392
  reconcileList(binding, listSeg);
1281
1393
  renderCtx.bindingCounts.set(listSeg.id, binding.items.length);
1282
1394
  }
@@ -1289,18 +1401,19 @@ function mount(rootEl, render) {
1289
1401
  for (const item of b.items) disposeRowBindings(item.bindingDisposers);
1290
1402
  }
1291
1403
  listenerWarnObserver?.disconnect();
1292
- delete rootEl[MOUNTED_MARKER];
1404
+ setMounted(rootEl, false);
1293
1405
  };
1294
1406
  }
1295
1407
  function runFirstRender(rootEl, segment, bindings) {
1296
1408
  rootEl.innerHTML = flatten(segment, true);
1297
1409
  bindListsFromMarkers(rootEl, segment, bindings, true);
1298
1410
  }
1299
- function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml) {
1411
+ function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml, valueOnlyWarnCtx) {
1300
1412
  const currentStaticHtml = flattenWithoutListItems(segment);
1301
1413
  if (currentStaticHtml === prevStaticHtml) {
1302
1414
  return prevStaticHtml;
1303
1415
  }
1416
+ maybeWarnValueOnlyRerender(prevStaticHtml, currentStaticHtml, valueOnlyWarnCtx);
1304
1417
  cleanupOrphanBindings(segment, bindings, renderCtx);
1305
1418
  const template = rootEl.cloneNode(false);
1306
1419
  template.innerHTML = currentStaticHtml;
@@ -1345,7 +1458,7 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
1345
1458
  }
1346
1459
  const binding = { liveParent, items, marker };
1347
1460
  if (items.length > 0) {
1348
- maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
1461
+ maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
1349
1462
  }
1350
1463
  maybeWarnEachInMorphSkip(id, liveParent, rootEl);
1351
1464
  bindings.set(id, binding);