kerfjs 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/ai/cursorrules +11 -1
  3. package/ai/manifest.json +5 -5
  4. package/ai/skill.md +11 -1
  5. package/dist/array-signal.d.ts +7 -1
  6. package/dist/array-signal.js +11 -2
  7. package/dist/array-signal.js.map +1 -1
  8. package/dist/bindings-CYwoJpQb.d.ts +60 -0
  9. package/dist/chunk-3APBEVHF.js +20 -0
  10. package/dist/chunk-3APBEVHF.js.map +1 -0
  11. package/dist/chunk-GY4XV2UV.js +73 -0
  12. package/dist/chunk-GY4XV2UV.js.map +1 -0
  13. package/dist/{chunk-GYRZQCSY.js → chunk-JVVU2RQO.js} +11 -79
  14. package/dist/chunk-JVVU2RQO.js.map +1 -0
  15. package/dist/chunk-QIP723L4.js +15 -0
  16. package/dist/chunk-QIP723L4.js.map +1 -0
  17. package/dist/chunk-SAYPJ6XR.js +43 -0
  18. package/dist/chunk-SAYPJ6XR.js.map +1 -0
  19. package/dist/chunk-VVDJLWMP.js +14 -0
  20. package/dist/chunk-VVDJLWMP.js.map +1 -0
  21. package/dist/chunk-YHH7OUFA.js +58 -0
  22. package/dist/chunk-YHH7OUFA.js.map +1 -0
  23. package/dist/dev.d.ts +339 -0
  24. package/dist/dev.js +607 -0
  25. package/dist/dev.js.map +1 -0
  26. package/dist/html.d.ts +1 -0
  27. package/dist/html.js +4 -2
  28. package/dist/html.js.map +1 -1
  29. package/dist/index.d.ts +49 -11
  30. package/dist/index.js +351 -340
  31. package/dist/index.js.map +1 -1
  32. package/dist/jsx-runtime.d.ts +16 -60
  33. package/dist/jsx-runtime.js +4 -2
  34. package/dist/testing.js +3 -2
  35. package/llms.txt +2 -2
  36. package/package.json +22 -11
  37. package/dist/chunk-GYRZQCSY.js.map +0 -1
  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
package/dist/index.js CHANGED
@@ -1,8 +1,12 @@
1
- import { captureRowBindings, isSafeHtml, listSafeHtml, boundTextNodeOf, syncFormProp, newBindingContext, _setBindingContext, flattenWithoutListItems, wireBindings, collectLists, disposeRowBindings, flatten, LIST_MARKER_PREFIX, wireRowBindings, granularListSafeHtml, carryOrRewireRowBindings } from './chunk-GYRZQCSY.js';
2
- export { Fragment, SafeHtml, isSafeHtml, raw } from './chunk-GYRZQCSY.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,12 @@ 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
+ );
389
492
  if (matched.nodeType === COMMENT_NODE && fromChild !== null) {
390
493
  const owned = boundTextNodeOf(matched);
391
494
  if (owned !== null && fromChild === owned) {
@@ -393,6 +496,35 @@ function morphChildren(fromParent, toParent, ownedItems) {
393
496
  }
394
497
  }
395
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
+ }
527
+ }
396
528
  if (matched !== null) {
397
529
  morphNode(matched, toChild, ownedItems);
398
530
  } else {
@@ -499,144 +631,12 @@ function preserveTextEntryState(fromEl, toEl) {
499
631
  }
500
632
  }
501
633
 
