kerfjs 2.0.0 → 3.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +8 -0
  3. package/ai/cursorrules +11 -1
  4. package/ai/manifest.json +5 -5
  5. package/ai/skill.md +11 -1
  6. package/dist/array-signal.d.ts +7 -1
  7. package/dist/array-signal.js +11 -2
  8. package/dist/array-signal.js.map +1 -1
  9. package/dist/bindings-CYwoJpQb.d.ts +60 -0
  10. package/dist/chunk-3APBEVHF.js +20 -0
  11. package/dist/chunk-3APBEVHF.js.map +1 -0
  12. package/dist/chunk-GY4XV2UV.js +73 -0
  13. package/dist/chunk-GY4XV2UV.js.map +1 -0
  14. package/dist/{chunk-RYZHZBHE.js → chunk-JVVU2RQO.js} +15 -79
  15. package/dist/chunk-JVVU2RQO.js.map +1 -0
  16. package/dist/chunk-QIP723L4.js +15 -0
  17. package/dist/chunk-QIP723L4.js.map +1 -0
  18. package/dist/chunk-SAYPJ6XR.js +43 -0
  19. package/dist/chunk-SAYPJ6XR.js.map +1 -0
  20. package/dist/chunk-VVDJLWMP.js +14 -0
  21. package/dist/chunk-VVDJLWMP.js.map +1 -0
  22. package/dist/chunk-YHH7OUFA.js +58 -0
  23. package/dist/chunk-YHH7OUFA.js.map +1 -0
  24. package/dist/dev.d.ts +339 -0
  25. package/dist/dev.js +607 -0
  26. package/dist/dev.js.map +1 -0
  27. package/dist/html.d.ts +1 -0
  28. package/dist/html.js +4 -2
  29. package/dist/html.js.map +1 -1
  30. package/dist/index.d.ts +49 -11
  31. package/dist/index.js +358 -340
  32. package/dist/index.js.map +1 -1
  33. package/dist/jsx-runtime.d.ts +16 -60
  34. package/dist/jsx-runtime.js +4 -2
  35. package/dist/testing.js +3 -2
  36. package/llms.txt +2 -2
  37. package/package.json +22 -11
  38. package/dist/chunk-KFUDM3VP.js +0 -131
  39. package/dist/chunk-KFUDM3VP.js.map +0 -1
  40. package/dist/chunk-NU7YHYEV.js +0 -90
  41. package/dist/chunk-NU7YHYEV.js.map +0 -1
  42. package/dist/chunk-RYZHZBHE.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,8 +1,12 @@
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';
1
+ import { itemVersion } from './chunk-QIP723L4.js';
2
+ import { parseRowTemplate, rowContractError, parseSingleRow, collectTemplateChildren } from './chunk-YHH7OUFA.js';
3
+ import { captureRowBindings, listSafeHtml, boundTextNodeOf, syncFormProp, newBindingContext, wireBindings, disposeRowBindings, isSafeHtml, wireRowBindings, _setBindingContext, TEXT_MARKER_PREFIX, ROW_TEXT_PREFIX, carryOrRewireRowBindings, granularListSafeHtml } from './chunk-JVVU2RQO.js';
4
+ export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-JVVU2RQO.js';
5
+ import { LIST_MARKER_PREFIX, flattenWithoutListItems, collectLists, flatten } from './chunk-GY4XV2UV.js';
6
+ export { defineStore, resetAllStores } from './chunk-SAYPJ6XR.js';
7
+ import { effect } from './chunk-3APBEVHF.js';
8
+ export { batch, computed, effect, signal } from './chunk-3APBEVHF.js';
9
+ import { devHooks } from './chunk-VVDJLWMP.js';
6
10
 
7
11
  // src/attrSelector.ts
