kerfjs 0.6.0 → 0.8.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 { isSafeHtml, listSafeHtml, flattenWithoutListItems, collectLists, flatten, granularListSafeHtml } from './chunk-LADH5GVQ.js';
2
- export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-LADH5GVQ.js';
3
- export { defineStore, resetAllStores } from './chunk-GQGJFCWL.js';
4
- import { effect } from './chunk-FN2ID4QO.js';
5
- export { batch, computed, effect, signal } from './chunk-FN2ID4QO.js';
1
+ import { isSafeHtml, listSafeHtml, flattenWithoutListItems, collectLists, flatten, granularListSafeHtml } from './chunk-4VT4YZOO.js';
2
+ export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-4VT4YZOO.js';
3
+ export { defineStore, resetAllStores } from './chunk-WUFUTNA7.js';
4
+ import { effect } from './chunk-UU2YJEJY.js';
5
+ export { batch, computed, effect, signal } from './chunk-UU2YJEJY.js';
6
6
 
7
7
  // src/delegate.ts
8
8
  var NON_BUBBLING = /* @__PURE__ */ new Set([
@@ -63,41 +63,41 @@ var context = null;
63
63
  function _setRenderContext(c) {
64
64
  context = c;
65
65
  }
66
- function each(items, render, key) {
66
+ function each(items, render, cacheKey) {
67
67
  if (isArraySignal(items) && context !== null) {
68
- return eachGranular(items, render, key);
68
+ return eachGranular(items, render, cacheKey);
69
69
  }
70
70
  const snapshotItems = isArraySignal(items) ? items.value : items;
71
- return eachSnapshot(snapshotItems, render, key);
71
+ return eachSnapshot(snapshotItems, render, cacheKey);
72
72
  }
73
- function eachSnapshot(items, render, key) {
73
+ function eachSnapshot(items, render, cacheKey) {
74
74
  let id;
75
75
  if (context !== null) {
76
76
  id = String(context.counter++);
77
77
  } else {
78
78
  id = "orphan";
79
79
  }
80
- return eachSnapshotById(items, render, key, id);
80
+ return eachSnapshotById(items, render, cacheKey, id);
81
81
  }
82
- function eachGranular(sig, render, key) {
82
+ function eachGranular(sig, render, cacheKey) {
83
83
  const ctx = context;
84
84
  const id = String(ctx.counter++);
85
85
  const previousBindingCount = ctx.bindingCounts.get(id);
86
86
  const patches = sig._consumePatches();
87
87
  const snapshot = sig.value;
88
88
  if (previousBindingCount === void 0 || patches.length === 0) {
89
- return eachSnapshotById(snapshot, render, key, id);
89
+ return eachSnapshotById(snapshot, render, cacheKey, id);
90
90
  }
91
91
  let netDelta = 0;
92
92
  for (const p of patches) {
93
93
  if (p.type === "insert") netDelta += 1;
94
94
  else if (p.type === "remove") netDelta -= 1;
95
95
  else if (p.type === "replace") {
96
- return eachSnapshotById(snapshot, render, key, id);
96
+ return eachSnapshotById(snapshot, render, cacheKey, id);
97
97
  }
98
98
  }
99
99
  if (previousBindingCount + netDelta !== snapshot.length) {
100
- return eachSnapshotById(snapshot, render, key, id);
100
+ return eachSnapshotById(snapshot, render, cacheKey, id);
101
101
  }
102
102
  const renderFnInternal = (item, index) => {
103
103
  const out = render(item, index);
@@ -120,11 +120,11 @@ function eachGranular(sig, render, key) {
120
120
  }
121
121
  } catch {
122
122
  ctx.bindingCounts.delete(id);
123
- return eachSnapshotById(snapshot, render, key, id);
123
+ return eachSnapshotById(snapshot, render, cacheKey, id);
124
124
  }
125
125
  return granularListSafeHtml(id, [], internalPatches);
126
126
  }
127
- function eachSnapshotById(items, render, key, id) {
127
+ function eachSnapshotById(items, render, cacheKey, id) {
128
128
  let cache = null;
129
129
  if (context !== null) {
130
130
  let c = context.caches.get(id);
@@ -149,15 +149,15 @@ function eachSnapshotById(items, render, key, id) {
149
149
  );
150
150
  }
151
151
  seen.add(item);
152
- const k = key ? key(item, i) : void 0;
152
+ const k = cacheKey ? cacheKey(item, i) : void 0;
153
153
  let html;
154
154
  const cached = cache !== null ? cache.get(item) : void 0;
155
- if (cached !== void 0 && cached.key === k) {
155
+ if (cached !== void 0 && cached.cacheKey === k) {
156
156
  html = cached.html;
157
157
  } else {
158
158
  const out = render(item, i);
159
159
  html = isSafeHtml(out) ? out.toString() : out;
160
- if (cache !== null) cache.set(item, { key: k, html });
160
+ if (cache !== null) cache.set(item, { cacheKey: k, html });
161
161
  }
162
162
  segItems[i] = { ref: item, cacheKey: k, html };
163
163
  }
@@ -184,6 +184,9 @@ function morph(liveRoot, template, ownedItems = EMPTY_OWNED) {
184
184
  const templateEl = isElementNode(template) ? template : parseTemplate(liveRoot, template);
185
185
  morphChildren(liveRoot, templateEl, ownedItems);
186
186
  }
187
+ function _morphElement(fromEl, toEl, ownedItems = EMPTY_OWNED) {
188
+ morphElement(fromEl, toEl, ownedItems);
189
+ }
187
190
  function isElementNode(t) {
188
191
  return typeof t === "object" && t !== null && t.nodeType === ELEMENT_NODE;
189
192
  }
@@ -324,6 +327,73 @@ function preserveTextEntryState(fromEl, toEl) {
324
327
  }
325
328
  }
326
329
 
330
+ // src/dev-listener-warn.ts
331
+ var LISTENER_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.devListener");
332
+ var patched = false;
333
+ var warned = false;
334
+ function isOptedIn() {
335
+ const proc = globalThis.process;
336
+ if (proc?.env?.NODE_ENV === "production") return false;
337
+ return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === "1";
338
+ }
339
+ function findAddEventListenerProto() {
340
+ const probe = document.createElement("div");
341
+ let proto = Object.getPrototypeOf(probe);
342
+ while (!Object.prototype.hasOwnProperty.call(proto, "addEventListener")) {
343
+ proto = Object.getPrototypeOf(proto);
344
+ }
345
+ return proto;
346
+ }
347
+ function patchAddEventListenerOnce() {
348
+ if (patched) return;
349
+ patched = true;
350
+ const proto = findAddEventListenerProto();
351
+ const orig = proto.addEventListener;
352
+ proto.addEventListener = function(type, listener, options) {
353
+ if (this instanceof Element) {
354
+ this[LISTENER_MARKER] = true;
355
+ }
356
+ return orig.call(this, type, listener, options);
357
+ };
358
+ }
359
+ function hasMarkedListener(el) {
360
+ if (el[LISTENER_MARKER] === true) return true;
361
+ const stack = [];
362
+ for (let i = 0; i < el.children.length; i++) stack.push(el.children[i]);
363
+ while (stack.length > 0) {
364
+ const cur = stack.pop();
365
+ if (cur[LISTENER_MARKER] === true) return true;
366
+ for (let i = 0; i < cur.children.length; i++) stack.push(cur.children[i]);
367
+ }
368
+ return false;
369
+ }
370
+ function emitWarning() {
371
+ if (warned) return;
372
+ warned = true;
373
+ console.warn(
374
+ "kerf: a node inside a mount()-managed tree was removed/rebuilt while carrying an imperative addEventListener listener. The listener is gone with the old node. Use `delegate(rootEl, 'click', '[data-action=\"...\"]', handler)` so the listener lives on a stable ancestor and survives re-renders, or wrap the host in `data-morph-skip` if the subtree is library-owned (Monaco, xterm, D3 charts). Set KERF_DEV_WARN_REBUILT_LISTENERS=0 (or unset it) to silence this warning."
375
+ );
376
+ }
377
+ function installListenerRebuildWarn(rootEl) {
378
+ if (!isOptedIn()) return null;
379
+ patchAddEventListenerOnce();
380
+ const observer = new MutationObserver((mutations) => {
381
+ if (warned) return;
382
+ for (const m of mutations) {
383
+ for (let i = 0; i < m.removedNodes.length; i++) {
384
+ const removed = m.removedNodes[i];
385
+ if (!(removed instanceof Element)) continue;
386
+ if (hasMarkedListener(removed)) {
387
+ emitWarning();
388
+ return;
389
+ }
390
+ }
391
+ }
392
+ });
393
+ observer.observe(rootEl, { childList: true, subtree: true });
394
+ return observer;
395
+ }
396
+
327
397
  // src/list-binding.ts
328
398
  function endAnchor(binding) {
329
399
  if (binding.items.length > 0) {
@@ -332,6 +402,184 @@ function endAnchor(binding) {
332
402
  return binding.marker.nextElementSibling;
333
403
  }
334
404
 
405
+ // src/list-reconcile-fast-paths.ts
406
+ var LT = 60;
407
+ var GT = 62;
408
+ var DQUOTE = 34;
409
+ var SQUOTE = 39;
410
+ var AMP = 38;
411
+ var EQ = 61;
412
+ var SLASH = 47;
413
+ var TEXT_NODE2 = 3;
414
+ var ELEMENT_NODE2 = 1;
415
+ function isWhitespace(cc) {
416
+ return cc === 32 || cc === 9 || cc === 10 || cc === 13;
417
+ }
418
+ function tryAttributeOnlyFastPath(liveNode, oldHtml, newHtml) {
419
+ const oldGt = oldHtml.indexOf(">");
420
+ const newGt = newHtml.indexOf(">");
421
+ if (oldGt === -1 || newGt === -1) return false;
422
+ if (oldHtml.length - oldGt !== newHtml.length - newGt) return false;
423
+ if (oldHtml.slice(oldGt) !== newHtml.slice(newGt)) return false;
424
+ if (containsDataMorphSkip(oldHtml) || containsDataMorphSkip(newHtml)) return false;
425
+ const oldTag = parseOpeningTag(oldHtml, oldGt);
426
+ const newTag = parseOpeningTag(newHtml, newGt);
427
+ if (oldTag === null || newTag === null) return false;
428
+ if (oldTag.tagName !== newTag.tagName) return false;
429
+ for (const name of oldTag.attrs.keys()) {
430
+ if (name.indexOf(":") !== -1) return false;
431
+ }
432
+ for (const name of newTag.attrs.keys()) {
433
+ if (name.indexOf(":") !== -1) return false;
434
+ }
435
+ const liveTagUpper = liveNode.tagName;
436
+ for (const [name, rawValue] of newTag.attrs) {
437
+ const oldValue = oldTag.attrs.get(name);
438
+ if (oldValue === rawValue) continue;
439
+ liveNode.setAttribute(name, unescapeAttrValue(rawValue));
440
+ }
441
+ for (const name of oldTag.attrs.keys()) {
442
+ if (newTag.attrs.has(name)) continue;
443
+ if (isUserAgentOwnedAttr2(liveTagUpper, name)) continue;
444
+ liveNode.removeAttribute(name);
445
+ }
446
+ return true;
447
+ }
448
+ function tryTextContentFastPath(liveNode, oldHtml, newHtml) {
449
+ if (containsDataMorphSkip(oldHtml) || containsDataMorphSkip(newHtml)) return false;
450
+ let p = 0;
451
+ const minLen = Math.min(oldHtml.length, newHtml.length);
452
+ while (p < minLen && oldHtml.charCodeAt(p) === newHtml.charCodeAt(p)) p++;
453
+ let s = 0;
454
+ const maxS = minLen - p;
455
+ while (s < maxS && oldHtml.charCodeAt(oldHtml.length - 1 - s) === newHtml.charCodeAt(newHtml.length - 1 - s)) {
456
+ s++;
457
+ }
458
+ const oldWinEnd = oldHtml.length - s;
459
+ const newWinEnd = newHtml.length - s;
460
+ if (!isPureTextWindow(oldHtml, p, oldWinEnd)) return false;
461
+ if (!isPureTextWindow(newHtml, p, newWinEnd)) return false;
462
+ if (p === 0) return false;
463
+ const boundaryCc = oldHtml.charCodeAt(p - 1);
464
+ if (boundaryCc === LT || boundaryCc === DQUOTE || boundaryCc === SQUOTE || boundaryCc === EQ || boundaryCc === AMP) return false;
465
+ const textStart = lastIndexOfChar(oldHtml, GT, p - 1);
466
+ if (textStart === -1) return false;
467
+ const textEnd = oldHtml.indexOf("<", p);
468
+ if (textEnd === -1) return false;
469
+ if (textEnd < oldWinEnd) return false;
470
+ const newTextEnd = textEnd + (newHtml.length - oldHtml.length);
471
+ const oldText = oldHtml.slice(textStart + 1, textEnd);
472
+ const newText = newHtml.slice(textStart + 1, newTextEnd);
473
+ const textIdx = countTextNodesBefore(oldHtml, textStart + 1);
474
+ const targetNode = nthTextNodeDescendant(liveNode, textIdx);
475
+ if (targetNode === null) return false;
476
+ if (targetNode.nodeValue !== oldText) return false;
477
+ targetNode.nodeValue = newText;
478
+ return true;
479
+ }
480
+ function containsDataMorphSkip(html) {
481
+ return html.indexOf("data-morph-skip") !== -1;
482
+ }
483
+ function isPureTextWindow(html, start, end) {
484
+ for (let i = start; i < end; i++) {
485
+ const cc = html.charCodeAt(i);
486
+ if (cc === LT || cc === GT || cc === DQUOTE || cc === SQUOTE || cc === AMP || cc === EQ) return false;
487
+ }
488
+ return true;
489
+ }
490
+ function lastIndexOfChar(html, target, beforeInclusive) {
491
+ for (let i = beforeInclusive; i >= 0; i--) {
492
+ if (html.charCodeAt(i) === target) return i;
493
+ }
494
+ return -1;
495
+ }
496
+ function countTextNodesBefore(html, beforePos) {
497
+ let count = 0;
498
+ let i = 0;
499
+ while (i < beforePos) {
500
+ if (html.charCodeAt(i) === LT) {
501
+ while (i < beforePos && html.charCodeAt(i) !== GT) i++;
502
+ i++;
503
+ } else {
504
+ const start = i;
505
+ while (i < beforePos && html.charCodeAt(i) !== LT) i++;
506
+ if (i > start) count++;
507
+ }
508
+ }
509
+ return count;
510
+ }
511
+ function nthTextNodeDescendant(root, n) {
512
+ let count = 0;
513
+ let result = null;
514
+ function walk(node) {
515
+ for (let c = node.firstChild; c !== null; c = c.nextSibling) {
516
+ if (result !== null) return;
517
+ if (c.nodeType === TEXT_NODE2) {
518
+ if (count === n) {
519
+ result = c;
520
+ return;
521
+ }
522
+ count++;
523
+ } else if (c.nodeType === ELEMENT_NODE2) {
524
+ walk(c);
525
+ }
526
+ }
527
+ }
528
+ walk(root);
529
+ return result;
530
+ }
531
+ function parseOpeningTag(html, gtPos) {
532
+ if (html.charCodeAt(0) !== LT) return null;
533
+ let i = 1;
534
+ let end = gtPos;
535
+ if (i < end && html.charCodeAt(end - 1) === SLASH) end -= 1;
536
+ const nameStart = i;
537
+ while (i < end) {
538
+ const cc = html.charCodeAt(i);
539
+ if (isWhitespace(cc)) break;
540
+ i++;
541
+ }
542
+ const tagName = html.slice(nameStart, i);
543
+ if (tagName.length === 0) return null;
544
+ const attrs = /* @__PURE__ */ new Map();
545
+ while (i < end) {
546
+ while (i < end && isWhitespace(html.charCodeAt(i))) i++;
547
+ if (i >= end) break;
548
+ const aNameStart = i;
549
+ while (i < end) {
550
+ const cc = html.charCodeAt(i);
551
+ if (cc === EQ || isWhitespace(cc)) break;
552
+ i++;
553
+ }
554
+ const aName = html.slice(aNameStart, i);
555
+ if (aName.length === 0) return null;
556
+ while (i < end && isWhitespace(html.charCodeAt(i))) i++;
557
+ if (i < end && html.charCodeAt(i) === EQ) {
558
+ i++;
559
+ while (i < end && isWhitespace(html.charCodeAt(i))) i++;
560
+ if (i >= end) return null;
561
+ const q = html.charCodeAt(i);
562
+ if (q !== DQUOTE && q !== SQUOTE) return null;
563
+ i++;
564
+ const vStart = i;
565
+ while (i < end && html.charCodeAt(i) !== q) i++;
566
+ if (i >= end) return null;
567
+ attrs.set(aName, html.slice(vStart, i));
568
+ i++;
569
+ } else {
570
+ attrs.set(aName, "");
571
+ }
572
+ }
573
+ return { tagName, attrs };
574
+ }
575
+ function unescapeAttrValue(s) {
576
+ if (s.indexOf("&") === -1) return s;
577
+ return s.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
578
+ }
579
+ function isUserAgentOwnedAttr2(tagNameUpper, name) {
580
+ return name === "open" && (tagNameUpper === "DETAILS" || tagNameUpper === "DIALOG");
581
+ }
582
+
335
583
  // src/list-reconcile-focus.ts
336
584
  function captureFocus(liveParent) {
337
585
  const active = document.activeElement;
@@ -378,6 +626,19 @@ function rowContractError(index, html) {
378
626
  `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))}`
379
627
  );
380
628
  }
629
+ function isDevMode() {
630
+ const proc = globalThis.process;
631
+ return proc?.env?.NODE_ENV !== "production";
632
+ }
633
+ function maybeWarnMissingRowKey(rowEl, rowIndex, rowHtml, binding) {
634
+ if (!isDevMode()) return;
635
+ if (binding.warnedMissingKey === true) return;
636
+ binding.warnedMissingKey = true;
637
+ if (rowEl.id !== "" || rowEl.hasAttribute("data-key")) return;
638
+ console.warn(
639
+ `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))}`
640
+ );
641
+ }
381
642
 
382
643
  // src/list-reconcile-granular.ts
383
644
  function reconcileGranular(binding, patches) {
@@ -439,6 +700,9 @@ function reconcileGranular(binding, patches) {
439
700
  }
440
701
  }
441
702
  if (focusSnap !== null) restoreFocus(focusSnap);
703
+ if (items.length > 0) {
704
+ maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
705
+ }
442
706
  }
443
707
  function applySingleInsert(liveParent, items, patch, tailAnchor) {
444
708
  const { html } = patch;
@@ -456,35 +720,53 @@ function applySingleUpdate(liveParent, items, patch) {
456
720
  const { html } = patch;
457
721
  const oldEntry = items[patch.index];
458
722
  if (html === oldEntry.html) return;
723
+ if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, html)) {
724
+ items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: oldEntry.node };
725
+ return;
726
+ }
459
727
  const newNode = parseSingleRow(html);
460
- liveParent.replaceChild(newNode, oldEntry.node);
461
- items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: newNode };
728
+ if (oldEntry.node.tagName === newNode.tagName) {
729
+ _morphElement(oldEntry.node, newNode);
730
+ items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: oldEntry.node };
731
+ } else {
732
+ liveParent.replaceChild(newNode, oldEntry.node);
733
+ items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: newNode };
734
+ }
462
735
  }
463
736
  function applyBulkUpdate(liveParent, items, patches, start, end) {
464
- const changes = [];
737
+ const morphChanges = [];
465
738
  for (let k = start; k < end; k++) {
466
739
  const p = patches[k];
467
- if (p.html !== items[p.index].html) {
468
- changes.push({ patchIdx: k, html: p.html });
740
+ const oldEntry = items[p.index];
741
+ if (p.html === oldEntry.html) continue;
742
+ if (tryAttributeOnlyFastPath(oldEntry.node, oldEntry.html, p.html) || tryTextContentFastPath(oldEntry.node, oldEntry.html, p.html)) {
743
+ items[p.index] = { ref: p.item, cacheKey: void 0, html: p.html, node: oldEntry.node };
744
+ continue;
469
745
  }
746
+ morphChanges.push({ patchIdx: k, html: p.html });
470
747
  }
471
- if (changes.length === 0) return;
472
- const { tpl, count } = parseRowTemplate(changes.map((c) => c.html).join(""));
473
- if (count !== changes.length) {
474
- throw findOffendingChange(patches, changes);
748
+ if (morphChanges.length === 0) return;
749
+ const { tpl, count } = parseRowTemplate(morphChanges.map((c) => c.html).join(""));
750
+ if (count !== morphChanges.length) {
751
+ throw findOffendingChange(patches, morphChanges);
475
752
  }
476
- const newNodes = new Array(changes.length);
753
+ const newNodes = new Array(morphChanges.length);
477
754
  let child = tpl.content.firstElementChild;
478
755
  for (let k = 0; k < newNodes.length; k++) {
479
756
  newNodes[k] = child;
480
757
  child = child.nextElementSibling;
481
758
  }
482
- for (let k = 0; k < changes.length; k++) {
483
- const c = changes[k];
759
+ for (let k = 0; k < morphChanges.length; k++) {
760
+ const c = morphChanges[k];
484
761
  const p = patches[c.patchIdx];
485
762
  const oldEntry = items[p.index];
486
- liveParent.replaceChild(newNodes[k], oldEntry.node);
487
- items[p.index] = { ref: p.item, cacheKey: void 0, html: c.html, node: newNodes[k] };
763
+ if (oldEntry.node.tagName === newNodes[k].tagName) {
764
+ _morphElement(oldEntry.node, newNodes[k]);
765
+ items[p.index] = { ref: p.item, cacheKey: void 0, html: c.html, node: oldEntry.node };
766
+ } else {
767
+ liveParent.replaceChild(newNodes[k], oldEntry.node);
768
+ items[p.index] = { ref: p.item, cacheKey: void 0, html: c.html, node: newNodes[k] };
769
+ }
488
770
  }
489
771
  }
490
772
  function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
@@ -553,6 +835,9 @@ function reconcileSnapshot(binding, listSeg) {
553
835
  const { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);
554
836
  if (replacedNodes.length === 0 && freshIndices.length === 0 && isInOrder(prevIdx)) {
555
837
  binding.items = newRecord;
838
+ if (newRecord.length > 0) {
839
+ maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
840
+ }
556
841
  return;
557
842
  }
558
843
  const tailAnchor = endAnchor(binding);
@@ -562,6 +847,9 @@ function reconcileSnapshot(binding, listSeg) {
562
847
  applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);
563
848
  if (focusSnap !== null) restoreFocus(focusSnap);
564
849
  binding.items = newRecord;
850
+ if (newRecord.length > 0) {
851
+ maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
852
+ }
565
853
  }
566
854
  function classifyItems(oldItems, listSeg) {
567
855
  const oldByRef = /* @__PURE__ */ new Map();
@@ -681,12 +969,43 @@ function reconcileList(binding, listSeg) {
681
969
 
682
970
  // src/mount.ts
683
971
  var LIST_MARKER_PREFIX = "kf-list:";
972
+ var MOUNTED_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.mounted");
973
+ function assertNotInsideMountedTree(rootEl) {
974
+ if (rootEl[MOUNTED_MARKER] === true) {
975
+ throw new Error(
976
+ "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."
977
+ );
978
+ }
979
+ let ancestor = rootEl.parentElement;
980
+ while (ancestor !== null) {
981
+ if (ancestor[MOUNTED_MARKER] === true) {
982
+ throw new Error(
983
+ "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."
984
+ );
985
+ }
986
+ ancestor = ancestor.parentElement;
987
+ }
988
+ const stack = [];
989
+ for (let i = 0; i < rootEl.children.length; i++) stack.push(rootEl.children[i]);
990
+ while (stack.length > 0) {
991
+ const cur = stack.pop();
992
+ if (cur[MOUNTED_MARKER] === true) {
993
+ throw new Error(
994
+ "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."
995
+ );
996
+ }
997
+ for (let i = 0; i < cur.children.length; i++) stack.push(cur.children[i]);
998
+ }
999
+ }
684
1000
  function mount(rootEl, render) {
685
1001
  if (rootEl == null) {
686
1002
  throw new Error(
687
1003
  'mount: rootEl is null/undefined \u2014 pass the live element, e.g. mount(document.getElementById("app")!, render). A common cause is a typo in the id or selector that returns null at runtime even though the TypeScript types say HTMLElement.'
688
1004
  );
689
1005
  }
1006
+ assertNotInsideMountedTree(rootEl);
1007
+ rootEl[MOUNTED_MARKER] = true;
1008
+ const listenerWarnObserver = installListenerRebuildWarn(rootEl);
690
1009
  const bindings = /* @__PURE__ */ new Map();
691
1010
  const renderCtx = {
692
1011
  counter: 0,
@@ -695,7 +1014,7 @@ function mount(rootEl, render) {
695
1014
  };
696
1015
  let isFirst = true;
697
1016
  let prevStaticHtml = "";
698
- return effect(() => {
1017
+ const disposeEffect = effect(() => {
699
1018
  renderCtx.counter = 0;
700
1019
  _setRenderContext(renderCtx);
701
1020
  let result;
@@ -718,6 +1037,11 @@ function mount(rootEl, render) {
718
1037
  renderCtx.bindingCounts.set(listSeg.id, binding.items.length);
719
1038
  }
720
1039
  });
1040
+ return () => {
1041
+ disposeEffect();
1042
+ listenerWarnObserver?.disconnect();
1043
+ delete rootEl[MOUNTED_MARKER];
1044
+ };
721
1045
  }
722
1046
  function runFirstRender(rootEl, segment, bindings) {
723
1047
  rootEl.innerHTML = flatten(segment, true);
@@ -764,7 +1088,11 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
764
1088
  next = next.nextElementSibling;
765
1089
  }
766
1090
  }
767
- bindings.set(id, { liveParent, items, marker });
1091
+ const binding = { liveParent, items, marker };
1092
+ if (items.length > 0) {
1093
+ maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
1094
+ }
1095
+ bindings.set(id, binding);
768
1096
  }
769
1097
  }
770
1098
  function validateInlinedRowMatch(expectedHtml, index, boundEl) {