502
- // src/dev-binding-warn.ts
503
- var warnedHoles = /* @__PURE__ */ new Set();
504
- function isOptedIn2() {
505
- if (!isDevMode()) return false;
506
- const proc = globalThis.process;
507
- return proc?.env?.KERF_DEV_WARN_STALE_BINDING === "1";
508
- }
509
- function describeHole(b) {
510
- return b.kind === "attr" ? `attr '${b.attr}' (id '${b.id}')` : `text (id '${b.id}')`;
511
- }
512
- function maybeWarnStaleBinding(prevWired, current) {
513
- if (!isOptedIn2()) return;
514
- const n = Math.min(prevWired.length, current.length);
515
- for (let i = 0; i < n; i++) {
516
- const cur = current[i];
517
- if (prevWired[i].signal === cur.signal) continue;
518
- if (warnedHoles.has(cur.id)) continue;
519
- warnedHoles.add(cur.id);
520
- console.warn(
521
- `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.`
522
- );
523
- }
524
- }
525
-
526
- // src/dev-listener-warn.ts
527
- var LISTENER_MARKER = /* @__PURE__ */ Symbol.for("kerfjs.devListener");
528
- var patched = false;
529
- var warned = false;
530
- function isOptedIn3() {
531
- if (!isDevMode()) return false;
532
- const proc = globalThis.process;
533
- return proc?.env?.KERF_DEV_WARN_REBUILT_LISTENERS === "1";
534
- }
535
- function findAddEventListenerProto() {
536
- const probe = document.createElement("div");
537
- let proto = Object.getPrototypeOf(probe);
538
- while (!Object.prototype.hasOwnProperty.call(proto, "addEventListener")) {
539
- proto = Object.getPrototypeOf(proto);
540
- }
541
- return proto;
542
- }
543
- function patchAddEventListenerOnce() {
544
- if (patched) return;
545
- patched = true;
546
- const proto = findAddEventListenerProto();
547
- const orig = proto.addEventListener;
548
- proto.addEventListener = function(type, listener, options) {
549
- if (this instanceof Element) {
550
- this[LISTENER_MARKER] = true;
551
- }
552
- return orig.call(this, type, listener, options);
553
- };
554
- }
555
- function hasMarkedListener(el) {
556
- if (el[LISTENER_MARKER] === true) return true;
557
- const stack = [];
558
- for (let i = 0; i < el.children.length; i++) stack.push(el.children[i]);
559
- while (stack.length > 0) {
560
- const cur = stack.pop();
561
- if (cur[LISTENER_MARKER] === true) return true;
562
- for (let i = 0; i < cur.children.length; i++) stack.push(cur.children[i]);
563
- }
564
- return false;
565
- }
566
- function emitWarning() {
567
- if (warned) return;
568
- warned = true;
569
- console.warn(
570
- "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."
571
- );
572
- }
573
- function installListenerRebuildWarn(rootEl) {
574
- if (!isOptedIn3()) return null;
575
- patchAddEventListenerOnce();
576
- const observer = new MutationObserver((mutations) => {
577
- if (warned) return;
578
- for (const m of mutations) {
579
- for (let i = 0; i < m.removedNodes.length; i++) {
580
- const removed = m.removedNodes[i];
581
- if (!(removed instanceof Element)) continue;
582
- if (hasMarkedListener(removed)) {
583
- emitWarning();
584
- return;
585
- }
586
- }
587
- }
588
- });
589
- observer.observe(rootEl, { childList: true, subtree: true });
590
- return observer;
591
- }
592
-
593
- // src/dev-rerender-warn.ts
594
- function isOptedIn4() {
595
- const proc = globalThis.process;
596
- if (proc?.env?.KERF_DEV_WARN_VALUE_ONLY_RERENDER !== "1") return false;
597
- return isDevMode();
598
- }
599
- var ELEMENT_NODE2 = 1;
600
- var TEXT_NODE2 = 3;
601
- var COMMENT_NODE2 = 8;
602
- function _isValueOnlyDiff(a, b) {
603
- const an = a.childNodes;
604
- const bn = b.childNodes;
605
- if (an.length !== bn.length) return false;
606
- for (let i = 0; i < an.length; i++) {
607
- const x = an[i];
608
- const y = bn[i];
609
- if (x.nodeType !== y.nodeType) return false;
610
- if (x.nodeType === ELEMENT_NODE2) {
611
- if (x.tagName !== y.tagName) return false;
612
- if (!_isValueOnlyDiff(x, y)) return false;
613
- } else if (x.nodeType === COMMENT_NODE2) {
614
- if (x.data !== y.data) return false;
615
- } else if (x.nodeType !== TEXT_NODE2) {
616
- return false;
617
- }
618
- }
619
- return true;
620
- }
621
- function maybeWarnValueOnlyRerender(prevHtml, nextHtml, ctx) {
622
- if (ctx.warned || !isOptedIn4()) return;
623
- const a = document.createElement("template");
624
- const b = document.createElement("template");
625
- a.innerHTML = prevHtml;
626
- b.innerHTML = nextHtml;
627
- if (!_isValueOnlyDiff(a.content, b.content)) return;
628
- ctx.warned = true;
629
- console.warn(
630
- "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."
631
- );
632
- }
633
-
634
634
  // src/list-binding.ts