8
12
  function cssEscapeIdent(value) {
@@ -106,7 +110,7 @@ function makeListener(rootEl, selector, handler, match) {
106
110
  }
107
111
  function delegate(rootEl, type, selector, handler, options) {
108
112
  assertValidSelector(selector, "delegate");
109
- warnIfInsideEffect("delegate");
113
+ devHooks.delegateInEffect?.("delegate");
110
114
  const listener = makeListener(rootEl, selector, handler, options?.match ?? "closest");
111
115
  const capture = NON_BUBBLING.has(type);
112
116
  rootEl.addEventListener(type, listener, capture);
@@ -116,7 +120,7 @@ function delegate(rootEl, type, selector, handler, options) {
116
120
  }
117
121
  function delegateCapture(rootEl, type, selector, handler, options) {
118
122
  assertValidSelector(selector, "delegateCapture");
119
- warnIfInsideEffect("delegateCapture");
123
+ devHooks.delegateInEffect?.("delegateCapture");
120
124
  const listener = makeListener(rootEl, selector, handler, options?.match ?? "closest");
121
125
  rootEl.addEventListener(type, listener, true);
122
126
  return () => {
@@ -124,52 +128,6 @@ function delegateCapture(rootEl, type, selector, handler, options) {
124
128
  };
125
129
  }
126
130
 
127
- // src/dev-each-warn.ts
128
- var warnedIds = /* @__PURE__ */ new Set();
129
- function isOptedIn() {
130
- if (!isDevMode()) return false;
131
- const proc = globalThis.process;
132
- return proc?.env?.KERF_DEV_WARN_EACH_IN_MORPH_SKIP === "1";
133
- }
134
- function hasMorphSkipAncestor(el, root) {
135
- let ancestor = el.parentElement;
136
- while (ancestor !== null && ancestor !== root) {
137
- if (ancestor.dataset.morphSkip !== void 0) return true;
138
- ancestor = ancestor.parentElement;
139
- }
140
- return false;
141
- }
142
- function maybeWarnEachInMorphSkip(id, liveParent, rootEl) {
143
- if (!isOptedIn()) return;
144
- if (warnedIds.has(id)) return;
145
- if (!hasMorphSkipAncestor(liveParent, rootEl)) return;
146
- warnedIds.add(id);
147
- console.warn(
148
- `kerf: each() list '${id}' is inside a data-morph-skip subtree. The keyed reconciler still updates the list rows, but any static signal-reactive JSX inside the same skipped ancestor (e.g. <p>{count.value}</p>) is frozen \u2014 the morph never visits it. Remove data-morph-skip from any element that contains reactive JSX content and reserve it for truly library-owned hosts. Set KERF_DEV_WARN_EACH_IN_MORPH_SKIP=0 (or unset it) to silence this warning.`
149
- );
150
- }
151
- var warnedDupIds = /* @__PURE__ */ new Set();
152
- function isOptedInDupKeys() {
153
- if (!isDevMode()) return false;
154
- const proc = globalThis.process;
155
- return proc?.env?.KERF_DEV_WARN_DUPLICATE_EACH_KEYS === "1";
156
- }
157
- function maybeWarnDuplicateCacheKeys(id, segItems) {
158
- if (!isOptedInDupKeys()) return;
159
- if (warnedDupIds.has(id)) return;
160
- const seen = /* @__PURE__ */ new Set();
161
- for (const si of segItems) {
162
- if (seen.has(si.cacheKey)) {
163
- warnedDupIds.add(id);
164
- console.warn(
165
- `kerf: each() list '${id}' has duplicate cacheKey values (duplicate: ${String(si.cacheKey)}). The cacheKey function should return a unique value per row so kerf can tell apart items for memoization \u2014 duplicate values cause some rows to return stale cached HTML when external state that affects their render changes. Set KERF_DEV_WARN_DUPLICATE_EACH_KEYS=0 (or unset it) to silence this warning.`
166
- );
167
- return;
168
- }
169
- seen.add(si.cacheKey);
170
- }
171
- }
172
-
173
131
  // src/list-render-state.ts
174
132
  function deriveListRenderState(bindingCount) {
175
133
  if (bindingCount === void 0) return "unbound";
@@ -198,60 +156,138 @@ function isArraySignal(value) {
198
156
  return typeof value === "object" && value !== null && value[ARRAY_SIGNAL_BRAND] === true;
199
157
  }
200
158
  var context = null;
159
+ var renderingRow = false;
160
+ function inRowScope(fn) {
161
+ const prev = renderingRow;
162
+ renderingRow = true;
163
+ try {
164
+ return fn();
165
+ } finally {
166
+ renderingRow = prev;
167
+ }
168
+ }
201
169
  function _setRenderContext(c) {
202
170
  context = c;
203
171
  }
204
- function each(items, render, cacheKey) {
172
+ function _resetCallOrderListState(ctx) {
173
+ const isCallOrderId = (id) => !id.startsWith("k:");
174
+ for (const map of [ctx.caches, ctx.bindingCounts, ctx.bindingSources]) {
175
+ for (const id of Array.from(map.keys())) {
176
+ if (isCallOrderId(id)) map.delete(id);
177
+ }
178
+ }
179
+ }
180
+ function isEachOptions(v) {
181
+ return typeof v === "object" && v !== null;
182
+ }
183
+ var VALID_KEY = /^[A-Za-z0-9_.:/-]+$/;
184
+ function assertValidKey(key) {
185
+ if (typeof key !== "string" || !VALID_KEY.test(key) || key.includes("--")) {
186
+ throw new Error(
187
+ `each(): invalid list key ${JSON.stringify(key)}. A key must be a non-empty string of letters, digits, or _ . : / - (and may not contain "--"), because kerf writes it into the list's marker comment in the DOM. Use a short stable identifier, e.g. { key: 'results' }.`
188
+ );
189
+ }
190
+ }
191
+ function claimKey(ctx, key) {
192
+ assertValidKey(key);
193
+ if (renderingRow) {
194
+ throw new Error(
195
+ `each(): list key ${JSON.stringify(key)} was used by an each() inside a row render. A nested each() is not reconciled \u2014 the row is flattened to HTML, so the inner list never binds and would render as static markup. Render the inner collection with plain .map() (it re-renders with its row), or restructure to a flat list.`
196
+ );
197
+ }
198
+ if (ctx.keysThisRender.has(key)) {
199
+ throw new Error(
200
+ `each(): duplicate list key ${JSON.stringify(key)}. Every keyed each() in a mount must have its own key \u2014 two lists sharing one would share the same cache, binding and DOM anchor. Give each list a distinct key.`
201
+ );
202
+ }
203
+ ctx.keysThisRender.add(key);
204
+ return `k:${key}`;
205
+ }
206
+ function each(items, render, cacheKeyOrOptions) {
207
+ const useOptions = isEachOptions(cacheKeyOrOptions);
208
+ const cacheKey = useOptions ? cacheKeyOrOptions.cacheKey : cacheKeyOrOptions;
209
+ const listKey = useOptions ? cacheKeyOrOptions.key : void 0;
205
210
  if (isArraySignal(items) && context !== null) {
206
- return eachGranular(items, render, cacheKey);
211
+ return eachGranular(items, render, cacheKey, listKey);
207
212
  }
208
213
  const snapshotItems = isArraySignal(items) ? items.value : items;
209
- return eachSnapshot(snapshotItems, render, cacheKey);
214
+ return eachSnapshot(snapshotItems, render, cacheKey, listKey);
210
215
  }
211
- function eachSnapshot(items, render, cacheKey) {
216
+ function eachSnapshot(items, render, cacheKey, listKey) {
212
217
  let id;
213
218
  if (context !== null) {
214
- id = String(context.counter++);
219
+ id = listKey !== void 0 ? claimKey(context, listKey) : String(context.counter++);
215
220
  } else {
216
221
  id = "orphan";
217
222
  }
218
223
  return eachSnapshotById(items, render, cacheKey, id);
219
224
  }
220
- function eachGranular(sig, render, cacheKey) {
225
+ function assertObjectItem(item, index) {
226
+ if (typeof item !== "object" || item === null) {
227
+ throw new Error(
228
+ `each(): items must be objects (the per-item HTML cache is a WeakMap), got ${item === null ? "null" : typeof item} at index ${index}. Wrap primitives if you need to iterate them, e.g. items.map(v => ({ v })).`
229
+ );
230
+ }
231
+ }
232
+ function eachGranular(sig, render, cacheKey, listKey) {
221
233
  const ctx = context;
222
- const id = String(ctx.counter++);
234
+ const id = listKey !== void 0 ? claimKey(ctx, listKey) : String(ctx.counter++);
223
235
  const previousBindingCount = ctx.bindingCounts.get(id);
224
236
  const patches = sig._consumePatches();
225
237
  const snapshot = sig.value;
226
- const decision = decideListPath(
238
+ const previousSource = ctx.bindingSources.get(id);
239
+ const sourceReused = ctx.bindingSources.has(id) && previousSource !== sig;
240
+ if (sourceReused && listKey === void 0) ctx.shiftCandidates.push(id);
241
+ const decision = sourceReused ? { path: "snapshot" } : decideListPath(
227
242
  deriveListRenderState(previousBindingCount),
228
243
  patches,
229
244
  snapshot.length,
230
245
  previousBindingCount
231
246
  );
232
247
  if (decision.path === "snapshot") {
233
- return eachSnapshotById(snapshot, render, cacheKey, id);
248
+ return eachSnapshotById(snapshot, render, cacheKey, id, sig);
249
+ }
250
+ let staleIndexShift = false;
251
+ if (render.length >= 2 && devHooks.staleIndexEnabled?.() === true) {
252
+ const rendered = [];
253
+ for (let i = 0; i < previousBindingCount; i++) rendered.push(i);
254
+ for (const p of patches) {
255
+ if (p.type === "insert") rendered.splice(p.index, 0, p.index);
256
+ else if (p.type === "remove") rendered.splice(p.index, 1);
257
+ else if (p.type === "move") {
258
+ const [moved] = rendered.splice(p.from, 1);
259
+ rendered.splice(p.to, 0, moved);
260
+ }
261
+ }
262
+ for (let i = 0; i < rendered.length; i++) {
263
+ if (rendered[i] !== i) {
264
+ staleIndexShift = true;
265
+ break;
266
+ }
267
+ }
234
268
  }
235
269
  if (cacheKey !== void 0) {
236
- const cache = ctx.caches.get(id);
270
+ const cache2 = ctx.caches.get(id);
237
271
  for (let i = 0; i < snapshot.length; i++) {
238
272
  const item = snapshot[i];
239
273
  const k = cacheKey(item, i);
240
- const cached = cache.get(item);
274
+ const cached = cache2.get(item);
241
275
  if (cached !== void 0 && cached.cacheKey !== k) {
242
- return eachSnapshotById(snapshot, render, cacheKey, id);
276
+ return eachSnapshotById(snapshot, render, cacheKey, id, sig);
243
277
  }
244
278
  }
245
279
  }
246
- const renderRow = (item, index) => captureRowBindings(() => {
280
+ const renderRow = (item, index) => captureRowBindings(() => inRowScope(() => {
247
281
  const out = render(item, index);
248
282
  return isSafeHtml(out) ? out.toString() : out;
249
- });
283
+ }));
250
284
  const internalPatches = new Array(patches.length);
285
+ const cache = ctx.caches.get(id);
251
286
  try {
252
287
  for (let i = 0; i < patches.length; i++) {
253
288
  const p = patches[i];
254
289
  if (p.type === "insert" || p.type === "update") {
290
+ assertObjectItem(p.item, p.index);
255
291
  const { html, bindings } = renderRow(p.item, p.index);
256
292
  internalPatches[i] = {
257
293
  type: p.type,
@@ -260,17 +296,25 @@ function eachGranular(sig, render, cacheKey) {
260
296
  html,
261
297
  bindings
262
298
  };
299
+ cache?.set(p.item, {
300
+ cacheKey: cacheKey ? cacheKey(p.item, p.index) : void 0,
301
+ html,
302
+ bindings,
303
+ version: itemVersion(p.item),
304
+ index: p.index
305
+ });
263
306
  } else {
264
307
  internalPatches[i] = p;
265
308
  }
266
309
  }
267
310
  } catch {
268
311
  ctx.bindingCounts.delete(id);
269
- return eachSnapshotById(snapshot, render, cacheKey, id);
312
+ return eachSnapshotById(snapshot, render, cacheKey, id, sig);
270
313
  }
271
- return granularListSafeHtml(id, [], internalPatches);
314
+ if (staleIndexShift) devHooks.staleIndex?.(id);
315
+ return granularListSafeHtml(id, [], internalPatches, sig);
272
316
  }
273
- function eachSnapshotById(items, render, cacheKey, id) {
317
+ function eachSnapshotById(items, render, cacheKey, id, source) {
274
318
  let cache = null;
275
319
  if (context !== null) {
276
320
  let c = context.caches.get(id);
@@ -284,11 +328,7 @@ function eachSnapshotById(items, render, cacheKey, id) {
284
328
  const seen = /* @__PURE__ */ new Set();
285
329
  for (let i = 0; i < items.length; i++) {
286
330
  const item = items[i];
287
- if (typeof item !== "object" || item === null) {
288
- throw new Error(
289
- `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 })).`
290
- );
291
- }
331
+ assertObjectItem(item, i);
292
332
  if (seen.has(item)) {
293
333
  throw new Error(
294
334
  `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()).`
@@ -296,27 +336,60 @@ function eachSnapshotById(items, render, cacheKey, id) {
296
336
  }
297
337
  seen.add(item);
298
338
  const k = cacheKey ? cacheKey(item, i) : void 0;
339
+ const version = itemVersion(item);
299
340
  let html;
300
341
  let bindings;
301
342
  const cached = cache !== null ? cache.get(item) : void 0;
302
- if (cached !== void 0 && cached.cacheKey === k) {
343
+ if (cached !== void 0 && cached.cacheKey === k && cached.version === version) {
303
344
  html = cached.html;
304
345
  bindings = cached.bindings;
346
+ if (cached.index !== i && render.length >= 2 && devHooks.staleIndexEnabled?.() === true) {
347
+ devHooks.staleIndex?.(id);
348
+ }
305
349
  } else {
306
- const captured = captureRowBindings(() => {
350
+ const captured = captureRowBindings(() => inRowScope(() => {
307
351
  const out = render(item, i);
308
352
  return isSafeHtml(out) ? out.toString() : out;
309
- });
353
+ }));
310
354
  html = captured.html;
311
355
  bindings = captured.bindings;
312
- if (cache !== null) cache.set(item, { cacheKey: k, html, bindings });
356
+ if (cache !== null) cache.set(item, { cacheKey: k, html, bindings, version, index: i });
313
357
  }
314
358
  segItems[i] = { ref: item, cacheKey: k, html, bindings };
315
359
  }
316
360
  if (cacheKey !== void 0) {
317
- maybeWarnDuplicateCacheKeys(id, segItems);
361
+ devHooks.duplicateCacheKeys?.(id, segItems);
362
+ }
363
+ return listSafeHtml(id, segItems, source);
364
+ }
365
+
366
+ // src/list-reconcile-focus.ts
367
+ function captureFocus(liveParent) {
368
+ const active = document.activeElement;
369
+ if (active === null || active === document.body) return null;
370
+ if (!liveParent.contains(active)) return null;
371
+ const el = active;
372
+ let selStart = null;
373
+ let selEnd = null;
374
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
375
+ try {
376
+ selStart = el.selectionStart;
377
+ selEnd = el.selectionEnd;
378
+ } catch {
379
+ }
380
+ }
381
+ return { el, selStart, selEnd };
382
+ }
383
+ function restoreFocus(snap) {
384
+ if (document.activeElement === snap.el) return;
385
+ if (!snap.el.isConnected) return;
386
+ snap.el.focus();
387
+ if (snap.selStart !== null && snap.selEnd !== null) {
388
+ try {
389
+ snap.el.setSelectionRange(snap.selStart, snap.selEnd);
390
+ } catch {
391
+ }
318
392
  }
319
- return listSafeHtml(id, segItems);
320
393
  }
321
394
 
322
395
  // src/morph.ts
@@ -342,7 +415,9 @@ function morph(liveRoot, template, ownedItems = EMPTY_OWNED) {
342
415
  );
343
416
  }
344
417
  const templateEl = isElementNode(template) ? template : parseTemplate(liveRoot, template);
418
+ const focusSnap = captureFocus(liveRoot);
345
419
  morphChildren(liveRoot, templateEl, ownedItems);
420
+ if (focusSnap !== null) restoreFocus(focusSnap);
346
421
  }
347
422
  function _morphElement(fromEl, toEl, ownedItems = EMPTY_OWNED) {
348
423
  morphElement(fromEl, toEl, ownedItems);
@@ -355,12 +430,37 @@ function parseTemplate(liveRoot, template) {
355
430
  el.innerHTML = String(template);
356
431
  return el;
357
432
  }
433
+ function protectionTag(node) {
434
+ const { dataset } = node;
435
+ return (dataset.morphSkip !== void 0 ? "s" : "") + (dataset.morphSkipChildren !== void 0 ? "c" : "") + (dataset.morphPreserve !== void 0 ? "p" : "");
436
+ }
437
+ var MARKER_PREFIXES = [LIST_MARKER_PREFIX, TEXT_MARKER_PREFIX, ROW_TEXT_PREFIX];
438
+ function isMarker(node) {
439
+ if (node.nodeType !== COMMENT_NODE) return false;
440
+ const { data } = node;
441
+ return MARKER_PREFIXES.some((prefix) => data.startsWith(prefix));
442
+ }
443
+ function markersPairable(a, b) {
444
+ if (!isMarker(a) && !isMarker(b)) return true;
445
+ return a.data === b.data;
446
+ }
358
447
  function skipOwned(node, ownedItems) {
359
448
  while (node !== null && node.nodeType === ELEMENT_NODE && ownedItems.has(node)) {
360
449
  node = node.nextSibling;
361
450
  }
362
451
  return node;
363
452
  }
453
+ function isListMarker(node) {
454
+ return node.nodeType === COMMENT_NODE && node.data.startsWith(LIST_MARKER_PREFIX);
455
+ }
456
+ function afterListRegion(marker, ownedItems) {
457
+ let last = marker;
458
+ for (let r = marker.nextSibling; r !== null; r = r.nextSibling) {
459
+ if (isListMarker(r)) break;
460
+ if (r.nodeType === ELEMENT_NODE && ownedItems.has(r)) last = r;
461
+ }
462
+ return last.nextSibling;
463
+ }
364
464
  function morphChildren(fromParent, toParent, ownedItems) {
365
465
  const keyed = /* @__PURE__ */ new Map();
366
466
  for (let c = fromParent.firstChild; c !== null; c = c.nextSibling) {
@@ -383,9 +483,47 @@ function morphChildren(fromParent, toParent, ownedItems) {
383
483
  fromChild = skipOwned(fromChild.nextSibling, ownedItems);
384
484
  }
385
485
  }
386
- if (matched === null && fromChild !== null && fromChild.nodeType === toChild.nodeType && (toChild.nodeType !== ELEMENT_NODE || fromChild.tagName === toChild.tagName && getNodeKey(fromChild) === void 0)) {
486
+ if (matched === null && fromChild !== null && fromChild.nodeType === toChild.nodeType && markersPairable(fromChild, toChild) && (toChild.nodeType !== ELEMENT_NODE || fromChild.tagName === toChild.tagName && getNodeKey(fromChild) === void 0 && toKey === void 0 && protectionTag(fromChild) === protectionTag(toChild))) {
387
487
  matched = fromChild;
388
- fromChild = skipOwned(fromChild.nextSibling, ownedItems);
488
+ fromChild = skipOwned(
489
+ isListMarker(matched) ? afterListRegion(matched, ownedItems) : fromChild.nextSibling,
490
+ ownedItems
491
+ );
492
+ if (matched.nodeType === COMMENT_NODE && fromChild !== null) {
493
+ const owned = boundTextNodeOf(matched);
494
+ if (owned !== null && fromChild === owned) {
495
+ fromChild = skipOwned(owned.nextSibling, ownedItems);
496
+ }
497
+ }
498
+ }
499
+ if (matched === null && toChild.nodeType === ELEMENT_NODE && fromChild !== null && toKey === void 0) {
500
+ const toTag = toChild.tagName;
501
+ for (let scan = fromChild.nextSibling; scan !== null; scan = scan.nextSibling) {
502
+ if (scan.nodeType !== ELEMENT_NODE) continue;
503
+ const el = scan;
504
+ if (ownedItems.has(el)) continue;
505
+ if (el.tagName !== toTag || getNodeKey(el) !== void 0) continue;
506
+ if (protectionTag(el) !== protectionTag(toChild)) continue;
507
+ matched = el;
508
+ fromParent.insertBefore(el, fromChild);
509
+ break;
510
+ }
511
+ }
512
+ if (matched === null && fromChild !== null && toChild.nodeType === COMMENT_NODE && toChild.data.startsWith(LIST_MARKER_PREFIX)) {
513
+ const wantData = toChild.data;
514
+ for (let scan = fromChild.nextSibling; scan !== null; scan = scan.nextSibling) {
515
+ if (scan.nodeType !== COMMENT_NODE || scan.data !== wantData) continue;
516
+ const regionEnd = afterListRegion(scan, ownedItems);
517
+ const run = [];
518
+ for (let r = scan; r !== null && r !== regionEnd; r = r.nextSibling) {
519
+ run.push(r);
520
+ }
521
+ const focusSnap = captureFocus(fromParent);
522
+ for (const node of run) fromParent.insertBefore(node, fromChild);
523
+ if (focusSnap !== null) restoreFocus(focusSnap);
524
+ matched = scan;
525
+ break;
526
+ }
389
527
  }
390
528
  if (matched !== null) {
391
529
  morphNode(matched, toChild, ownedItems);
@@ -493,144 +631,12 @@ function preserveTextEntryState(fromEl, toEl) {
493
631
  }
494
632
  }
495
633
 
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
-
520
- // src/dev-listener-warn.ts
521
- var LISTENER_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.devListener");
522
- var patched = false;
523
- var warned = false;
524
- function isOptedIn3() {
525
- if (!isDevMode()) return false;
526
- const proc = globalThis.process;
527
- return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === "1";
528
- }
529
- function findAddEventListenerProto() {
530
- const probe = document.createElement("div");
531
- let proto = Object.getPrototypeOf(probe);
532
- while (!Object.prototype.hasOwnProperty.call(proto, "addEventListener")) {
533
- proto = Object.getPrototypeOf(proto);
534
- }
535
- return proto;
536
- }
537
- function patchAddEventListenerOnce() {
538
- if (patched) return;
539
- patched = true;
540
- const proto = findAddEventListenerProto();
541
- const orig = proto.addEventListener;
542
- proto.addEventListener = function(type, listener, options) {
543
- if (this instanceof Element) {
544
- this[LISTENER_MARKER] = true;
545
- }
546
- return orig.call(this, type, listener, options);
547
- };
548
- }
549
- function hasMarkedListener(el) {
550
- if (el[LISTENER_MARKER] === true) return true;
551
- const stack = [];
552
- for (let i = 0; i < el.children.length; i++) stack.push(el.children[i]);
553
- while (stack.length > 0) {
554
- const cur = stack.pop();
555
- if (cur[LISTENER_MARKER] === true) return true;
556
- for (let i = 0; i < cur.children.length; i++) stack.push(cur.children[i]);
557
- }
558
- return false;
559
- }
560
- function emitWarning() {
561
- if (warned) return;
562
- warned = true;
563
- console.warn(
564
- "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."
565
- );
566
- }
567
- function installListenerRebuildWarn(rootEl) {
568
- if (!isOptedIn3()) return null;
569
- patchAddEventListenerOnce();
570
- const observer = new MutationObserver((mutations) => {
571
- if (warned) return;
572
- for (const m of mutations) {
573
- for (let i = 0; i < m.removedNodes.length; i++) {
574
- const removed = m.removedNodes[i];
575
- if (!(removed instanceof Element)) continue;
576
- if (hasMarkedListener(removed)) {
577
- emitWarning();
578
- return;
579
- }
580
- }
581
- }
582
- });
583
- observer.observe(rootEl, { childList: true, subtree: true });
584
- return observer;
585
- }
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
-
628
634
  // src/list-binding.ts
629
635
  function endAnchor(binding) {
630
636
  if (binding.items.length > 0) {
631
- return binding.items[binding.items.length - 1].node.nextElementSibling;
637
+ return binding.items[binding.items.length - 1].node.nextSibling;
632
638
  }
633
- return binding.marker.nextElementSibling;
639
+ return binding.marker.nextSibling;
634
640
  }
635
641
 
636
642
  // src/list-reconcile-fast-paths.ts
@@ -641,8 +647,8 @@ var SQUOTE = 39;
641
647
  var AMP = 38;
642
648
  var EQ = 61;
643
649
  var SLASH = 47;
644
- var TEXT_NODE3 = 3;
645
- var ELEMENT_NODE3 = 1;
650
+ var TEXT_NODE2 = 3;
651
+ var ELEMENT_NODE2 = 1;
646
652
  function isWhitespace(cc) {
647
653
  return cc === 32 || cc === 9 || cc === 10 || cc === 13;
648
654
  }
@@ -667,12 +673,15 @@ function tryAttributeOnlyFastPath(liveNode, oldHtml, newHtml) {
667
673
  for (const [name, rawValue] of newTag.attrs) {
668
674
  const oldValue = oldTag.attrs.get(name);
669
675
  if (oldValue === rawValue) continue;
670
- liveNode.setAttribute(name, unescapeAttrValue(rawValue));
676
+ const value = unescapeAttrValue(rawValue);
677
+ liveNode.setAttribute(name, value);
678
+ syncFormProp(liveNode, name, value, true);
671
679
  }
672
680
  for (const name of oldTag.attrs.keys()) {
673
681
  if (newTag.attrs.has(name)) continue;
674
682
  if (isUserAgentOwnedAttr2(liveTagUpper, name)) continue;
675
683
  liveNode.removeAttribute(name);
684
+ syncFormProp(liveNode, name, "", false);
676
685
  }
677
686
  return true;
678
687
  }
@@ -701,11 +710,16 @@ function tryTextContentFastPath(liveNode, oldHtml, newHtml) {
701
710
  const newTextEnd = textEnd + (newHtml.length - oldHtml.length);
702
711
  const oldText = oldHtml.slice(textStart + 1, textEnd);
703
712
  const newText = newHtml.slice(textStart + 1, newTextEnd);
713
+ if (oldHtml.lastIndexOf("<!--kfb", textStart) !== -1) return false;
704
714
  const textIdx = countTextNodesBefore(oldHtml, textStart + 1);
705
715
  const targetNode = nthTextNodeDescendant(liveNode, textIdx);
706
716
  if (targetNode === null) return false;
707
717
  if (targetNode.nodeValue !== oldText) return false;
708
718
  targetNode.nodeValue = newText;
719
+ const host = targetNode.parentNode;
720
+ if (host !== null && host.tagName === "TEXTAREA" && host !== document.activeElement) {
721
+ host.value = newText;
722
+ }
709
723
  return true;
710
724
  }
711
725
  function containsDataMorphSkip(html) {
@@ -745,13 +759,13 @@ function nthTextNodeDescendant(root, n) {
745
759
  function walk(node) {
746
760
  for (let c = node.firstChild; c !== null; c = c.nextSibling) {
747
761
  if (result !== null) return;
748
- if (c.nodeType === TEXT_NODE3) {
762
+ if (c.nodeType === TEXT_NODE2) {
749
763
  if (count === n) {
750
764
  result = c;
751
765
  return;
752
766
  }
753
767
  count++;
754
- } else if (c.nodeType === ELEMENT_NODE3) {
768
+ } else if (c.nodeType === ELEMENT_NODE2) {
755
769
  walk(c);
756
770
  }
757
771
  }
@@ -811,76 +825,6 @@ function isUserAgentOwnedAttr2(tagNameUpper, name) {
811
825
  return name === "open" && (tagNameUpper === "DETAILS" || tagNameUpper === "DIALOG");
812
826
  }
813
827
 
814
- // src/list-reconcile-focus.ts
815
- function captureFocus(liveParent) {
816
- const active = document.activeElement;
817
- if (active === null || active === document.body) return null;
818
- if (!liveParent.contains(active)) return null;
819
- const el = active;
820
- let selStart = null;
821
- let selEnd = null;
822
- if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
823
- try {
824
- selStart = el.selectionStart;
825
- selEnd = el.selectionEnd;
826
- } catch {
827
- }
828
- }
829
- return { el, selStart, selEnd };
830
- }
831
- function restoreFocus(snap) {
832
- if (document.activeElement === snap.el) return;
833
- if (!snap.el.isConnected) return;
834
- snap.el.focus();
835
- if (snap.selStart !== null && snap.selEnd !== null) {
836
- try {
837
- snap.el.setSelectionRange(snap.selStart, snap.selEnd);
838
- } catch {
839
- }
840
- }
841
- }
842
-
843
- // src/utils/rowContract.ts
844
- var ROW_HTML_SNIPPET_MAX = 120;
845
- function truncateRowHtml(html) {
846
- return html.length > ROW_HTML_SNIPPET_MAX ? html.slice(0, ROW_HTML_SNIPPET_MAX) + "\u2026" : html;
847
- }
848
- function parseRowTemplate(html) {
849
- const tpl = document.createElement("template");
850
- tpl.innerHTML = html;
851
- return { tpl, count: tpl.content.children.length };
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
- }
867
- function rowContractError(index, html) {
868
- const { count } = parseRowTemplate(html);
869
- const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
870
- return new Error(
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))}`
872
- );
873
- }
874
- function maybeWarnMissingRowKey(rowEl, rowHtml, binding) {
875
- if (!isDevMode()) return;
876
- if (binding.warnedMissingKey === true) return;
877
- binding.warnedMissingKey = true;
878
- if (rowEl.id !== "" || rowEl.hasAttribute("data-key")) return;
879
- console.warn(
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))}`
881
- );
882
- }
883
-
884
828
  // src/list-reconcile-granular.ts
885
829
  function reconcileGranular(binding, patches) {
886
830
  const { liveParent } = binding;
@@ -943,12 +887,12 @@ function reconcileGranular(binding, patches) {
943
887
  }
944
888
  if (focusSnap !== null) restoreFocus(focusSnap);
945
889
  if (items.length > 0) {
946
- maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
890
+ devHooks.missingRowKey?.(items[0].node, items[0].html, binding);
947
891
  }
948
892
  }
949
893
  function applySingleInsert(liveParent, items, patch, tailAnchor) {
950
894
  const { html } = patch;
951
- const newNode = parseSingleRow(html, patch.index);
895
+ const newNode = parseSingleRow(html, patch.index, liveParent);
952
896
  const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;
953
897
  liveParent.insertBefore(newNode, anchor);
954
898
  items.splice(patch.index, 0, {
@@ -975,7 +919,7 @@ function applySingleUpdate(liveParent, items, patch) {
975
919
  items[patch.index] = reuseBound(patch, html, oldEntry);
976
920
  return;
977
921
  }
978
- const newNode = parseSingleRow(html, patch.index);
922
+ const newNode = parseSingleRow(html, patch.index, liveParent);
979
923
  applyParsedRowUpdate(liveParent, items, patch, html, newNode);
980
924
  }
981
925
  function applyParsedRowUpdate(liveParent, items, patch, html, newNode) {
@@ -1028,11 +972,11 @@ function applyBulkUpdate(liveParent, items, patches, start, end) {
1028
972
  morphChanges.push({ patchIdx: k, html: p.html });
1029
973
  }
1030
974
  if (morphChanges.length === 0) return;
1031
- const { tpl, count } = parseRowTemplate(morphChanges.map((c) => c.html).join(""));
975
+ const { content, count } = parseRowTemplate(morphChanges.map((c) => c.html).join(""), liveParent);
1032
976
  if (count !== morphChanges.length) {
1033
- throw findOffendingChange(patches, morphChanges);
977
+ throw findOffendingChange(patches, morphChanges, liveParent);
1034
978
  }
1035
- const newNodes = collectTemplateChildren(tpl, morphChanges.length);
979
+ const newNodes = collectTemplateChildren(content, morphChanges.length);
1036
980
  for (let k = 0; k < morphChanges.length; k++) {
1037
981
  const c = morphChanges[k];
1038
982
  const p = patches[c.patchIdx];
@@ -1045,13 +989,13 @@ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
1045
989
  for (let k = start; k < end; k++) {
1046
990
  htmls[k - start] = patches[k].html;
1047
991
  }
1048
- const { tpl, count } = parseRowTemplate(htmls.join(""));
992
+ const { content, count } = parseRowTemplate(htmls.join(""), liveParent);
1049
993
  if (count !== htmls.length) {
1050
- throw findOffendingInsert(patches, start, htmls);
994
+ throw findOffendingInsert(patches, start, htmls, liveParent);
1051
995
  }
1052
- const newNodes = collectTemplateChildren(tpl, end - start);
996
+ const newNodes = collectTemplateChildren(content, end - start);
1053
997
  const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;
1054
- liveParent.insertBefore(tpl.content, anchor);
998
+ liveParent.insertBefore(content, anchor);
1055
999
  const newEntries = new Array(end - start);
1056
1000
  for (let k = 0; k < newEntries.length; k++) {
1057
1001
  const p = patches[start + k];
@@ -1067,18 +1011,18 @@ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
1067
1011
  }
1068
1012
  items.splice(startIdx, 0, ...newEntries);
1069
1013
  }
1070
- function findOffendingInsert(patches, start, htmls) {
1014
+ function findOffendingInsert(patches, start, htmls, liveParent) {
1071
1015
  for (let i = 0; i < htmls.length; i++) {
1072
- if (parseRowTemplate(htmls[i]).count !== 1) {
1073
- return rowContractError(patches[start + i].index, htmls[i]);
1016
+ if (parseRowTemplate(htmls[i], liveParent).count !== 1) {
1017
+ return rowContractError(patches[start + i].index, htmls[i], liveParent);
1074
1018
  }
1075
1019
  }
1076
1020
  return new Error("each(): bulk-insert mismatch with no per-row offender (kerf bug).");
1077
1021
  }
1078
- function findOffendingChange(patches, changes) {
1022
+ function findOffendingChange(patches, changes, liveParent) {
1079
1023
  for (const c of changes) {
1080
- if (parseRowTemplate(c.html).count !== 1) {
1081
- return rowContractError(patches[c.patchIdx].index, c.html);
1024
+ if (parseRowTemplate(c.html, liveParent).count !== 1) {
1025
+ return rowContractError(patches[c.patchIdx].index, c.html, liveParent);
1082
1026
  }
1083
1027
  }
1084
1028
  return new Error("each(): bulk-update mismatch with no per-row offender (kerf bug).");
@@ -1101,7 +1045,7 @@ function tryInPlaceContentUpdate(binding, listSeg) {
1101
1045
  }
1102
1046
  if (focusSnap !== null) restoreFocus(focusSnap);
1103
1047
  binding.items = newRecord;
1104
- maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1048
+ devHooks.missingRowKey?.(newRecord[0].node, newRecord[0].html, binding);
1105
1049
  return true;
1106
1050
  }
1107
1051
  function updateRowInPlace(liveParent, old, ni, index) {
@@ -1116,7 +1060,7 @@ function updateRowInPlace(liveParent, old, ni, index) {
1116
1060
  bindingDisposers: kept.bindingDisposers
1117
1061
  };
1118
1062
  }
1119
- const newNode = parseSingleRow(ni.html, index);
1063
+ const newNode = parseSingleRow(ni.html, index, liveParent);
1120
1064
  if (old.node.tagName === newNode.tagName) {
1121
1065
  _morphElement(old.node, newNode);
1122
1066
  const kept = carryOrRewireRowBindings(old.node, old.bindings, old.bindingDisposers, ni.bindings);
@@ -1148,14 +1092,14 @@ function reconcileSnapshot(binding, listSeg) {
1148
1092
  const { liveParent } = binding;
1149
1093
  const { newRecord, prevIdx, removedItems, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);
1150
1094
  const tailAnchor = endAnchor(binding);
1151
- buildFreshNodes(newRecord, freshIndices, freshHtmls);
1095
+ buildFreshNodes(newRecord, freshIndices, freshHtmls, liveParent);
1152
1096
  const focusSnap = captureFocus(liveParent);
1153
1097
  removeOldNodes(liveParent, removedItems);
1154
1098
  applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);
1155
1099
  if (focusSnap !== null) restoreFocus(focusSnap);
1156
1100
  binding.items = newRecord;
1157
1101
  if (newRecord.length > 0) {
1158
- maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1102
+ devHooks.missingRowKey?.(newRecord[0].node, newRecord[0].html, binding);
1159
1103
  }
1160
1104
  }
1161
1105
  function classifyItems(oldItems, listSeg) {
@@ -1196,13 +1140,13 @@ function classifyItems(oldItems, listSeg) {
1196
1140
  for (const [, orphan] of oldByRef) removedItems.push(orphan[0]);
1197
1141
  return { newRecord, prevIdx, removedItems, freshIndices, freshHtmls };
1198
1142
  }
1199
- function buildFreshNodes(newRecord, freshIndices, freshHtmls) {
1143
+ function buildFreshNodes(newRecord, freshIndices, freshHtmls, liveParent) {
1200
1144
  if (freshHtmls.length === 0) return;
1201
- const { tpl, count } = parseRowTemplate(freshHtmls.join(""));
1145
+ const { content, count } = parseRowTemplate(freshHtmls.join(""), liveParent);
1202
1146
  if (count !== freshHtmls.length) {
1203
- throw findOffendingRow(newRecord, freshIndices, freshHtmls);
1147
+ throw findOffendingRow(newRecord, freshIndices, freshHtmls, liveParent);
1204
1148
  }
1205
- let node = tpl.content.firstElementChild;
1149
+ let node = content.firstElementChild;
1206
1150
  for (const idx of freshIndices) {
1207
1151
  const next = node.nextElementSibling;
1208
1152
  const item = newRecord[idx];
@@ -1213,10 +1157,10 @@ function buildFreshNodes(newRecord, freshIndices, freshHtmls) {
1213
1157
  node = next;
1214
1158
  }
1215
1159
  }
1216
- function findOffendingRow(newRecord, freshIndices, freshHtmls) {
1160
+ function findOffendingRow(newRecord, freshIndices, freshHtmls, liveParent) {
1217
1161
  for (let i = 0; i < freshHtmls.length; i++) {
1218
- if (parseRowTemplate(freshHtmls[i]).count !== 1) {
1219
- return rowContractError(freshIndices[i], newRecord[freshIndices[i]].html);
1162
+ if (parseRowTemplate(freshHtmls[i], liveParent).count !== 1) {
1163
+ return rowContractError(freshIndices[i], newRecord[freshIndices[i]].html, liveParent);
1220
1164
  }
1221
1165
  }
1222
1166
  return new Error("each(): bulk-parse mismatch with no per-row offender (kerf bug).");
@@ -1325,12 +1269,17 @@ function mount(rootEl, render) {
1325
1269
  }
1326
1270
  assertNotInsideMountedTree(rootEl);
1327
1271
  setMounted(rootEl, true);
1328
- const listenerWarnObserver = installListenerRebuildWarn(rootEl);
1272
+ const listenerWarnObserver = devHooks.listenerRebuild?.(rootEl) ?? null;
1329
1273
  const bindings = /* @__PURE__ */ new Map();
1330
1274
  const renderCtx = {
1331
1275
  counter: 0,
1332
1276
  caches: /* @__PURE__ */ new Map(),
1333
- bindingCounts: /* @__PURE__ */ new Map()
1277
+ bindingCounts: /* @__PURE__ */ new Map(),
1278
+ bindingSources: /* @__PURE__ */ new Map(),
1279
+ keysThisRender: /* @__PURE__ */ new Set(),
1280
+ shiftCandidates: [],
1281
+ warnedShiftIds: /* @__PURE__ */ new Set(),
1282
+ rebuiltLists: /* @__PURE__ */ new Set()
1334
1283
  };
1335
1284
  const bindingCtx = newBindingContext();
1336
1285
  let bindingDisposers = [];
@@ -1338,28 +1287,43 @@ function mount(rootEl, render) {
1338
1287
  let isFirst = true;
1339
1288
  let prevStaticHtml = "";
1340
1289
  const valueOnlyWarnCtx = { warned: false };
1341
- const disposeEffect = effect(() => {
1290
+ const runRenderPass = () => {
1342
1291
  renderCtx.counter = 0;
1292
+ renderCtx.keysThisRender.clear();
1293
+ renderCtx.shiftCandidates.length = 0;
1343
1294
  bindingCtx.counter = 0;
1344
1295
  bindingCtx.list = [];
1345
1296
  _setRenderContext(renderCtx);
1346
1297
  _setBindingContext(bindingCtx);
1347
- let result;
1348
1298
  try {
1349
- result = render();
1299
+ return render();
1350
1300
  } finally {
1351
1301
  _setRenderContext(null);
1352
1302
  _setBindingContext(null);
1353
1303
  }
1354
- const segment = isSafeHtml(result) ? result.__segment ?? { kind: "static", html: result.__html } : { kind: "static", html: coerceRenderResult(result) };
1304
+ };
1305
+ const disposeEffect = effect(() => {
1306
+ let result = runRenderPass();
1307
+ const countChanged = renderCtx.previousCallCount !== void 0 && renderCtx.previousCallCount !== renderCtx.counter;
1308
+ if (countChanged) {
1309
+ for (const id of renderCtx.shiftCandidates) {
1310
+ if (renderCtx.warnedShiftIds.has(id)) continue;
1311
+ renderCtx.warnedShiftIds.add(id);
1312
+ devHooks.listIdShift?.(id);
1313
+ }
1314
+ _resetCallOrderListState(renderCtx);
1315
+ result = runRenderPass();
1316
+ }
1317
+ let segment = resultToSegment(result);
1355
1318
  if (isFirst) {
1356
1319
  runFirstRender(rootEl, segment, bindings);
1357
1320
  prevStaticHtml = flattenWithoutListItems(segment);
1321
+ devHooks.parserRepair?.(prevStaticHtml);
1358
1322
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1359
- if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1323
+ if (devHooks.staleBindingEnabled?.() === true) prevWiredBindings = bindingCtx.list;
1360
1324
  isFirst = false;
1361
1325
  } else {
1362
- const nextStaticHtml = runSubsequentRender(
1326
+ let nextStaticHtml = runSubsequentRender(
1363
1327
  rootEl,
1364
1328
  segment,
1365
1329
  bindings,
@@ -1367,14 +1331,28 @@ function mount(rootEl, render) {
1367
1331
  prevStaticHtml,
1368
1332
  valueOnlyWarnCtx
1369
1333
  );
1334
+ if (anyRebuiltListIsGranular(segment, renderCtx.rebuiltLists)) {
1335
+ for (const id of renderCtx.rebuiltLists) renderCtx.bindingCounts.delete(id);
1336
+ result = runRenderPass();
1337
+ segment = resultToSegment(result);
1338
+ nextStaticHtml = runSubsequentRender(
1339
+ rootEl,
1340
+ segment,
1341
+ bindings,
1342
+ renderCtx,
1343
+ prevStaticHtml,
1344
+ valueOnlyWarnCtx
1345
+ );
1346
+ }
1370
1347
  if (nextStaticHtml !== prevStaticHtml) {
1371
1348
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1372
- if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1349
+ if (devHooks.staleBindingEnabled?.() === true) prevWiredBindings = bindingCtx.list;
1373
1350
  } else {
1374
- maybeWarnStaleBinding(prevWiredBindings, bindingCtx.list);
1351
+ devHooks.staleBinding?.(prevWiredBindings, bindingCtx.list);
1375
1352
  }
1376
1353
  prevStaticHtml = nextStaticHtml;
1377
1354
  }
1355
+ const expectedCounts = devHooks.listInvariantsEnabled?.() === true ? /* @__PURE__ */ new Map() : null;
1378
1356
  for (const listSeg of collectLists(segment).values()) {
1379
1357
  const binding = bindings.get(listSeg.id);
1380
1358
  if (binding === void 0) {
@@ -1384,7 +1362,14 @@ function mount(rootEl, render) {
1384
1362
  }
1385
1363
  reconcileList(binding, listSeg);
1386
1364
  renderCtx.bindingCounts.set(listSeg.id, binding.items.length);
1365
+ renderCtx.bindingSources.set(listSeg.id, listSeg.source);
1366
+ expectedCounts?.set(
1367
+ listSeg.id,
1368
+ listSeg.patches !== void 0 && listSeg.source !== void 0 ? listSeg.source.value.length : listSeg.items.length
1369
+ );
1387
1370
  }
1371
+ renderCtx.previousCallCount = renderCtx.counter;
1372
+ devHooks.listInvariants?.(rootEl, bindings, expectedCounts ?? void 0);
1388
1373
  });
1389
1374
  return () => {
1390
1375
  disposeEffect();
@@ -1402,16 +1387,17 @@ function runFirstRender(rootEl, segment, bindings) {
1402
1387
  bindListsFromMarkers(rootEl, segment, bindings, true);
1403
1388
  }
1404
1389
  function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml, valueOnlyWarnCtx) {
1390
+ renderCtx.rebuiltLists.clear();
1405
1391
  const currentStaticHtml = flattenWithoutListItems(segment);
1406
1392
  if (currentStaticHtml === prevStaticHtml) {
1407
1393
  return prevStaticHtml;
1408
1394
  }
1409
- maybeWarnValueOnlyRerender(prevStaticHtml, currentStaticHtml, valueOnlyWarnCtx);
1395
+ devHooks.valueOnlyRerender?.(prevStaticHtml, currentStaticHtml, valueOnlyWarnCtx);
1410
1396
  cleanupOrphanBindings(segment, bindings, renderCtx);
1411
1397
  const template = rootEl.cloneNode(false);
1412
1398
  template.innerHTML = currentStaticHtml;
1413
1399
  morph(rootEl, template, collectOwnedItems(bindings));
1414
- bindListsFromMarkers(rootEl, segment, bindings, false);
1400
+ bindListsFromMarkers(rootEl, segment, bindings, false, renderCtx.rebuiltLists);
1415
1401
  return currentStaticHtml;
1416
1402
  }
1417
1403
  function coerceRenderResult(result) {
@@ -1419,21 +1405,44 @@ function coerceRenderResult(result) {
1419
1405
  if (result === false || result === true) return "";
1420
1406
  return String(result);
1421
1407
  }
1422
- function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
1408
+ function resultToSegment(result) {
1409
+ return isSafeHtml(result) ? result.__segment ?? { kind: "static", html: result.__html } : { kind: "static", html: coerceRenderResult(result) };
1410
+ }
1411
+ function anyRebuiltListIsGranular(segment, rebuilt) {
1412
+ if (rebuilt.size === 0) return false;
1413
+ const lists = collectLists(segment);
1414
+ for (const id of rebuilt) {
1415
+ if (lists.get(id)?.patches !== void 0) return true;
1416
+ }
1417
+ return false;
1418
+ }
1419
+ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems, rebuiltLists) {
1423
1420
  const lists = collectLists(segment);
1424
1421
  const found = [];
1425
1422
  collectComments(rootEl, found);
1426
1423
  for (const marker of found) {
1427
1424
  if (!marker.data.startsWith(LIST_MARKER_PREFIX)) continue;
1428
1425
  const id = marker.data.slice(LIST_MARKER_PREFIX.length);
1429
- if (bindings.has(id)) continue;
1426
+ const existing = bindings.get(id);
1427
+ if (existing !== void 0) {
1428
+ if (existing.marker === marker && rootEl.contains(existing.marker)) continue;
1429
+ for (const item of existing.items) {
1430
+ disposeRowBindings(item.bindingDisposers);
1431
+ if (rootEl.contains(item.node)) {
1432
+ item.node.parentElement?.removeChild(item.node);
1433
+ }
1434
+ }
1435
+ bindings.delete(id);
1436
+ rebuiltLists?.add(id);
1437
+ devHooks.listRebind?.(id, marker.parentElement);
1438
+ }
1430
1439
  const listSeg = lists.get(id);
1431
1440
  const liveParent = marker.parentElement;
1432
1441
  const items = [];
1433
1442
  if (inlinedItems) {
1434
1443
  let next = marker.nextElementSibling;
1435
1444
  for (let i = 0; i < listSeg.items.length && next !== null; i++) {
1436
- validateInlinedRowMatch(listSeg.items[i].html, i, next);
1445
+ validateInlinedRowMatch(listSeg.items[i].html, i, next, liveParent);
1437
1446
  const rowBindings = listSeg.items[i].bindings;
1438
1447
  const bound = {
1439
1448
  ref: listSeg.items[i].ref,
@@ -1451,17 +1460,25 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
1451
1460
  }
1452
1461
  const binding = { liveParent, items, marker };
1453
1462
  if (items.length > 0) {
1454
- maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
1463
+ devHooks.missingRowKey?.(items[0].node, items[0].html, binding);
1455
1464
  }
1456
- maybeWarnEachInMorphSkip(id, liveParent, rootEl);
1465
+ devHooks.eachInMorphSkip?.(id, liveParent, rootEl);
1457
1466
  bindings.set(id, binding);
1458
1467
  }
1459
1468
  }
1460
- function validateInlinedRowMatch(expectedHtml, index, boundEl) {
1469
+ function validateInlinedRowMatch(expectedHtml, index, boundEl, liveParent) {
1461
1470
  if (boundEl.outerHTML === expectedHtml) return;
1462
- const { count } = parseRowTemplate(expectedHtml);
1463
- if (count === 1) return;
1464
- throw rowContractError(index, expectedHtml);
1471
+ const { content, count } = parseRowTemplate(expectedHtml, liveParent);
1472
+ if (count !== 1) throw rowContractError(index, expectedHtml, liveParent);
1473
+ const expectedTag = content.firstElementChild.tagName;
1474
+ if (boundEl.tagName !== expectedTag) throw rowStructureError(index, boundEl.tagName, expectedTag);
1475
+ }
1476
+ function rowStructureError(index, gotTag, wantTag) {
1477
+ const got = gotTag.toLowerCase();
1478
+ const want = wantTag.toLowerCase();
1479
+ return new Error(
1480
+ `each(): row ${index} renders <${want}>, but the HTML parser wrapped the rows in <${got}> \u2014 so kerf cannot bind one row per element. This happens when an each() of <${want}> sits directly inside a table: the parser inserts <${got}> around the whole run. Put the each() inside an explicit <${got}> (e.g. <table><${got}>{each(...)}</${got}></table>) so the rows are the direct children kerf binds.`
1481
+ );
1465
1482
  }
1466
1483
  function collectOwnedItems(bindings) {
1467
1484
  const owned = /* @__PURE__ */ new Set();
@@ -1485,6 +1502,7 @@ function cleanupOrphanBindings(segment, bindings, renderCtx) {
1485
1502
  }
1486
1503
  bindings.delete(id);
1487
1504
  renderCtx.bindingCounts.delete(id);
1505
+ renderCtx.bindingSources.delete(id);
1488
1506
  renderCtx.caches.delete(id);
1489
1507
  }
1490
1508
  }