kerfjs 1.0.2 → 2.0.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.
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, syncFormProp, newBindingContext, _setBindingContext, flattenWithoutListItems, wireBindings, collectLists, disposeRowBindings, flatten, LIST_MARKER_PREFIX, wireRowBindings, granularListSafeHtml, carryOrRewireRowBindings } from './chunk-RYZHZBHE.js';
2
+ export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-RYZHZBHE.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
  }
@@ -415,7 +434,11 @@ function morphElement(fromEl, toEl, ownedItems) {
415
434
  }
416
435
  morphAttributes(fromEl, toEl);
417
436
  if (fromEl.dataset.morphSkipChildren !== void 0) return;
437
+ const syncTextareaValue = fromEl.tagName === "TEXTAREA" && fromEl !== document.activeElement && fromEl.textContent !== toEl.textContent;
418
438
  morphChildren(fromEl, toEl, ownedItems);
439
+ if (syncTextareaValue) {
440
+ fromEl.value = toEl.textContent;
441
+ }
419
442
  }
420
443
  function isUserAgentOwnedAttr(tagName, name) {
421
444
  return name === "open" && (tagName === "DETAILS" || tagName === "DIALOG");
@@ -433,6 +456,7 @@ function morphAttributes(fromEl, toEl) {
433
456
  }
434
457
  } else if (fromEl.getAttribute(name) !== value) {
435
458
  fromEl.setAttribute(name, value);
459
+ syncFormProp(fromEl, name, value, true);
436
460
  }
437
461
  }
438
462
  const fromAttrs = fromEl.attributes;
@@ -445,6 +469,7 @@ function morphAttributes(fromEl, toEl) {
445
469
  if (!toEl.hasAttributeNS(ns, name)) fromEl.removeAttributeNS(ns, name);
446
470
  } else if (!toEl.hasAttribute(name) && !isUserAgentOwnedAttr(fromTag, name)) {
447
471
  fromEl.removeAttribute(name);
472
+ syncFormProp(fromEl, name, "", false);
448
473
  }
449
474
  }
450
475
  }
@@ -468,13 +493,37 @@ function preserveTextEntryState(fromEl, toEl) {
468
493
  }
469
494
  }
470
495
 
496
+ // src/dev-binding-warn.ts
497
+ var warnedHoles = /* @__PURE__ */ new Set();
498
+ function isOptedIn2() {
499
+ if (!isDevMode()) return false;
500
+ const proc = globalThis.process;
501
+ return proc?.env?.KERF_DEV_WARN_STALE_BINDING === "1";
502
+ }
503
+ function describeHole(b) {
504
+ return b.kind === "attr" ? `attr '${b.attr}' (id '${b.id}')` : `text (id '${b.id}')`;
505
+ }
506
+ function maybeWarnStaleBinding(prevWired, current) {
507
+ if (!isOptedIn2()) return;
508
+ const n = Math.min(prevWired.length, current.length);
509
+ for (let i = 0; i < n; i++) {
510
+ const cur = current[i];
511
+ if (prevWired[i].signal === cur.signal) continue;
512
+ if (warnedHoles.has(cur.id)) continue;
513
+ warnedHoles.add(cur.id);
514
+ console.warn(
515
+ `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.`
516
+ );
517
+ }
518
+ }
519
+
471
520
  // src/dev-listener-warn.ts
472
521
  var LISTENER_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.devListener");
473
522
  var patched = false;
474
523
  var warned = false;