635
635
  function endAnchor(binding) {
636
636
  if (binding.items.length > 0) {
637
- return binding.items[binding.items.length - 1].node.nextElementSibling;
637
+ return binding.items[binding.items.length - 1].node.nextSibling;
638
638
  }
639
- return binding.marker.nextElementSibling;
639
+ return binding.marker.nextSibling;
640
640
  }
641
641
 
642
642
  // src/list-reconcile-fast-paths.ts
@@ -647,8 +647,8 @@ var SQUOTE = 39;
647
647
  var AMP = 38;
648
648
  var EQ = 61;
649
649
  var SLASH = 47;
650
- var TEXT_NODE3 = 3;
651
- var ELEMENT_NODE3 = 1;
650
+ var TEXT_NODE2 = 3;
651
+ var ELEMENT_NODE2 = 1;
652
652
  function isWhitespace(cc) {
653
653
  return cc === 32 || cc === 9 || cc === 10 || cc === 13;
654
654
  }
@@ -673,12 +673,15 @@ function tryAttributeOnlyFastPath(liveNode, oldHtml, newHtml) {
673
673
  for (const [name, rawValue] of newTag.attrs) {
674
674
  const oldValue = oldTag.attrs.get(name);
675
675
  if (oldValue === rawValue) continue;
676
- liveNode.setAttribute(name, unescapeAttrValue(rawValue));
676
+ const value = unescapeAttrValue(rawValue);
677
+ liveNode.setAttribute(name, value);
678
+ syncFormProp(liveNode, name, value, true);
677
679
  }
678
680
  for (const name of oldTag.attrs.keys()) {
679
681
  if (newTag.attrs.has(name)) continue;
680
682
  if (isUserAgentOwnedAttr2(liveTagUpper, name)) continue;
681
683
  liveNode.removeAttribute(name);
684
+ syncFormProp(liveNode, name, "", false);
682
685
  }
683
686
  return true;
684
687
  }
@@ -713,6 +716,10 @@ function tryTextContentFastPath(liveNode, oldHtml, newHtml) {
713
716
  if (targetNode === null) return false;
714
717
  if (targetNode.nodeValue !== oldText) return false;
715
718
  targetNode.nodeValue = newText;
719
+ const host = targetNode.parentNode;
720
+ if (host !== null && host.tagName === "TEXTAREA" && host !== document.activeElement) {
721
+ host.value = newText;
722
+ }
716
723
  return true;
717
724
  }
