kerfjs 0.3.1 → 0.5.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,9 +1,19 @@
1
- import { isSafeHtml, listSafeHtml, flatten, collectLists, flattenWithoutListItems } from './chunk-WK5D3OO7.js';
2
- export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-WK5D3OO7.js';
3
- import { effect } from './chunk-IZJIKRCE.js';
4
- export { batch, computed, defineStore, effect, resetAllStores, signal } from './chunk-IZJIKRCE.js';
1
+ import { isSafeHtml, listSafeHtml, flattenWithoutListItems, collectLists, flatten, granularListSafeHtml } from './chunk-WYJTMERY.js';
2
+ export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-WYJTMERY.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';
5
6
 
6
7
  // src/delegate.ts
8
+ var NON_BUBBLING = /* @__PURE__ */ new Set([
9
+ "focus",
10
+ "blur",
11
+ "scroll",
12
+ "load",
13
+ "error",
14
+ "mouseenter",
15
+ "mouseleave"
16
+ ]);
7
17
  function assertValidSelector(selector, fn) {
8
18
  try {
9
19
  document.createElement("div").matches(selector);
@@ -23,9 +33,10 @@ function delegate(rootEl, type, selector, handler) {
23
33
  handler(event, matched);
24
34
  }
25
35
  };
26
- rootEl.addEventListener(type, listener);
36
+ const capture = NON_BUBBLING.has(type);
37
+ rootEl.addEventListener(type, listener, capture);
27
38
  return () => {
28
- rootEl.removeEventListener(type, listener);
39
+ rootEl.removeEventListener(type, listener, capture);
29
40
  };
30
41
  }