475
- function isOptedIn2() {
524
+ function isOptedIn3() {
525
+ if (!isDevMode()) return false;
476
526
  const proc = globalThis.process;
477
- if (proc?.env?.NODE_ENV === "production") return false;
478
527
  return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === "1";
479
528
  }
480
529
  function findAddEventListenerProto() {
@@ -516,7 +565,7 @@ function emitWarning() {
516
565
  );
517
566
  }
518
567
  function installListenerRebuildWarn(rootEl) {
519
- if (!isOptedIn2()) return null;
568
+ if (!isOptedIn3()) return null;
520
569
  patchAddEventListenerOnce();
521
570
  const observer = new MutationObserver((mutations) => {
522
571
  if (warned) return;
@@ -535,6 +584,47 @@ function installListenerRebuildWarn(rootEl) {
535
584
  return observer;
536
585
  }
537
586
 
587
+ // src/dev-rerender-warn.ts
588
+ function isOptedIn4() {
589
+ const proc = globalThis.process;
590
+ if (proc?.env?.KERF_DEV_WARN_VALUE_ONLY_RERENDER !== "1") return false;
591
+ return isDevMode();
592
+ }
593
+ var ELEMENT_NODE2 = 1;
594
+ var TEXT_NODE2 = 3;
595
+ var COMMENT_NODE2 = 8;
596
+ function _isValueOnlyDiff(a, b) {
597
+ const an = a.childNodes;
598
+ const bn = b.childNodes;
599
+ if (an.length !== bn.length) return false;
600
+ for (let i = 0; i < an.length; i++) {
601
+ const x = an[i];
602
+ const y = bn[i];
603
+ if (x.nodeType !== y.nodeType) return false;
604
+ if (x.nodeType === ELEMENT_NODE2) {
605
+ if (x.tagName !== y.tagName) return false;
606
+ if (!_isValueOnlyDiff(x, y)) return false;
607
+ } else if (x.nodeType === COMMENT_NODE2) {
608
+ if (x.data !== y.data) return false;
609
+ } else if (x.nodeType !== TEXT_NODE2) {
610
+ return false;
611
+ }
612
+ }
613
+ return true;
614
+ }
615
+ function maybeWarnValueOnlyRerender(prevHtml, nextHtml, ctx) {
616
+ if (ctx.warned || !isOptedIn4()) return;
617
+ const a = document.createElement("template");
618
+ const b = document.createElement("template");
619
+ a.innerHTML = prevHtml;
620
+ b.innerHTML = nextHtml;
621
+ if (!_isValueOnlyDiff(a.content, b.content)) return;
622
+ ctx.warned = true;
623
+ console.warn(
624
+ "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."
625
+ );
626
+ }
627
+
538
628
  // src/list-binding.ts
539
629
  function endAnchor(binding) {
540
630
  if (binding.items.length > 0) {
@@ -551,8 +641,8 @@ var SQUOTE = 39;
551
641
  var AMP = 38;
552
642
  var EQ = 61;
553
643
  var SLASH = 47;
554
- var TEXT_NODE2 = 3;
555
- var ELEMENT_NODE2 = 1;
644
+ var TEXT_NODE3 = 3;
645
+ var ELEMENT_NODE3 = 1;
556
646
  function isWhitespace(cc) {
557
647
  return cc === 32 || cc === 9 || cc === 10 || cc === 13;
558
648
  }
@@ -655,13 +745,13 @@ function nthTextNodeDescendant(root, n) {
655
745
  function walk(node) {
656
746
  for (let c = node.firstChild; c !== null; c = c.nextSibling) {
657
747
  if (result !== null) return;
658
- if (c.nodeType === TEXT_NODE2) {
748
+ if (c.nodeType === TEXT_NODE3) {
659
749
  if (count === n) {
660
750
  result = c;
661
751
  return;
662
752
  }
663
753
  count++;
664
- } else if (c.nodeType === ELEMENT_NODE2) {
754
+ } else if (c.nodeType === ELEMENT_NODE3) {
665
755
  walk(c);
666
756
  }
667
757
  }
@@ -760,6 +850,20 @@ function parseRowTemplate(html) {
760
850
  tpl.innerHTML = html;
761
851
  return { tpl, count: tpl.content.children.length };
762
852
  }
853
+ function parseSingleRow(html, index) {
854
+ const { tpl, count } = parseRowTemplate(html);
855
+ if (count !== 1) throw rowContractError(index, html);
856
+ return tpl.content.firstElementChild;
857
+ }
858
+ function collectTemplateChildren(tpl, n) {
859
+ const nodes = new Array(n);
860
+ let child = tpl.content.firstElementChild;
861
+ for (let k = 0; k < n; k++) {
862
+ nodes[k] = child;
863
+ child = child.nextElementSibling;
864
+ }
865
+ return nodes;
866
+ }
763
867
  function rowContractError(index, html) {
764
868
  const { count } = parseRowTemplate(html);
765
869
  const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
@@ -767,17 +871,13 @@ function rowContractError(index, html) {
767
871
  `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
872
  );
769
873
  }
770
- function isDevMode() {
771
- const proc = globalThis.process;
772
- return proc?.env?.NODE_ENV !== "production";
773
- }
774
- function maybeWarnMissingRowKey(rowEl, rowIndex, rowHtml, binding) {
874
+ function maybeWarnMissingRowKey(rowEl, rowHtml, binding) {
775
875
  if (!isDevMode()) return;
776
876
  if (binding.warnedMissingKey === true) return;
777
877
  binding.warnedMissingKey = true;
778
878
  if (rowEl.id !== "" || rowEl.hasAttribute("data-key")) return;
779
879
  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))}`
880
+ `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
881
  );
782
882
  }
783
883
 
@@ -843,12 +943,12 @@ function reconcileGranular(binding, patches) {
843
943
  }
844
944
  if (focusSnap !== null) restoreFocus(focusSnap);
845
945
  if (items.length > 0) {
846
- maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
946
+ maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
847
947
  }
848
948
  }
849
949
  function applySingleInsert(liveParent, items, patch, tailAnchor) {
850
950
  const { html } = patch;
851
- const newNode = parseSingleRow(html);
951
+ const newNode = parseSingleRow(html, patch.index);
852
952
  const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;
853
953
  liveParent.insertBefore(newNode, anchor);
854
954
  items.splice(patch.index, 0, {
@@ -867,12 +967,19 @@ function wireRowIfBound(node, bindings) {
867
967
  function applySingleUpdate(liveParent, items, patch) {
868
968
  const { html } = patch;
869
969
  const oldEntry = items[patch.index];
870
- if (html === oldEntry.html) return;
970
+ if (html === oldEntry.html) {
971
+ items[patch.index] = reuseBound(patch, html, oldEntry);
972
+ return;
973
+ }
871
974
  if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, html)) {
872
975
  items[patch.index] = reuseBound(patch, html, oldEntry);
873
976
  return;
874
977
  }
875
- const newNode = parseSingleRow(html);
978
+ const newNode = parseSingleRow(html, patch.index);
979
+ applyParsedRowUpdate(liveParent, items, patch, html, newNode);
980
+ }
981
+ function applyParsedRowUpdate(liveParent, items, patch, html, newNode) {
982
+ const oldEntry = items[patch.index];
876
983
  if (oldEntry.node.tagName === newNode.tagName) {
877
984
  _morphElement(oldEntry.node, newNode);
878
985
  items[patch.index] = reuseBound(patch, html, oldEntry);
@@ -890,13 +997,19 @@ function applySingleUpdate(liveParent, items, patch) {
890
997
  }
891
998
  }
892
999
  function reuseBound(patch, html, oldEntry) {
1000
+ const kept = carryOrRewireRowBindings(
1001
+ oldEntry.node,
1002
+ oldEntry.bindings,
1003
+ oldEntry.bindingDisposers,
1004
+ patch.bindings
1005
+ );
893
1006
  return {
894
1007
  ref: patch.item,
895
1008
  cacheKey: void 0,
896
1009
  html,
897
1010
  node: oldEntry.node,
898
- bindings: oldEntry.bindings,
899
- bindingDisposers: oldEntry.bindingDisposers
1011
+ bindings: kept.bindings,
1012
+ bindingDisposers: kept.bindingDisposers
900
1013
  };
901
1014
  }
902
1015
  function applyBulkUpdate(liveParent, items, patches, start, end) {
@@ -904,7 +1017,10 @@ function applyBulkUpdate(liveParent, items, patches, start, end) {
904
1017
  for (let k = start; k < end; k++) {
905
1018
  const p = patches[k];
906
1019
  const oldEntry = items[p.index];
907
- if (p.html === oldEntry.html) continue;
1020
+ if (p.html === oldEntry.html) {
1021
+ items[p.index] = reuseBound(p, p.html, oldEntry);
1022
+ continue;
1023
+ }
908
1024
  if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, p.html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, p.html)) {
909
1025
  items[p.index] = reuseBound(p, p.html, oldEntry);
910
1026
  continue;
@@ -916,50 +1032,24 @@ function applyBulkUpdate(liveParent, items, patches, start, end) {
916
1032
  if (count !== morphChanges.length) {
917
1033
  throw findOffendingChange(patches, morphChanges);
918
1034
  }
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
- }
1035
+ const newNodes = collectTemplateChildren(tpl, morphChanges.length);
925
1036
  for (let k = 0; k < morphChanges.length; k++) {
926
1037
  const c = morphChanges[k];
927
1038
  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
- }
1039
+ applyParsedRowUpdate(liveParent, items, p, c.html, newNodes[k]);
944
1040
  }
945
1041
  }
946
1042
  function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
947
1043
  const startIdx = patches[start].index;
948
1044
  const htmls = new Array(end - start);
949
1045
  for (let k = start; k < end; k++) {
950
- const p = patches[k];
951
- htmls[k - start] = p.html;
1046
+ htmls[k - start] = patches[k].html;
952
1047
  }
953
1048
  const { tpl, count } = parseRowTemplate(htmls.join(""));
954
1049
  if (count !== htmls.length) {
955
1050
  throw findOffendingInsert(patches, start, htmls);
956
1051
  }
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
- }
1052
+ const newNodes = collectTemplateChildren(tpl, end - start);
963
1053
  const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;
964
1054
  liveParent.insertBefore(tpl.content, anchor);
965
1055
  const newEntries = new Array(end - start);
@@ -977,21 +1067,10 @@ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
977
1067
  }
978
1068
  items.splice(startIdx, 0, ...newEntries);
979
1069
  }
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
1070
  function findOffendingInsert(patches, start, htmls) {
991
1071
  for (let i = 0; i < htmls.length; i++) {
992
1072
  if (parseRowTemplate(htmls[i]).count !== 1) {
993
- const p = patches[start + i];
994
- return rowContractError(p.index, htmls[i]);
1073
+ return rowContractError(patches[start + i].index, htmls[i]);
995
1074
  }
996
1075
  }
997
1076
  return new Error("each(): bulk-insert mismatch with no per-row offender (kerf bug).");
@@ -999,8 +1078,7 @@ function findOffendingInsert(patches, start, htmls) {
999
1078
  function findOffendingChange(patches, changes) {
1000
1079
  for (const c of changes) {
1001
1080
  if (parseRowTemplate(c.html).count !== 1) {
1002
- const p = patches[c.patchIdx];
1003
- return rowContractError(p.index, c.html);
1081
+ return rowContractError(patches[c.patchIdx].index, c.html);
1004
1082
  }
1005
1083
  }
1006
1084
  return new Error("each(): bulk-update mismatch with no per-row offender (kerf bug).");
@@ -1023,40 +1101,45 @@ function tryInPlaceContentUpdate(binding, listSeg) {
1023
1101
  }
1024
1102
  if (focusSnap !== null) restoreFocus(focusSnap);
1025
1103
  binding.items = newRecord;
1026
- maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
1104
+ maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1027
1105
  return true;
1028
1106
  }
1029
1107
  function updateRowInPlace(liveParent, old, ni, index) {
1030
1108
  if (old.html === ni.html || tryAttributeOnlyFastPath(old.node, old.html, ni.html) || tryTextContentFastPath(old.node, old.html, ni.html)) {
1109
+ const kept = carryOrRewireRowBindings(old.node, old.bindings, old.bindingDisposers, ni.bindings);
1031
1110
  return {
1032
1111
  ref: ni.ref,
1033
1112
  cacheKey: ni.cacheKey,
1034
1113
  html: ni.html,
1035
1114
  node: old.node,
1036
- bindings: old.bindings,
1037
- bindingDisposers: old.bindingDisposers
1115
+ bindings: kept.bindings,
1116
+ bindingDisposers: kept.bindingDisposers
1038
1117
  };
1039
1118
  }
1040
- const newNode = parseSingleRow2(ni.html, index);
1119
+ const newNode = parseSingleRow(ni.html, index);
1041
1120
  if (old.node.tagName === newNode.tagName) {
1042
1121
  _morphElement(old.node, newNode);
1122
+ const kept = carryOrRewireRowBindings(old.node, old.bindings, old.bindingDisposers, ni.bindings);
1043
1123
  return {
1044
1124
  ref: ni.ref,
1045
1125
  cacheKey: ni.cacheKey,
1046
1126
  html: ni.html,
1047
1127
  node: old.node,
1048
- bindings: old.bindings,
1049
- bindingDisposers: old.bindingDisposers
1128
+ bindings: kept.bindings,
1129
+ bindingDisposers: kept.bindingDisposers
1050
1130
  };
1051
1131
  }
1052
1132
  disposeRowBindings(old.bindingDisposers);
1053
1133
  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;
1134
+ const fresh = carryOrRewireRowBindings(newNode, void 0, void 0, ni.bindings);
1135
+ return {
1136
+ ref: ni.ref,
1137
+ cacheKey: ni.cacheKey,
1138
+ html: ni.html,
1139
+ node: newNode,
1140
+ bindings: fresh.bindings,
1141
+ bindingDisposers: fresh.bindingDisposers
1142
+ };
1060
1143
  }
1061
1144
 
1062
1145
  // src/list-reconcile-snapshot.ts
@@ -1072,7 +1155,7 @@ function reconcileSnapshot(binding, listSeg) {
1072
1155
  if (focusSnap !== null) restoreFocus(focusSnap);
1073
1156
  binding.items = newRecord;
1074
1157
  if (newRecord.length > 0) {
1075
- maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
1158
+ maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1076
1159
  }
1077
1160
  }
1078
1161
  function classifyItems(oldItems, listSeg) {
@@ -1098,6 +1181,8 @@ function classifyItems(oldItems, listSeg) {
1098
1181
  removedItems.push(oi[0]);
1099
1182
  }
1100
1183
  newRecord[i] = {
1184
+ // `node` placeholder is filled by `buildFreshNodes`; its parse-count
1185
+ // check guarantees every fresh index gets a real element before use.
1101
1186
  ref: ni.ref,
1102
1187
  cacheKey: ni.cacheKey,
1103
1188
  html: ni.html,
@@ -1192,37 +1277,39 @@ function reconcileList(binding, listSeg) {
1192
1277
  }
1193
1278
 
1194
1279
  // src/mount.ts
1195
- var LIST_MARKER_PREFIX = "kf-list:";
1196
1280
  var MOUNTED_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.mounted");
1281
+ 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.";
1282
+ function isMounted(el) {
1283
+ return el[MOUNTED_MARKER] === true;
1284
+ }
1285
+ function setMounted(el, on) {
1286
+ if (on) {
1287
+ el[MOUNTED_MARKER] = true;
1288
+ } else {
1289
+ delete el[MOUNTED_MARKER];
1290
+ }
1291
+ }
1197
1292
  function describeEl(el) {
1198
1293
  const tag = el.tagName.toLowerCase();
1199
1294
  const id = el.id ? `#${el.id}` : "";
1200
1295
  return `<${tag}${id}>`;
1201
1296
  }
1202
1297
  function assertNotInsideMountedTree(rootEl) {
1203
- if (rootEl[MOUNTED_MARKER] === true) {
1298
+ if (isMounted(rootEl)) {
1204
1299
  throw new Error(
1205
1300
  `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
1301
  );
1207
1302
  }
1208
1303
  let ancestor = rootEl.parentElement;
1209
1304
  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
- }
1305
+ if (isMounted(ancestor)) throw new Error(NESTED_MOUNT_MSG);
1215
1306
  ancestor = ancestor.parentElement;
1216
1307
  }
1217
1308
  const stack = [];
1218
1309
  for (let i = 0; i < rootEl.children.length; i++) stack.push(rootEl.children[i]);
1219
1310
  while (stack.length > 0) {
1220
1311
  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
- }
1312
+ if (isMounted(cur)) throw new Error(NESTED_MOUNT_MSG);
1226
1313
  for (let i = 0; i < cur.children.length; i++) stack.push(cur.children[i]);
1227
1314
  }
1228
1315
  }
@@ -1237,7 +1324,7 @@ function mount(rootEl, render) {
1237
1324
  if (owner.defaultView === null) document.adoptNode(rootEl);
1238
1325
  }
1239
1326
  assertNotInsideMountedTree(rootEl);
1240
- rootEl[MOUNTED_MARKER] = true;
1327
+ setMounted(rootEl, true);
1241
1328
  const listenerWarnObserver = installListenerRebuildWarn(rootEl);
1242
1329
  const bindings = /* @__PURE__ */ new Map();
1243
1330
  const renderCtx = {
@@ -1247,8 +1334,10 @@ function mount(rootEl, render) {
1247
1334
  };
1248
1335
  const bindingCtx = newBindingContext();
1249
1336
  let bindingDisposers = [];
1337
+ let prevWiredBindings = [];
1250
1338
  let isFirst = true;
1251
1339
  let prevStaticHtml = "";
1340
+ const valueOnlyWarnCtx = { warned: false };
1252
1341
  const disposeEffect = effect(() => {
1253
1342
  renderCtx.counter = 0;
1254
1343
  bindingCtx.counter = 0;
@@ -1267,16 +1356,32 @@ function mount(rootEl, render) {
1267
1356
  runFirstRender(rootEl, segment, bindings);
1268
1357
  prevStaticHtml = flattenWithoutListItems(segment);
1269
1358
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1359
+ if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1270
1360
  isFirst = false;
1271
1361
  } else {
1272
- const nextStaticHtml = runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml);
1362
+ const nextStaticHtml = runSubsequentRender(
1363
+ rootEl,
1364
+ segment,
1365
+ bindings,
1366
+ renderCtx,
1367
+ prevStaticHtml,
1368
+ valueOnlyWarnCtx
1369
+ );
1273
1370
  if (nextStaticHtml !== prevStaticHtml) {
1274
1371
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1372
+ if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1373
+ } else {
1374
+ maybeWarnStaleBinding(prevWiredBindings, bindingCtx.list);
1275
1375
  }
1276
1376
  prevStaticHtml = nextStaticHtml;
1277
1377
  }
1278
1378
  for (const listSeg of collectLists(segment).values()) {
1279
1379
  const binding = bindings.get(listSeg.id);
1380
+ if (binding === void 0) {
1381
+ throw new Error(
1382
+ "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."
1383
+ );
1384
+ }
1280
1385
  reconcileList(binding, listSeg);
1281
1386
  renderCtx.bindingCounts.set(listSeg.id, binding.items.length);
1282
1387
  }
@@ -1289,18 +1394,19 @@ function mount(rootEl, render) {
1289
1394
  for (const item of b.items) disposeRowBindings(item.bindingDisposers);
1290
1395
  }
1291
1396
  listenerWarnObserver?.disconnect();
1292
- delete rootEl[MOUNTED_MARKER];
1397
+ setMounted(rootEl, false);
1293
1398
  };
1294
1399
  }
1295
1400
  function runFirstRender(rootEl, segment, bindings) {
1296
1401
  rootEl.innerHTML = flatten(segment, true);
1297
1402
  bindListsFromMarkers(rootEl, segment, bindings, true);
1298
1403
  }
1299
- function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml) {
1404
+ function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml, valueOnlyWarnCtx) {
1300
1405
  const currentStaticHtml = flattenWithoutListItems(segment);
1301
1406
  if (currentStaticHtml === prevStaticHtml) {
1302
1407
  return prevStaticHtml;
1303
1408
  }
1409
+ maybeWarnValueOnlyRerender(prevStaticHtml, currentStaticHtml, valueOnlyWarnCtx);
1304
1410
  cleanupOrphanBindings(segment, bindings, renderCtx);
1305
1411
  const template = rootEl.cloneNode(false);
1306
1412
  template.innerHTML = currentStaticHtml;
@@ -1345,7 +1451,7 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
1345
1451
  }
1346
1452
  const binding = { liveParent, items, marker };
1347
1453
  if (items.length > 0) {
1348
- maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
1454
+ maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
1349
1455
  }
1350
1456
  maybeWarnEachInMorphSkip(id, liveParent, rootEl);
1351
1457
  bindings.set(id, binding);