718
725
  function containsDataMorphSkip(html) {
@@ -752,13 +759,13 @@ function nthTextNodeDescendant(root, n) {
752
759
  function walk(node) {
753
760
  for (let c = node.firstChild; c !== null; c = c.nextSibling) {
754
761
  if (result !== null) return;
755
- if (c.nodeType === TEXT_NODE3) {
762
+ if (c.nodeType === TEXT_NODE2) {
756
763
  if (count === n) {
757
764
  result = c;
758
765
  return;
759
766
  }
760
767
  count++;
761
- } else if (c.nodeType === ELEMENT_NODE3) {
768
+ } else if (c.nodeType === ELEMENT_NODE2) {
762
769
  walk(c);
763
770
  }
764
771
  }
@@ -818,76 +825,6 @@ function isUserAgentOwnedAttr2(tagNameUpper, name) {
818
825
  return name === "open" && (tagNameUpper === "DETAILS" || tagNameUpper === "DIALOG");
819
826
  }
820
827
 
821
- // src/list-reconcile-focus.ts
822
- function captureFocus(liveParent) {
823
- const active = document.activeElement;
824
- if (active === null || active === document.body) return null;
825
- if (!liveParent.contains(active)) return null;
826
- const el = active;
827
- let selStart = null;
828
- let selEnd = null;
829
- if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
830
- try {
831
- selStart = el.selectionStart;
832
- selEnd = el.selectionEnd;
833
- } catch {
834
- }
835
- }
836
- return { el, selStart, selEnd };
837
- }
838
- function restoreFocus(snap) {
839
- if (document.activeElement === snap.el) return;
840
- if (!snap.el.isConnected) return;
841
- snap.el.focus();
842
- if (snap.selStart !== null && snap.selEnd !== null) {
843
- try {
844
- snap.el.setSelectionRange(snap.selStart, snap.selEnd);
845
- } catch {
846
- }
847
- }
848
- }
849
-
850
- // src/utils/rowContract.ts
851
- var ROW_HTML_SNIPPET_MAX = 120;
852
- function truncateRowHtml(html) {
853
- return html.length > ROW_HTML_SNIPPET_MAX ? html.slice(0, ROW_HTML_SNIPPET_MAX) + "\u2026" : html;
854
- }
855
- function parseRowTemplate(html) {
856
- const tpl = document.createElement("template");
857
- tpl.innerHTML = html;
858
- return { tpl, count: tpl.content.children.length };
859
- }
860
- function parseSingleRow(html, index) {
861
- const { tpl, count } = parseRowTemplate(html);
862
- if (count !== 1) throw rowContractError(index, html);
863
- return tpl.content.firstElementChild;
864
- }
865
- function collectTemplateChildren(tpl, n) {
866
- const nodes = new Array(n);
867
- let child = tpl.content.firstElementChild;
868
- for (let k = 0; k < n; k++) {
869
- nodes[k] = child;
870
- child = child.nextElementSibling;
871
- }
872
- return nodes;
873
- }
874
- function rowContractError(index, html) {
875
- const { count } = parseRowTemplate(html);
876
- const reason = count === 0 ? "produced no top-level element" : `produced ${count} top-level elements; exactly one is required`;
877
- return new Error(
878
- `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))}`
879
- );
880
- }
881
- function maybeWarnMissingRowKey(rowEl, rowHtml, binding) {
882
- if (!isDevMode()) return;
883
- if (binding.warnedMissingKey === true) return;
884
- binding.warnedMissingKey = true;
885
- if (rowEl.id !== "" || rowEl.hasAttribute("data-key")) return;
886
- console.warn(
887
- `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))}`
888
- );
889
- }
890
-
891
828
  // src/list-reconcile-granular.ts
892
829
  function reconcileGranular(binding, patches) {
893
830
  const { liveParent } = binding;
@@ -950,12 +887,12 @@ function reconcileGranular(binding, patches) {
950
887
  }
951
888
  if (focusSnap !== null) restoreFocus(focusSnap);
952
889
  if (items.length > 0) {
953
- maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
890
+ devHooks.missingRowKey?.(items[0].node, items[0].html, binding);
954
891
  }
955
892
  }
956
893
  function applySingleInsert(liveParent, items, patch, tailAnchor) {
957
894
  const { html } = patch;
958
- const newNode = parseSingleRow(html, patch.index);
895
+ const newNode = parseSingleRow(html, patch.index, liveParent);
959
896
  const anchor = patch.index < items.length ? items[patch.index].node : tailAnchor;
960
897
  liveParent.insertBefore(newNode, anchor);
961
898
  items.splice(patch.index, 0, {
@@ -982,7 +919,7 @@ function applySingleUpdate(liveParent, items, patch) {
982
919
  items[patch.index] = reuseBound(patch, html, oldEntry);
983
920
  return;
984
921
  }
985
- const newNode = parseSingleRow(html, patch.index);
922
+ const newNode = parseSingleRow(html, patch.index, liveParent);
986
923
  applyParsedRowUpdate(liveParent, items, patch, html, newNode);
987
924
  }
988
925
  function applyParsedRowUpdate(liveParent, items, patch, html, newNode) {
@@ -1035,11 +972,11 @@ function applyBulkUpdate(liveParent, items, patches, start, end) {
1035
972
  morphChanges.push({ patchIdx: k, html: p.html });
1036
973
  }
1037
974
  if (morphChanges.length === 0) return;
1038
- const { tpl, count } = parseRowTemplate(morphChanges.map((c) => c.html).join(""));
975
+ const { content, count } = parseRowTemplate(morphChanges.map((c) => c.html).join(""), liveParent);
1039
976
  if (count !== morphChanges.length) {
1040
- throw findOffendingChange(patches, morphChanges);
977
+ throw findOffendingChange(patches, morphChanges, liveParent);
1041
978
  }
1042
- const newNodes = collectTemplateChildren(tpl, morphChanges.length);
979
+ const newNodes = collectTemplateChildren(content, morphChanges.length);
1043
980
  for (let k = 0; k < morphChanges.length; k++) {
1044
981
  const c = morphChanges[k];
1045
982
  const p = patches[c.patchIdx];
@@ -1052,13 +989,13 @@ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
1052
989
  for (let k = start; k < end; k++) {
1053
990
  htmls[k - start] = patches[k].html;
1054
991
  }
1055
- const { tpl, count } = parseRowTemplate(htmls.join(""));
992
+ const { content, count } = parseRowTemplate(htmls.join(""), liveParent);
1056
993
  if (count !== htmls.length) {
1057
- throw findOffendingInsert(patches, start, htmls);
994
+ throw findOffendingInsert(patches, start, htmls, liveParent);
1058
995
  }
1059
- const newNodes = collectTemplateChildren(tpl, end - start);
996
+ const newNodes = collectTemplateChildren(content, end - start);
1060
997
  const anchor = startIdx < items.length ? items[startIdx].node : tailAnchor;
1061
- liveParent.insertBefore(tpl.content, anchor);
998
+ liveParent.insertBefore(content, anchor);
1062
999
  const newEntries = new Array(end - start);
1063
1000
  for (let k = 0; k < newEntries.length; k++) {
1064
1001
  const p = patches[start + k];
@@ -1074,18 +1011,18 @@ function applyBulkInsert(liveParent, items, patches, start, end, tailAnchor) {
1074
1011
  }
1075
1012
  items.splice(startIdx, 0, ...newEntries);
1076
1013
  }
1077
- function findOffendingInsert(patches, start, htmls) {
1014
+ function findOffendingInsert(patches, start, htmls, liveParent) {
1078
1015
  for (let i = 0; i < htmls.length; i++) {
1079
- if (parseRowTemplate(htmls[i]).count !== 1) {
1080
- 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);
1081
1018
  }
1082
1019
  }
1083
1020
  return new Error("each(): bulk-insert mismatch with no per-row offender (kerf bug).");
1084
1021
  }
1085
- function findOffendingChange(patches, changes) {
1022
+ function findOffendingChange(patches, changes, liveParent) {
1086
1023
  for (const c of changes) {
1087
- if (parseRowTemplate(c.html).count !== 1) {
1088
- 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);
1089
1026
  }
1090
1027
  }
1091
1028
  return new Error("each(): bulk-update mismatch with no per-row offender (kerf bug).");
@@ -1108,7 +1045,7 @@ function tryInPlaceContentUpdate(binding, listSeg) {
1108
1045
  }
1109
1046
  if (focusSnap !== null) restoreFocus(focusSnap);
1110
1047
  binding.items = newRecord;
1111
- maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1048
+ devHooks.missingRowKey?.(newRecord[0].node, newRecord[0].html, binding);
1112
1049
  return true;
1113
1050
  }
1114
1051
  function updateRowInPlace(liveParent, old, ni, index) {
@@ -1123,7 +1060,7 @@ function updateRowInPlace(liveParent, old, ni, index) {
1123
1060
  bindingDisposers: kept.bindingDisposers
1124
1061
  };
1125
1062
  }
1126
- const newNode = parseSingleRow(ni.html, index);
1063
+ const newNode = parseSingleRow(ni.html, index, liveParent);
1127
1064
  if (old.node.tagName === newNode.tagName) {
1128
1065
  _morphElement(old.node, newNode);
1129
1066
  const kept = carryOrRewireRowBindings(old.node, old.bindings, old.bindingDisposers, ni.bindings);
@@ -1155,14 +1092,14 @@ function reconcileSnapshot(binding, listSeg) {
1155
1092
  const { liveParent } = binding;
1156
1093
  const { newRecord, prevIdx, removedItems, freshIndices, freshHtmls } = classifyItems(binding.items, listSeg);
1157
1094
  const tailAnchor = endAnchor(binding);
1158
- buildFreshNodes(newRecord, freshIndices, freshHtmls);
1095
+ buildFreshNodes(newRecord, freshIndices, freshHtmls, liveParent);
1159
1096
  const focusSnap = captureFocus(liveParent);
1160
1097
  removeOldNodes(liveParent, removedItems);
1161
1098
  applyMoves(liveParent, newRecord, prevIdx, lis(prevIdx), tailAnchor);
1162
1099
  if (focusSnap !== null) restoreFocus(focusSnap);
1163
1100
  binding.items = newRecord;
1164
1101
  if (newRecord.length > 0) {
1165
- maybeWarnMissingRowKey(newRecord[0].node, newRecord[0].html, binding);
1102
+ devHooks.missingRowKey?.(newRecord[0].node, newRecord[0].html, binding);
1166
1103
  }
1167
1104
  }
1168
1105
  function classifyItems(oldItems, listSeg) {
@@ -1203,13 +1140,13 @@ function classifyItems(oldItems, listSeg) {
1203
1140
  for (const [, orphan] of oldByRef) removedItems.push(orphan[0]);
1204
1141
  return { newRecord, prevIdx, removedItems, freshIndices, freshHtmls };
1205
1142
  }
1206
- function buildFreshNodes(newRecord, freshIndices, freshHtmls) {
1143
+ function buildFreshNodes(newRecord, freshIndices, freshHtmls, liveParent) {
1207
1144
  if (freshHtmls.length === 0) return;
1208
- const { tpl, count } = parseRowTemplate(freshHtmls.join(""));
1145
+ const { content, count } = parseRowTemplate(freshHtmls.join(""), liveParent);
1209
1146
  if (count !== freshHtmls.length) {
1210
- throw findOffendingRow(newRecord, freshIndices, freshHtmls);
1147
+ throw findOffendingRow(newRecord, freshIndices, freshHtmls, liveParent);
1211
1148
  }
1212
- let node = tpl.content.firstElementChild;
1149
+ let node = content.firstElementChild;
1213
1150
  for (const idx of freshIndices) {
1214
1151
  const next = node.nextElementSibling;
1215
1152
  const item = newRecord[idx];
@@ -1220,10 +1157,10 @@ function buildFreshNodes(newRecord, freshIndices, freshHtmls) {
1220
1157
  node = next;
1221
1158
  }
1222
1159
  }
1223
- function findOffendingRow(newRecord, freshIndices, freshHtmls) {
1160
+ function findOffendingRow(newRecord, freshIndices, freshHtmls, liveParent) {
1224
1161
  for (let i = 0; i < freshHtmls.length; i++) {
1225
- if (parseRowTemplate(freshHtmls[i]).count !== 1) {
1226
- 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);
1227
1164
  }
1228
1165
  }
1229
1166
  return new Error("each(): bulk-parse mismatch with no per-row offender (kerf bug).");
@@ -1332,12 +1269,17 @@ function mount(rootEl, render) {
1332
1269
  }
1333
1270
  assertNotInsideMountedTree(rootEl);
1334
1271
  setMounted(rootEl, true);
1335
- const listenerWarnObserver = installListenerRebuildWarn(rootEl);
1272
+ const listenerWarnObserver = devHooks.listenerRebuild?.(rootEl) ?? null;
1336
1273
  const bindings = /* @__PURE__ */ new Map();
1337
1274
  const renderCtx = {
1338
1275
  counter: 0,
1339
1276
  caches: /* @__PURE__ */ new Map(),
1340
- 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()
1341
1283
  };
1342
1284
  const bindingCtx = newBindingContext();
1343
1285
  let bindingDisposers = [];
@@ -1345,28 +1287,43 @@ function mount(rootEl, render) {
1345
1287
  let isFirst = true;
1346
1288
  let prevStaticHtml = "";
1347
1289
  const valueOnlyWarnCtx = { warned: false };
1348
- const disposeEffect = effect(() => {
1290
+ const runRenderPass = () => {
1349
1291
  renderCtx.counter = 0;
1292
+ renderCtx.keysThisRender.clear();
1293
+ renderCtx.shiftCandidates.length = 0;
1350
1294
  bindingCtx.counter = 0;
1351
1295
  bindingCtx.list = [];
1352
1296
  _setRenderContext(renderCtx);
1353
1297
  _setBindingContext(bindingCtx);
1354
- let result;
1355
1298
  try {
1356
- result = render();
1299
+ return render();
1357
1300
  } finally {
1358
1301
  _setRenderContext(null);
1359
1302
  _setBindingContext(null);
1360
1303
  }
1361
- 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);
1362
1318
  if (isFirst) {
1363
1319
  runFirstRender(rootEl, segment, bindings);
1364
1320
  prevStaticHtml = flattenWithoutListItems(segment);
1321
+ devHooks.parserRepair?.(prevStaticHtml);
1365
1322
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1366
- if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1323
+ if (devHooks.staleBindingEnabled?.() === true) prevWiredBindings = bindingCtx.list;
1367
1324
  isFirst = false;
1368
1325
  } else {
1369
- const nextStaticHtml = runSubsequentRender(
1326
+ let nextStaticHtml = runSubsequentRender(
1370
1327
  rootEl,
1371
1328
  segment,
1372
1329
  bindings,
@@ -1374,14 +1331,28 @@ function mount(rootEl, render) {
1374
1331
  prevStaticHtml,
1375
1332
  valueOnlyWarnCtx
1376
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
+ }
1377
1347
  if (nextStaticHtml !== prevStaticHtml) {
1378
1348
  bindingDisposers = wireBindings(rootEl, bindingCtx, bindingDisposers);
1379
- if (isOptedIn2()) prevWiredBindings = bindingCtx.list;
1349
+ if (devHooks.staleBindingEnabled?.() === true) prevWiredBindings = bindingCtx.list;
1380
1350
  } else {
1381
- maybeWarnStaleBinding(prevWiredBindings, bindingCtx.list);
1351
+ devHooks.staleBinding?.(prevWiredBindings, bindingCtx.list);
1382
1352
  }
1383
1353
  prevStaticHtml = nextStaticHtml;
1384
1354
  }
1355
+ const expectedCounts = devHooks.listInvariantsEnabled?.() === true ? /* @__PURE__ */ new Map() : null;
1385
1356
  for (const listSeg of collectLists(segment).values()) {
1386
1357
  const binding = bindings.get(listSeg.id);
1387
1358
  if (binding === void 0) {
@@ -1391,7 +1362,14 @@ function mount(rootEl, render) {
1391
1362
  }
1392
1363
  reconcileList(binding, listSeg);
1393
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
+ );
1394
1370
  }
1371
+ renderCtx.previousCallCount = renderCtx.counter;
1372
+ devHooks.listInvariants?.(rootEl, bindings, expectedCounts ?? void 0);
1395
1373
  });
1396
1374
  return () => {
1397
1375
  disposeEffect();
@@ -1409,16 +1387,17 @@ function runFirstRender(rootEl, segment, bindings) {
1409
1387
  bindListsFromMarkers(rootEl, segment, bindings, true);
1410
1388
  }
1411
1389
  function runSubsequentRender(rootEl, segment, bindings, renderCtx, prevStaticHtml, valueOnlyWarnCtx) {
1390
+ renderCtx.rebuiltLists.clear();
1412
1391
  const currentStaticHtml = flattenWithoutListItems(segment);
1413
1392
  if (currentStaticHtml === prevStaticHtml) {
1414
1393
  return prevStaticHtml;
1415
1394
  }
1416
- maybeWarnValueOnlyRerender(prevStaticHtml, currentStaticHtml, valueOnlyWarnCtx);
1395
+ devHooks.valueOnlyRerender?.(prevStaticHtml, currentStaticHtml, valueOnlyWarnCtx);
1417
1396
  cleanupOrphanBindings(segment, bindings, renderCtx);
1418
1397
  const template = rootEl.cloneNode(false);
1419
1398
  template.innerHTML = currentStaticHtml;
1420
1399
  morph(rootEl, template, collectOwnedItems(bindings));
1421
- bindListsFromMarkers(rootEl, segment, bindings, false);
1400
+ bindListsFromMarkers(rootEl, segment, bindings, false, renderCtx.rebuiltLists);
1422
1401
  return currentStaticHtml;
1423
1402
  }
1424
1403
  function coerceRenderResult(result) {
@@ -1426,21 +1405,44 @@ function coerceRenderResult(result) {
1426
1405
  if (result === false || result === true) return "";
1427
1406
  return String(result);
1428
1407
  }
1429
- 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) {
1430
1420
  const lists = collectLists(segment);
1431
1421
  const found = [];
1432
1422
  collectComments(rootEl, found);
1433
1423
  for (const marker of found) {
1434
1424
  if (!marker.data.startsWith(LIST_MARKER_PREFIX)) continue;
1435
1425
  const id = marker.data.slice(LIST_MARKER_PREFIX.length);
1436
- 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
+ }
1437
1439
  const listSeg = lists.get(id);
1438
1440
  const liveParent = marker.parentElement;
1439
1441
  const items = [];
1440
1442
  if (inlinedItems) {
1441
1443
  let next = marker.nextElementSibling;
1442
1444
  for (let i = 0; i < listSeg.items.length && next !== null; i++) {
1443
- validateInlinedRowMatch(listSeg.items[i].html, i, next);
1445
+ validateInlinedRowMatch(listSeg.items[i].html, i, next, liveParent);
1444
1446
  const rowBindings = listSeg.items[i].bindings;
1445
1447
  const bound = {
1446
1448
  ref: listSeg.items[i].ref,
@@ -1458,17 +1460,25 @@ function bindListsFromMarkers(rootEl, segment, bindings, inlinedItems) {
1458
1460
  }
1459
1461
  const binding = { liveParent, items, marker };
1460
1462
  if (items.length > 0) {
1461
- maybeWarnMissingRowKey(items[0].node, items[0].html, binding);
1463
+ devHooks.missingRowKey?.(items[0].node, items[0].html, binding);
1462
1464
  }
1463
- maybeWarnEachInMorphSkip(id, liveParent, rootEl);
1465
+ devHooks.eachInMorphSkip?.(id, liveParent, rootEl);
1464
1466
  bindings.set(id, binding);
1465
1467
  }
1466
1468
  }
1467
- function validateInlinedRowMatch(expectedHtml, index, boundEl) {
1469
+ function validateInlinedRowMatch(expectedHtml, index, boundEl, liveParent) {
1468
1470
  if (boundEl.outerHTML === expectedHtml) return;
1469
- const { count } = parseRowTemplate(expectedHtml);
1470
- if (count === 1) return;
1471
- 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
+ );
1472
1482
  }
1473
1483
  function collectOwnedItems(bindings) {
1474
1484
  const owned = /* @__PURE__ */ new Set();
@@ -1492,6 +1502,7 @@ function cleanupOrphanBindings(segment, bindings, renderCtx) {
1492
1502
  }
1493
1503
  bindings.delete(id);
1494
1504
  renderCtx.bindingCounts.delete(id);
1505
+ renderCtx.bindingSources.delete(id);
1495
1506
  renderCtx.caches.delete(id);
1496
1507
  }
1497
1508
  }