31
42
  function delegateCapture(rootEl, type, selector, handler) {
@@ -44,25 +55,109 @@ function delegateCapture(rootEl, type, selector, handler) {
44
55
  }
45
56
 
46
57
  // src/each.ts
47
- var ROW_CACHE = /* @__PURE__ */ new WeakMap();
48
- var listCounter = null;
49
- function _setListCounter(c) {
50
- listCounter = c;
58
+ var ARRAY_SIGNAL_BRAND = /* @__PURE__ */ Symbol.for("kerfjs.ArraySignal");
59
+ function isArraySignal(value) {
60
+ return typeof value === "object" && value !== null && value[ARRAY_SIGNAL_BRAND] === true;
61
+ }
62
+ var context = null;
63
+ function _setRenderContext(c) {
64
+ context = c;
51
65
  }
52
66
  function each(items, render, key) {
53
- const id = listCounter !== null ? String(listCounter.value++) : "orphan";
67
+ if (isArraySignal(items) && context !== null) {
68
+ return eachGranular(items, render, key);
69
+ }
70
+ const snapshotItems = isArraySignal(items) ? items.value : items;
71
+ return eachSnapshot(snapshotItems, render, key);
72
+ }
73
+ function eachSnapshot(items, render, key) {
74
+ let id;
75
+ if (context !== null) {
76
+ id = String(context.counter++);
77
+ } else {
78
+ id = "orphan";
79
+ }
80
+ return eachSnapshotById(items, render, key, id);
81
+ }
82
+ function eachGranular(sig, render, key) {
83
+ const ctx = context;
84
+ const id = String(ctx.counter++);
85
+ const previousBindingCount = ctx.bindingCounts.get(id);
86
+ const patches = sig._consumePatches();
87
+ const snapshot = sig.value;
88
+ if (previousBindingCount === void 0 || patches.length === 0) {
89
+ return eachSnapshotById(snapshot, render, key, id);
90
+ }
91
+ let netDelta = 0;
92
+ for (const p of patches) {
93
+ if (p.type === "insert") netDelta += 1;
94
+ else if (p.type === "remove") netDelta -= 1;
95
+ else if (p.type === "replace") {
96
+ return eachSnapshotById(snapshot, render, key, id);
97
+ }
98
+ }
99
+ if (previousBindingCount + netDelta !== snapshot.length) {
100
+ return eachSnapshotById(snapshot, render, key, id);
101
+ }
102
+ const renderFnInternal = (item, index) => {
103
+ const out = render(item, index);
104
+ return isSafeHtml(out) ? out.toString() : out;
105
+ };
106
+ const internalPatches = new Array(patches.length);
107
+ try {
108
+ for (let i = 0; i < patches.length; i++) {
109
+ const p = patches[i];
110
+ if (p.type === "insert" || p.type === "update") {
111
+ internalPatches[i] = {
112
+ type: p.type,
113
+ index: p.index,
114
+ item: p.item,
115
+ html: renderFnInternal(p.item, p.index)
116
+ };
117
+ } else {
118
+ internalPatches[i] = p;
119
+ }
120
+ }
121
+ } catch {
122
+ ctx.bindingCounts.delete(id);
123
+ return eachSnapshotById(snapshot, render, key, id);
124
+ }
125
+ return granularListSafeHtml(id, [], internalPatches);
126
+ }
127
+ function eachSnapshotById(items, render, key, id) {
128
+ let cache = null;
129
+ if (context !== null) {
130
+ let c = context.caches.get(id);
131
+ if (c === void 0) {
132
+ c = /* @__PURE__ */ new WeakMap();
133
+ context.caches.set(id, c);
134
+ }
135
+ cache = c;
136
+ }
54
137
  const segItems = new Array(items.length);
138
+ const seen = /* @__PURE__ */ new Set();
55
139
  for (let i = 0; i < items.length; i++) {
56
140
  const item = items[i];
141
+ if (typeof item !== "object" || item === null) {
142
+ throw new Error(
143
+ `each(): items must be objects (the per-item HTML cache is a WeakMap), got ${item === null ? "null" : typeof item} at index ${i}. Wrap primitives if you need to iterate them, e.g. items.map(v => ({ v })).`
144
+ );
145
+ }
146
+ if (seen.has(item)) {
147
+ throw new Error(
148
+ `each(): the same object reference appears at multiple indices in items (first seen earlier, again at index ${i}). The per-item HTML cache is keyed on object identity, so duplicate references break the keyed reconciler and can leak DOM nodes on re-render. Use a fresh object per row (e.g. items.map(o => ({ ...o })) before passing to each()).`
149
+ );
150
+ }
151
+ seen.add(item);
57
152
  const k = key ? key(item, i) : void 0;
58
- const cached = ROW_CACHE.get(item);
59
153
  let html;
154
+ const cached = cache !== null ? cache.get(item) : void 0;
60
155
  if (cached !== void 0 && cached.key === k) {
61
156
  html = cached.html;
62
157
  } else {
63
158
  const out = render(item, i);
64
159
  html = isSafeHtml(out) ? out.toString() : out;
65
- ROW_CACHE.set(item, { key: k, html });
160
+ if (cache !== null) cache.set(item, { key: k, html });
66
161
  }
67
162
  segItems[i] = { ref: item, cacheKey: k, html };
68
163
  }
@@ -84,16 +179,23 @@ function getNodeKey(node) {
84
179
  }
85
180
  return void 0;
86
181
  }
87
- function diff(liveRoot, templateRoot, listParents) {
88
- diffChildren(liveRoot, templateRoot, listParents);
182
+ function diff(liveRoot, templateRoot, ownedItems) {
183
+ diffChildren(liveRoot, templateRoot, ownedItems);
89
184
  }
90
- function diffChildren(fromParent, toParent, listParents) {
185
+ function skipOwned(node, ownedItems) {
186
+ while (node !== null && node.nodeType === ELEMENT_NODE && ownedItems.has(node)) {
187
+ node = node.nextSibling;
188
+ }
189
+ return node;
190
+ }
191
+ function diffChildren(fromParent, toParent, ownedItems) {
91
192
  const keyed = /* @__PURE__ */ new Map();
92
193
  for (let c = fromParent.firstChild; c !== null; c = c.nextSibling) {
194
+ if (c.nodeType === ELEMENT_NODE && ownedItems.has(c)) continue;
93
195
  const k = getNodeKey(c);
94
196
  if (k !== void 0) keyed.set(k, c);
95
197
  }
96
- let fromChild = fromParent.firstChild;
198
+ let fromChild = skipOwned(fromParent.firstChild, ownedItems);
97
199
  let toChild = toParent.firstChild;
98
200
  while (toChild !== null) {
99
201
  const toNext = toChild.nextSibling;
@@ -105,15 +207,15 @@ function diffChildren(fromParent, toParent, listParents) {
105
207
  if (matched !== fromChild) {
106
208
  fromParent.insertBefore(matched, fromChild);
107
209
  } else {
108
- fromChild = fromChild.nextSibling;
210
+ fromChild = skipOwned(fromChild.nextSibling, ownedItems);
109
211
  }
110
212
  }
111
213
  if (matched === null && fromChild !== null && fromChild.nodeType === toChild.nodeType && (toChild.nodeType !== ELEMENT_NODE || fromChild.tagName === toChild.tagName && getNodeKey(fromChild) === void 0)) {
112
214
  matched = fromChild;
113
- fromChild = fromChild.nextSibling;
215
+ fromChild = skipOwned(fromChild.nextSibling, ownedItems);
114
216
  }
115
217
  if (matched !== null) {
116
- morphNode(matched, toChild, listParents);
218
+ morphNode(matched, toChild, ownedItems);
117
219
  } else {
118
220
  const cloned = toChild.cloneNode(true);
119
221
  fromParent.insertBefore(cloned, fromChild);
@@ -122,13 +224,15 @@ function diffChildren(fromParent, toParent, listParents) {
122
224
  }
123
225
  while (fromChild !== null) {
124
226
  const next = fromChild.nextSibling;
125
- fromParent.removeChild(fromChild);
227
+ if (fromChild.nodeType !== ELEMENT_NODE || !ownedItems.has(fromChild)) {
228
+ fromParent.removeChild(fromChild);
229
+ }
126
230
  fromChild = next;
127
231
  }
128
232
  }
129
- function morphNode(fromNode, toNode, listParents) {
233
+ function morphNode(fromNode, toNode, ownedItems) {
130
234
  if (fromNode.nodeType === ELEMENT_NODE) {
131
- morphElement(fromNode, toNode, listParents);
235
+ morphElement(fromNode, toNode, ownedItems);
132
236
  return;
133
237
  }
134
238
  if (fromNode.nodeType === TEXT_NODE || fromNode.nodeType === COMMENT_NODE) {
@@ -137,7 +241,7 @@ function morphNode(fromNode, toNode, listParents) {
137
241
  if (fromText.data !== toText.data) fromText.data = toText.data;
138
242
  }
139
243
  }
140
- function morphElement(fromEl, toEl, listParents) {
244
+ function morphElement(fromEl, toEl, ownedItems) {
141
245
  if (fromEl.tagName !== toEl.tagName) {
142
246
  const replacement = toEl.cloneNode(true);
143
247
  fromEl.parentNode?.replaceChild(replacement, fromEl);
@@ -151,8 +255,10 @@ function morphElement(fromEl, toEl, listParents) {
151
255
  if (isTextInputOrTextarea(fromEl)) preserveTextEntryState(fromEl, toEl);
152
256
  }
153
257
  morphAttributes(fromEl, toEl);
154
- if (listParents.has(fromEl)) return;
155
- diffChildren(fromEl, toEl, listParents);
258
+ diffChildren(fromEl, toEl, ownedItems);
259
+ }
260
+ function isUserAgentOwnedAttr(tagName, name) {
261
+ return name === "open" && (tagName === "DETAILS" || tagName === "DIALOG");
156
262
  }
157
263
  function morphAttributes(fromEl, toEl) {
158
264
  const toAttrs = toEl.attributes;
@@ -170,13 +276,14 @@ function morphAttributes(fromEl, toEl) {
170
276
  }
171
277
  }
172
278
  const fromAttrs = fromEl.attributes;
279
+ const fromTag = fromEl.tagName;
173
280
  for (let i = fromAttrs.length - 1; i >= 0; i--) {
174
281
  const attr = fromAttrs[i];
175
282
  const ns = attr.namespaceURI;
176
283
  const name = attr.localName;
177
284
  if (ns !== null) {
178
285
  if (!toEl.hasAttributeNS(ns, name)) fromEl.removeAttributeNS(ns, name);
179
- } else if (!toEl.hasAttribute(name)) {
286
+ } else if (!toEl.hasAttribute(name) && !isUserAgentOwnedAttr(fromTag, name)) {
180
287
  fromEl.removeAttribute(name);
181
288
  }
182
289
  }
@@ -201,13 +308,249 @@ function preserveTextEntryState(fromEl, toEl) {
201
308
  }
202
309
  }
203
310
 
204
- // src/list-reconcile.ts
311
+ // src/list-binding.ts
312
+ function endAnchor(binding) {
313
+ if (binding.items.length > 0) {
314
+ return binding.items[binding.items.length - 1].node.nextElementSibling;
315
+ }
316
+ return binding.marker.nextElementSibling;
317
+ }
318
+
319
+ // src/list-reconcile-focus.ts
320
+ function captureFocus(liveParent) {
321
+ const active = document.activeElement;
322
+ if (active === null || active === document.body) return null;
323
+ if (!liveParent.contains(active)) return null;
324
+ const el = active;
325
+ let selStart = null;
326
+ let selEnd = null;
327
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
328
+ try {
329
+ selStart = el.selectionStart;
330
+ selEnd = el.selectionEnd;
331
+ } catch {
332
+ }
333
+ }
334
+ return { el, selStart, selEnd };
335
+ }
336
+ function restoreFocus(snap) {
337
+ if (document.activeElement === snap.el) return;
338
+ if (!snap.el.isConnected) return;
339
+ snap.el.focus();
340
+ if (snap.selStart !== null && snap.selEnd !== null) {
341
+ try {
342
+ snap.el.setSelectionRange(snap.selStart, snap.selEnd);
343
+ } catch {
344
+ }
345
+ }
346
+ }
347
+
348
+ // src/utils/rowContract.ts
349
+ var ROW_HTML_SNIPPET_MAX = 120;
350
+ function truncateRowHtml(html) {
351
+ return html.length > ROW_HTML_SNIPPET_MAX ? html.slice(0, ROW_HTML_SNIPPET_MAX) + "\u2026" : html;
352
+ }
353
+ function parseRowTemplate(html) {
354
+ const tpl = document.createElement("template");
355
+ tpl.innerHTML = html;
356
+ return { tpl, count: tpl.content.children.length };
357
+ }
358
+ function rowContractError(index, html) {
359
+ const { count } = parseRowTemplate(html);
360
+ const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
361
+ return new Error(
362
+ `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
+ );
364
+ }
365
+
366
+ // src/list-reconcile-granular.ts
367
+ function reconcileGranular(binding, patches) {
368
+ const { liveParent } = binding;
369
+ const items = binding.items;
370
+ const focusSnap = captureFocus(liveParent);
371
+ let i = 0;
372
+ while (i < patches.length) {
373
+ const patch = patches[i];
374
+ if (patch.type === "replace") {
375
+ i += 1;
376
+ continue;
377
+ }
378
+ if (patch.type === "update") {
379
+ let runEnd = i + 1;
380
+ while (runEnd < patches.length && patches[runEnd].type === "update") {
381
+ runEnd += 1;
382
+ }
383
+ const runLen = runEnd - i;
384
+ if (runLen === 1) {
385
+ applySingleUpdate(liveParent, items, patch);
386
+ } else {
387
+ applyBulkUpdate(liveParent, items, patches, i, runEnd);
388
+ }
389
+ i = runEnd;
390
+ continue;
391
+ }
392
+ if (patch.type === "insert") {
393
+ let runEnd = i + 1;
394
+ while (runEnd < patches.length && patches[runEnd].type === "insert" && patches[runEnd].index === patches[runEnd - 1].index + 1) {
395
+ runEnd += 1;
396
+ }
397
+ const runLen = runEnd - i;
398
+ if (runLen === 1) {
399
+ applySingleInsert(liveParent, items, patch, endAnchor(binding));
400
+ } else {
401
+ applyBulkInsert(liveParent, items, patches, i, runEnd, endAnchor(binding));
402
+ }
403
+ i = runEnd;
404
+ continue;
405
+ }
406
+ if (patch.type === "remove") {
407
+ const entry = items[patch.index];
408
+ liveParent.removeChild(entry.node);
409
+ items.splice(patch.index, 1);
410
+ i += 1;
411
+ continue;
412
+ }
413
+ if (patch.type === "move") {
414
+ const moved = items[patch.from];
415
+ let anchorIdx = patch.to;
416
+ if (patch.from < patch.to) anchorIdx += 1;
417
+ const anchor = anchorIdx < items.length ? items[anchorIdx].node : endAnchor(binding);
418
+ liveParent.insertBefore(moved.node, anchor);
419
+ items.splice(patch.from, 1);
420
+ items.splice(patch.to, 0, moved);
421
+ i += 1;
422
+ continue;
423
+ }
424
+ }
425
+ if (focusSnap !== null) restoreFocus(focusSnap);
426
+ }
427
+ function applySingleInsert(liveParent, items, patch, tailAnchor) {
428
+ const { html } = patch;
429
+ const newNode = parseSingleRow(html);
430
+ const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;
431
+ liveParent.insertBefore(newNode, anchor);
432
+ items.splice(patch.index, 0, {
433
+ ref: patch.item,
434
+ cacheKey: void 0,
435
+ html,
436
+ node: newNode
437
+ });
438
+ }
439
+ function applySingleUpdate(liveParent, items, patch) {
440
+ const { html } = patch;
441
+ const oldEntry = items[patch.index];
442
+ if (html === oldEntry.html) return;
443
+ const newNode = parseSingleRow(html);
444
+ liveParent.replaceChild(newNode, oldEntry.node);
445
+ items[patch.index] = { ref: patch.item, cacheKey: void 0, html, node: newNode };
446
+ }
447
+ function applyBulkUpdate(liveParent, items, patches, start, end) {
448
+ const changes = [];
449
+ for (let k = start; k < end; k++) {
450
+ const p = patches[k];
451
+ if (p.html !== items[p.index].html) {
452
+ changes.push({ patchIdx: k, html: p.html });
453
+ }
454
+ }
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);
459
+ }
460
+ const newNodes = new Array(changes.length);
461
+ let child = tpl.content.firstElementChild;
462
+ for (let k = 0; k < newNodes.length; k++) {
463
+ newNodes[k] = child;
464
+ child = child.nextElementSibling;
465
+ }
466
+ for (let k = 0; k < changes.length; k++) {
467
+ const c = changes[k];
468
+ const p = patches[c.patchIdx];
469
+ 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] };
472
+ }
473
+ }
474
+ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
475
+ const startIdx = patches[start].index;
476
+ const htmls = new Array(end - start);
477
+ for (let k = start; k < end; k++) {
478
+ const p = patches[k];
479
+ htmls[k - start] = p.html;
480
+ }
481
+ const { tpl, count } = parseRowTemplate(htmls.join(""));
482
+ if (count !== htmls.length) {
483
+ throw findOffendingInsert(patches, start, htmls);
484
+ }
485
+ const newNodes = new Array(end - start);
486
+ let child = tpl.content.firstElementChild;
487
+ for (let k = 0; k < newNodes.length; k++) {
488
+ newNodes[k] = child;
489
+ child = child.nextElementSibling;
490
+ }
491
+ const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;
492
+ liveParent.insertBefore(tpl.content, anchor);
493
+ const newEntries = new Array(end - start);
494
+ for (let k = 0; k < newEntries.length; k++) {
495
+ const p = patches[start + k];
496
+ newEntries[k] = {
497
+ ref: p.item,
498
+ cacheKey: void 0,
499
+ html: htmls[k],
500
+ node: newNodes[k]
501
+ };
502
+ }
503
+ items.splice(startIdx, 0, ...newEntries);
504
+ }
505
+ function parseSingleRow(html) {
506
+ const { tpl, count } = parseRowTemplate(html);
507
+ if (count !== 1) {
508
+ const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
509
+ throw new Error(
510
+ `each() granular reconcile: row render ${reason}. Each item's render must return exactly one element. Got HTML: ${JSON.stringify(truncateRowHtml(html))}`
511
+ );
512
+ }
513
+ return tpl.content.firstElementChild;
514
+ }
515
+ function findOffendingInsert(patches, start, htmls) {
516
+ for (let i = 0; i < htmls.length; i++) {
517
+ if (parseRowTemplate(htmls[i]).count !== 1) {
518
+ const p = patches[start + i];
519
+ return rowContractError(p.index, htmls[i]);
520
+ }
521
+ }
522
+ return new Error("each(): bulk-insert mismatch with no per-row offender (kerf bug).");
523
+ }
524
+ function findOffendingChange(patches, changes) {
525
+ for (const c of changes) {
526
+ if (parseRowTemplate(c.html).count !== 1) {
527
+ const p = patches[c.patchIdx];
528
+ return rowContractError(p.index, c.html);
529
+ }
530
+ }
531
+ return new Error("each(): bulk-update mismatch with no per-row offender (kerf bug).");
532
+ }
533
+
534
+ // src/list-reconcile-snapshot.ts
535
+ function reconcileSnapshot(binding, listSeg) {
536
+ const { liveParent } = binding;
537
+ const { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);
538
+ if (replacedNodes.length === 0 && freshIndices.length === 0 && isInOrder(prevIdx)) {
539
+ binding.items = newRecord;
540
+ return;
541
+ }
542
+ const tailAnchor = endAnchor(binding);
543
+ buildFreshNodes(newRecord, freshIndices, freshHtmls);
544
+ const focusSnap = captureFocus(liveParent);
545
+ removeOldNodes(liveParent, replacedNodes);
546
+ applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);
547
+ if (focusSnap !== null) restoreFocus(focusSnap);
548
+ binding.items = newRecord;
549
+ }
205
550
  function classifyItems(oldItems, listSeg) {
206
551
  const oldByRef = /* @__PURE__ */ new Map();
207
- const oldIndex = /* @__PURE__ */ new Map();
208
552
  for (let i = 0; i < oldItems.length; i++) {
209
- oldByRef.set(oldItems[i].ref, oldItems[i]);
210
- oldIndex.set(oldItems[i].ref, i);
553
+ oldByRef.set(oldItems[i].ref, [oldItems[i], i]);
211
554
  }
212
555
  const newRecord = new Array(listSeg.items.length);
213
556
  const prevIdx = new Array(listSeg.items.length);
@@ -219,12 +562,12 @@ function classifyItems(oldItems, listSeg) {
219
562
  const oi = oldByRef.get(ni.ref);
220
563
  if (oi !== void 0) {
221
564
  oldByRef.delete(ni.ref);
222
- if (oi.html === ni.html) {
223
- newRecord[i] = oi;
224
- prevIdx[i] = oldIndex.get(ni.ref);
565
+ if (oi[0].html === ni.html) {
566
+ newRecord[i] = oi[0];
567
+ prevIdx[i] = oi[1];
225
568
  continue;
226
569
  }
227
- replacedNodes.push(oi.node);
570
+ replacedNodes.push(oi[0].node);
228
571
  }
229
572
  newRecord[i] = {
230
573
  ref: ni.ref,
@@ -236,32 +579,37 @@ function classifyItems(oldItems, listSeg) {
236
579
  freshIndices.push(i);
237
580
  freshHtmls.push(ni.html);
238
581
  }
239
- for (const [, orphan] of oldByRef) replacedNodes.push(orphan.node);
582
+ for (const [, orphan] of oldByRef) replacedNodes.push(orphan[0].node);
240
583
  return { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls };
241
584
  }
242
585
  function buildFreshNodes(newRecord, freshIndices, freshHtmls) {
243
586
  if (freshHtmls.length === 0) return;
244
- const tpl = document.createElement("template");
245
- tpl.innerHTML = freshHtmls.join("");
587
+ const { tpl, count } = parseRowTemplate(freshHtmls.join(""));
588
+ if (count !== freshHtmls.length) {
589
+ throw findOffendingRow(newRecord, freshIndices, freshHtmls);
590
+ }
246
591
  let node = tpl.content.firstElementChild;
247
592
  for (const idx of freshIndices) {
248
- if (node === null) {
249
- throw new Error(
250
- `each(): row render produced no top-level element. Each item's render must return exactly one element. Got HTML: ${newRecord[idx].html.slice(0, 120)}`
251
- );
252
- }
253
593
  const next = node.nextElementSibling;
254
594
  newRecord[idx].node = node;
255
595
  node = next;
256
596
  }
257
597
  }
598
+ function findOffendingRow(newRecord, freshIndices, freshHtmls) {
599
+ for (let i = 0; i < freshHtmls.length; i++) {
600
+ if (parseRowTemplate(freshHtmls[i]).count !== 1) {
601
+ return rowContractError(freshIndices[i], newRecord[freshIndices[i]].html);
602
+ }
603
+ }
604
+ return new Error("each(): bulk-parse mismatch with no per-row offender (kerf bug).");
605
+ }
258
606
  function removeOldNodes(liveParent, replacedNodes) {
259
607
  for (const node of replacedNodes) {
260
608
  if (node.parentElement === liveParent) liveParent.removeChild(node);
261
609
  }
262
610
  }
263
- function applyMoves(liveParent, newRecord, prevIdx, stable) {
264
- let nextSibling = null;
611
+ function applyMoves(liveParent, newRecord, prevIdx, stable, tailAnchor) {
612
+ let nextSibling = tailAnchor;
265
613
  for (let i = newRecord.length - 1; i >= 0; i--) {
266
614
  const node = newRecord[i].node;
267
615
  if (prevIdx[i] === -1 || !stable.has(i)) {
@@ -299,71 +647,138 @@ function lis(arr) {
299
647
  }
300
648
  return out;
301
649
  }
650
+ function isInOrder(prevIdx) {
651
+ for (let i = 0; i < prevIdx.length; i++) {
652
+ if (prevIdx[i] !== i) return false;
653
+ }
654
+ return true;
655
+ }
656
+
657
+ // src/list-reconcile.ts
302
658
  function reconcileList(binding, listSeg) {
303
- const { liveParent } = binding;
304
- const { newRecord, prevIdx, replacedNodes, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);
305
- buildFreshNodes(newRecord, freshIndices, freshHtmls);
306
- removeOldNodes(liveParent, replacedNodes);
307
- applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx));
308
- binding.items = newRecord;
659
+ if (listSeg.patches !== void 0 && binding.items.length > 0) {
660
+ reconcileGranular(binding, listSeg.patches);
661
+ return;
662
+ }
663
+ reconcileSnapshot(binding, listSeg);
309
664
  }
310
665
 
311
666
  // src/mount.ts
312
667
  var LIST_MARKER_PREFIX = "kf-list:";
313
668
  function mount(rootEl, render) {
669
+ if (rootEl == null) {
670
+ throw new Error(
671
+ '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
+ );
673
+ }
314
674
  const bindings = /* @__PURE__ */ new Map();
315
- const counter = { value: 0 };
675
+ const renderCtx = {
676
+ counter: 0,
677
+ caches: /* @__PURE__ */ new Map(),
678
+ bindingCounts: /* @__PURE__ */ new Map()
679
+ };
316
680
  let isFirst = true;
681
+ let prevStaticHtml = "";
317
682
  return effect(() => {
318
- counter.value = 0;
319
- _setListCounter(counter);
683
+ renderCtx.counter = 0;
684
+ _setRenderContext(renderCtx);
320
685
  let result;
321
686
  try {
322
687
  result = render();
323
688
  } finally {
324
- _setListCounter(null);
689
+ _setRenderContext(null);
325
690
  }
326
- const segment = isSafeHtml(result) ? result.__segment ?? { kind: "static", html: result.__html } : { kind: "static", html: result };
691
+ const segment = isSafeHtml(result) ? result.__segment ?? { kind: "static", html: result.__html } : { kind: "static", html: coerceRenderResult(result) };
327
692
  if (isFirst) {
328
- rootEl.innerHTML = flatten(segment, true);
329
- bindListsFromMarkers(rootEl, segment, bindings);
693
+ runFirstRender(rootEl, segment, bindings);
694
+ prevStaticHtml = flattenWithoutListItems(segment);
330
695
  isFirst = false;
331
696
  } else {
332
- const template = rootEl.cloneNode(false);
333
- template.innerHTML = flattenWithoutListItems(segment);
334
- const listParents = /* @__PURE__ */ new Set();
335
- for (const b of bindings.values()) listParents.add(b.liveParent);
336
- diff(rootEl, template, listParents);
337
- bindListsFromMarkers(rootEl, segment, bindings);
697
+ prevStaticHtml = runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml);
338
698
  }
339
699
  for (const listSeg of collectLists(segment).values()) {
340
700
  const binding = bindings.get(listSeg.id);
341
701
  reconcileList(binding, listSeg);
702
+ renderCtx.bindingCounts.set(listSeg.id, binding.items.length);
342
703
  }
343
704
  });
344
705
  }
345
- function bindListsFromMarkers(rootEl, segment, bindings) {
706
+ function runFirstRender(rootEl, segment, bindings) {
707
+ rootEl.innerHTML = flatten(segment, true);
708
+ bindListsFromMarkers(rootEl, segment, bindings, true);
709
+ }
710
+ function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml) {
711
+ const currentStaticHtml = flattenWithoutListItems(segment);
712
+ if (currentStaticHtml === prevStaticHtml) {
713
+ return prevStaticHtml;
714
+ }
715
+ cleanupOrphanBindings(segment, bindings, renderCtx);
716
+ const template = rootEl.cloneNode(false);
717
+ template.innerHTML = currentStaticHtml;
718
+ diff(rootEl, template, collectOwnedItems(bindings));
719
+ bindListsFromMarkers(rootEl, segment, bindings, false);
720
+ return currentStaticHtml;
721
+ }
722
+ function coerceRenderResult(result) {
723
+ if (result === null || result === void 0) return "";
724
+ if (result === false || result === true) return "";
725
+ return String(result);
726
+ }
727
+ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
346
728
  const lists = collectLists(segment);
347
729
  const found = [];
348
730
  collectComments(rootEl, found);
349
731
  for (const marker of found) {
350
732
  if (!marker.data.startsWith(LIST_MARKER_PREFIX)) continue;
351
733
  const id = marker.data.slice(LIST_MARKER_PREFIX.length);
734
+ if (bindings.has(id)) continue;
352
735
  const listSeg = lists.get(id);
353
736
  const liveParent = marker.parentElement;
354
737
  const items = [];
355
- let next = marker.nextElementSibling;
356
- for (let i = 0; i < listSeg.items.length && next !== null; i++) {
357
- items.push({
358
- ref: listSeg.items[i].ref,
359
- cacheKey: listSeg.items[i].cacheKey,
360
- html: listSeg.items[i].html,
361
- node: next
362
- });
363
- next = next.nextElementSibling;
364
- }
365
- bindings.set(id, { liveParent, items });
366
- marker.remove();
738
+ if (inlinedItems) {
739
+ let next = marker.nextElementSibling;
740
+ for (let i = 0; i < listSeg.items.length && next !== null; i++) {
741
+ validateInlinedRowMatch(listSeg.items[i].html, i, next);
742
+ items.push({
743
+ ref: listSeg.items[i].ref,
744
+ cacheKey: listSeg.items[i].cacheKey,
745
+ html: listSeg.items[i].html,
746
+ node: next
747
+ });
748
+ next = next.nextElementSibling;
749
+ }
750
+ }
751
+ bindings.set(id, { liveParent, items, marker });
752
+ }
753
+ }
754
+ function validateInlinedRowMatch(expectedHtml, index, boundEl) {
755
+ if (boundEl.outerHTML === expectedHtml) return;
756
+ const { count } = parseRowTemplate(expectedHtml);
757
+ if (count === 1) return;
758
+ throw rowContractError(index, expectedHtml);
759
+ }
760
+ function collectOwnedItems(bindings) {
761
+ const owned = /* @__PURE__ */ new Set();
762
+ for (const b of bindings.values()) {
763
+ for (const item of b.items) owned.add(item.node);
764
+ }
765
+ return owned;
766
+ }
767
+ function cleanupOrphanBindings(segment, bindings, renderCtx) {
768
+ const liveIds = collectLists(segment);
769
+ for (const [id, binding] of bindings) {
770
+ if (liveIds.has(id)) continue;
771
+ for (const item of binding.items) {
772
+ if (item.node.parentElement !== null) {
773
+ item.node.parentElement.removeChild(item.node);
774
+ }
775
+ }
776
+ if (binding.marker.parentElement !== null) {
777
+ binding.marker.parentElement.removeChild(binding.marker);
778
+ }
779
+ bindings.delete(id);
780
+ renderCtx.bindingCounts.delete(id);
781
+ renderCtx.caches.delete(id);
367
782
  }
368
783
  }
369
784
  function collectComments(node, out) {