kerfjs 0.5.1 → 0.7.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-KFOSUCCC.js';
2
- export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-KFOSUCCC.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-Z7ZHXD2R.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,22 +149,22 @@ 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
  }
164
164
  return listSafeHtml(id, segItems);
165
165
  }
166
166
 
167
- // src/diff.ts
167
+ // src/morph.ts
168
168
  var ID_KEY_PREFIX = "id:";
169
169
  var DATA_KEY_PREFIX = "data-key:";
170
170
  var ELEMENT_NODE = 1;
@@ -179,8 +179,21 @@ function getNodeKey(node) {
179
179
  }
180
180
  return void 0;
181
181
  }
182
- function diff(liveRoot, templateRoot, ownedItems) {
183
- diffChildren(liveRoot, templateRoot, ownedItems);
182
+ var EMPTY_OWNED = /* @__PURE__ */ new Set();
183
+ function morph(liveRoot, template, ownedItems = EMPTY_OWNED) {
184
+ const templateEl = isElementNode(template) ? template : parseTemplate(liveRoot, template);
185
+ morphChildren(liveRoot, templateEl, ownedItems);
186
+ }
187
+ function _morphElement(fromEl, toEl, ownedItems = EMPTY_OWNED) {
188
+ morphElement(fromEl, toEl, ownedItems);
189
+ }
190
+ function isElementNode(t) {
191
+ return typeof t === "object" && t !== null && t.nodeType === ELEMENT_NODE;
192
+ }
193
+ function parseTemplate(liveRoot, template) {
194
+ const el = liveRoot.cloneNode(false);
195
+ el.innerHTML = String(template);
196
+ return el;
184
197
  }
185
198
  function skipOwned(node, ownedItems) {
186
199
  while (node !== null && node.nodeType === ELEMENT_NODE && ownedItems.has(node)) {
@@ -188,7 +201,7 @@ function skipOwned(node, ownedItems) {
188
201
  }
189
202
  return node;
190
203
  }
191
- function diffChildren(fromParent, toParent, ownedItems) {
204
+ function morphChildren(fromParent, toParent, ownedItems) {
192
205
  const keyed = /* @__PURE__ */ new Map();
193
206
  for (let c = fromParent.firstChild; c !== null; c = c.nextSibling) {
194
207
  if (c.nodeType === ELEMENT_NODE && ownedItems.has(c)) continue;
@@ -224,7 +237,12 @@ function diffChildren(fromParent, toParent, ownedItems) {
224
237
  }
225
238
  while (fromChild !== null) {
226
239
  const next = fromChild.nextSibling;
227
- if (fromChild.nodeType !== ELEMENT_NODE || !ownedItems.has(fromChild)) {
240
+ if (fromChild.nodeType === ELEMENT_NODE) {
241
+ const el = fromChild;
242
+ if (!ownedItems.has(el) && el.dataset.morphPreserve === void 0) {
243
+ fromParent.removeChild(fromChild);
244
+ }
245
+ } else {
228
246
  fromParent.removeChild(fromChild);
229
247
  }
230
248
  fromChild = next;
@@ -255,7 +273,8 @@ function morphElement(fromEl, toEl, ownedItems) {
255
273
  if (isTextInputOrTextarea(fromEl)) preserveTextEntryState(fromEl, toEl);
256
274
  }
257
275
  morphAttributes(fromEl, toEl);
258
- diffChildren(fromEl, toEl, ownedItems);
276
+ if (fromEl.dataset.morphSkipChildren !== void 0) return;
277
+ morphChildren(fromEl, toEl, ownedItems);
259
278
  }
260
279
  function isUserAgentOwnedAttr(tagName, name) {
261
280
  return name === "open" && (tagName === "DETAILS" || tagName === "DIALOG");
@@ -308,6 +327,73 @@ function preserveTextEntryState(fromEl, toEl) {
308
327
  }
309
328
  }
310
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
+
311
397
  // src/list-binding.ts
312
398
  function endAnchor(binding) {
313
399
  if (binding.items.length > 0) {
@@ -316,6 +402,184 @@ function endAnchor(binding) {
316
402
  return binding.marker.nextElementSibling;
317
403
  }
318
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
+
319
583
  // src/list-reconcile-focus.ts
320
584
  function captureFocus(liveParent) {
321
585
  const active = document.activeElement;
@@ -362,6 +626,19 @@ function rowContractError(index, html) {
362
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))}`
363
627
  );
364
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
+ }
365
642
 
366
643
  // src/list-reconcile-granular.ts
367
644
  function reconcileGranular(binding, patches) {
@@ -423,6 +700,9 @@ function reconcileGranular(binding, patches) {
423
700
  }
424
701
  }
425
702
  if (focusSnap !== null) restoreFocus(focusSnap);
703
+ if (items.length > 0) {
704
+ maybeWarnMissingRowKey(items[0].node, 0, items[0].html, binding);
705
+ }
426
706
  }
427
707
  function applySingleInsert(liveParent, items, patch, tailAnchor) {
428
708
  const { html } = patch;
@@ -440,35 +720,53 @@ function applySingleUpdate(liveParent, items, patch) {
440
720
  const { html } = patch;
441
721
  const oldEntry = items[patch.index];
442
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
+ }
443
727
  const newNode = parseSingleRow(html);
444
- liveParent.replaceChild(newNode, oldEntry.node);
445
- 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
+ }
446
735
  }
447
736
  function applyBulkUpdate(liveParent, items, patches, start, end) {
448
- const changes = [];
737
+ const morphChanges = [];
449
738
  for (let k = start; k < end; k++) {
450
739
  const p = patches[k];
451
- if (p.html !== items[p.index].html) {
452
- 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;
453
745
  }
746
+ morphChanges.push({ patchIdx: k, html: p.html });
454
747
  }
455
- if (changes.length === 0) return;
456
- const { tpl, count } = parseRowTemplate(changes.map((c) => c.html).join(""));
457
- if (count !== changes.length) {
458
- 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);
459
752
  }
460
- const newNodes = new Array(changes.length);
753
+ const newNodes = new Array(morphChanges.length);
461
754
  let child = tpl.content.firstElementChild;
462
755
  for (let k = 0; k < newNodes.length; k++) {
463
756
  newNodes[k] = child;
464
757
  child = child.nextElementSibling;
465
758
  }
466
- for (let k = 0; k < changes.length; k++) {
467
- const c = changes[k];
759
+ for (let k = 0; k < morphChanges.length; k++) {
760
+ const c = morphChanges[k];
468
761
  const p = patches[c.patchIdx];
469
762
  const oldEntry = items[p.index];
470
- liveParent.replaceChild(newNodes[k], oldEntry.node);
471
- 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
+ }
472
770
  }
473
771
  }
474
772
  function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
@@ -537,6 +835,9 @@ function reconcileSnapshot(binding, listSeg) {
537
835
  const { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);
538
836
  if (replacedNodes.length === 0 && freshIndices.length === 0 && isInOrder(prevIdx)) {
539
837
  binding.items = newRecord;
838
+ if (newRecord.length > 0) {
839
+ maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
840
+ }
540
841
  return;
541
842
  }
542
843
  const tailAnchor = endAnchor(binding);
@@ -546,6 +847,9 @@ function reconcileSnapshot(binding, listSeg) {
546
847
  applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);
547
848
  if (focusSnap !== null) restoreFocus(focusSnap);
548
849
  binding.items = newRecord;
850
+ if (newRecord.length > 0) {
851
+ maybeWarnMissingRowKey(newRecord[0].node, 0, newRecord[0].html, binding);
852
+ }
549
853
  }
550
854
  function classifyItems(oldItems, listSeg) {
551
855
  const oldByRef = /* @__PURE__ */ new Map();
@@ -665,12 +969,43 @@ function reconcileList(binding, listSeg) {
665
969
 
666
970
  // src/mount.ts
667
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
+ }
668
1000
  function mount(rootEl, render) {
669
1001
  if (rootEl == null) {
670
1002
  throw new Error(
671
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.'
672
1004
  );
673
1005
  }
1006
+ assertNotInsideMountedTree(rootEl);
1007
+ rootEl[MOUNTED_MARKER] = true;
1008
+ const listenerWarnObserver = installListenerRebuildWarn(rootEl);
674
1009
  const bindings = /* @__PURE__ */ new Map();
675
1010
  const renderCtx = {
676
1011
  counter: 0,
@@ -679,7 +1014,7 @@ function mount(rootEl, render) {
679
1014
  };
680
1015
  let isFirst = true;
681
1016
  let prevStaticHtml = "";
682
- return effect(() => {
1017
+ const disposeEffect = effect(() => {
683
1018
  renderCtx.counter = 0;
684
1019
  _setRenderContext(renderCtx);
685
1020
  let result;
@@ -702,6 +1037,11 @@ function mount(rootEl, render) {
702
1037
  renderCtx.bindingCounts.set(listSeg.id, binding.items.length);
703
1038
  }
704
1039
  });
1040
+ return () => {
1041
+ disposeEffect();
1042
+ listenerWarnObserver?.disconnect();
1043
+ delete rootEl[MOUNTED_MARKER];
1044
+ };
705
1045
  }
706
1046
  function runFirstRender(rootEl, segment, bindings) {
707
1047
  rootEl.innerHTML = flatten(segment, true);
@@ -715,7 +1055,7 @@ function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtm
715
1055
  cleanupOrphanBindings(segment, bindings, renderCtx);
716
1056
  const template = rootEl.cloneNode(false);
717
1057
  template.innerHTML = currentStaticHtml;
718
- diff(rootEl, template, collectOwnedItems(bindings));
1058
+ morph(rootEl, template, collectOwnedItems(bindings));
719
1059
  bindListsFromMarkers(rootEl, segment, bindings, false);
720
1060
  return currentStaticHtml;
721
1061
  }
@@ -748,7 +1088,11 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
748
1088
  next = next.nextElementSibling;
749
1089
  }
750
1090
  }
751
- 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);
752
1096
  }
753
1097
  }
754
1098
  function validateInlinedRowMatch(expectedHtml, index, boundEl) {
@@ -855,6 +1199,6 @@ function toElement(jsx) {
855
1199
  return child;
856
1200
  }
857
1201
 
858
- export { delegate, delegateCapture, each, mount, toElement };
1202
+ export { delegate, delegateCapture, each, morph, mount, toElement };
859
1203
  //# sourceMappingURL=index.js.map
860
1204
  //# sourceMappingURL=index.js.map