lime-csr-js 0.1.4

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.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * @module bindings-loops
3
+ * Reactive <for data-live> — key-based diff with intelligent list updates.
4
+ *
5
+ * DIFFERENT MECHANISM from bindings-blocks.js's <if data-live>:
6
+ * - <if data-live>: when condition changes, the entire branch is TORN DOWN and rebuilt.
7
+ * - <for data-live>: elements with matching keys are PRESERVED (DOM identity —
8
+ * focus, input value, scroll, animation survive); only added/removed/moved
9
+ * elements are processed.
10
+ *
11
+ * NEW FILE (NOT added to bindings-blocks.js). Rationale:
12
+ * Different mechanism (tear-down/rebuild vs diff), different complexity,
13
+ * regression risk, and separation of concerns. loops.js — bindings-loops.js symmetry.
14
+ *
15
+ * KEY REQUIRED:
16
+ * data-live <for> MUST carry a key. If missing: errors.missingKey + not made
17
+ * reactive (stays empty — <for> removed). Avoid silent wrong behaviour.
18
+ *
19
+ * PLACEHOLDER PATTERN (same as bindings-blocks.js):
20
+ * <for data-live> → <!-- live-for:lf1 --> [element blocks] <!-- /live-for:lf1 -->
21
+ * Comment pair = fixed anchors. Content is kept in DOM order between them.
22
+ *
23
+ * DIFF STRATEGIES — data-diff="simple"|"lcs"|"replace" (default: simple):
24
+ * All three share the same delete step (cleanup FIRST, then DOM removal)
25
+ * and the same append fast-path (see below); they differ only in how
26
+ * SURVIVING (same-key) items are repositioned.
27
+ *
28
+ * - simple (default): forward pass, local in-place guard. For each item in
29
+ * new order, if it's already immediately after the last-placed item,
30
+ * leave it; otherwise move it to the end. Cheap to compute, correct, but
31
+ * only catches LOCALLY-adjacent no-op cases — a single item moved from
32
+ * the front to the back can cascade into moving everything after it.
33
+ * - lcs: computes the Longest Increasing Subsequence of survivors' old
34
+ * positions (in new order) — the maximal set of items whose RELATIVE
35
+ * order didn't change, which can all stay physically untouched. Only
36
+ * items outside that subsequence are moved. Same identity/preservation
37
+ * guarantees as simple, fewer DOM operations on non-trivial reorders.
38
+ * - replace: no diffing at all. Every existing block is torn down
39
+ * (cleanup + removal) and the list is rendered from scratch every time.
40
+ * Identity is NEVER preserved. Use for huge lists where per-item state
41
+ * doesn't matter and diff bookkeeping isn't worth it.
42
+ *
43
+ * Unknown data-diff value → errors.unknownDiffStrategy, falls back to simple.
44
+ *
45
+ * RECONCILE IMPROVEMENTS (simple + lcs; replace skips both by design):
46
+ * 1. In-place preservation: simple's local guard / lcs's LIS both avoid
47
+ * unnecessary insertBefore calls for items that don't need to move.
48
+ * 2. Append fast-path: if old keys are a prefix of new keys, skip the full
49
+ * diff and only append the new tail items (covers ~95% of chat/log
50
+ * scenarios, zero extra DOM work, shared by simple AND lcs).
51
+ *
52
+ * WHY AN EXPLICIT `orderedKeys` ARRAY (not `Array.from(keyedBlocks.keys())`):
53
+ * Map.set() on an ALREADY-PRESENT key does not change its iteration-order
54
+ * position — only a fresh key changes it. Since surviving items are never
55
+ * re-inserted into `keyedBlocks` on a plain reorder, the Map's own
56
+ * iteration order silently goes stale after any reorder-only reconcile
57
+ * (it keeps reflecting the OLD physical order, not the new one). This is
58
+ * harmless for the fast-path itself in isolation, but on a LATER reconcile
59
+ * it can make the append-prefix check compare against a stale order and
60
+ * wrongly conclude "this is just an append" when survivors also need
61
+ * repositioning — silently leaving them in the wrong place. `orderedKeys`
62
+ * is explicitly set to the just-established `newKeyOrder` at the end of
63
+ * every reconcile (all three strategies), so it always reflects reality.
64
+ * lcs also depends on this being accurate: it computes each survivor's
65
+ * OLD position from `orderedKeys` to find the longest increasing run.
66
+ *
67
+ * NOTE — considered and rejected: re-rendering a surviving key when its item
68
+ * object reference changes (Object.is(oldItem, newItem) === false). Rejected
69
+ * because most immutable-update patterns (sort/reverse/filter) build fresh
70
+ * object literals every time even when content is unchanged, which made this
71
+ * heuristic tear down and rebuild blocks on pure reordering — destroying DOM
72
+ * identity, focus, and in-progress input values. Reference identity is not a
73
+ * reliable proxy for "content changed."
74
+ *
75
+ * DOUBLE-BIND PREVENTION:
76
+ * setupBindings (bindings.js) skips the inside of live-for via
77
+ * closest('for[data-live]'). Content is bound only by renderFn's setupBindings.
78
+ *
79
+ * CLEANUP ORDER:
80
+ * Deleted element: block.cleanup() FIRST (cancel subscriptions) — THEN remove from DOM.
81
+ * Same rule as bindings-blocks.js: cleanup must not run on a detached node.
82
+ *
83
+ * EL="TAG" CONTAINER (2g, same pattern as bindings-blocks.js):
84
+ * <for data-live el="ul"> wraps the item blocks in a persistent <ul> element
85
+ * instead of bare comment anchors. The container itself is never recreated
86
+ * across reconciles — only its children are added/removed/moved — so any
87
+ * identity/state on the container itself (not just its items) survives.
88
+ * Non-reserved attributes (class, id, ...) on <for> are copied onto the
89
+ * container. Without el, the original comment-anchor behavior is unchanged.
90
+ *
91
+ * BLOCK-LEVEL HOOKS — data-after / data-before (per-ITEM, not per-list):
92
+ * <for data-live each="items" as="item" key="item.id" data-after="initRow" data-before="destroyRow">
93
+ * Same attribute-based, eval-free, name-lookup pattern as bindings-blocks.js.
94
+ * data-after -- called once a NEW item's nodes are placed in the DOM.
95
+ * Fires for every item on the INITIAL render too, and for
96
+ * every item added later (append fast-path, full diff, or
97
+ * every item under data-diff="replace").
98
+ * data-before -- called BEFORE a REMOVED item is torn down (synchronously,
99
+ * right before its cleanup() + DOM removal) -- its nodes
100
+ * are still attached to the DOM at call time.
101
+ * Handler signature: (itemRootNode, store). itemRootNode is the item's own
102
+ * first top-level ELEMENT node (template whitespace around it is skipped;
103
+ * el="tag" wraps the WHOLE LIST, not each item individually, so it has no
104
+ * bearing on the per-item hook root). null if the item has no element
105
+ * nodes at all.
106
+ * MOVED/reordered/stay-put survivors (simple's in-place guard, lcs's LIS,
107
+ * or a no-op position) never trigger either hook -- the item's node was
108
+ * never destroyed, so there's nothing to (re)initialize or tear down.
109
+ * Missing handler name -> errors.blockAfterNotFound/blockBeforeNotFound,
110
+ * warn and continue (no crash).
111
+ */
112
+
113
+ import { getByPath } from './store.js';
114
+ import { errors } from './errors.js';
115
+ import { inLiveBlock, longestIncreasingSubsequenceIndices } from './shared.js';
116
+
117
+ let forCounter = 0;
118
+ function nextForRef() { return `lf${++forCounter}`; }
119
+
120
+ /**
121
+ * Finds the first actual Element among a block's top-level nodes, for hook
122
+ * rootEl purposes. A block's nodes[0] is not reliably an Element: template
123
+ * whitespace (a newline/indentation before the item's own root tag) shows up
124
+ * as a leading Text node. `rootEl.getAttribute`/`querySelector`-style usage
125
+ * in a data-after/data-before handler requires a real Element.
126
+ *
127
+ * @param {Node[]} nodes
128
+ * @returns {Element|null}
129
+ */
130
+ function firstElementNode(nodes) {
131
+ return nodes.find((n) => n.nodeType === Node.ELEMENT_NODE) ?? null;
132
+ }
133
+
134
+ // 2g/data-diff/hooks: attributes that belong to the <for> element's own
135
+ // control surface — never copied onto the el="tag" container (everything
136
+ // else, e.g. class/id, is copied so the container can be styled/targeted like a normal element).
137
+ const RESERVED_FOR_ATTRS = new Set([
138
+ 'each', 'as', 'key', 'index', 'data-live', 'el', 'data-diff', 'data-after', 'data-before',
139
+ ]);
140
+
141
+ const VALID_DIFF_STRATEGIES = new Set(['simple', 'lcs', 'replace']);
142
+
143
+ /**
144
+ * Looks up `handlerName` in `handlers` and calls it with (rootEl, store).
145
+ * Missing name (or no handlers dictionary at all) -> dev-mode warning, no crash.
146
+ *
147
+ * @param {string|null} handlerName
148
+ * @param {Node|null} rootEl
149
+ * @param {import('./store.js').Store} store
150
+ * @param {Object<string, function(Node, import('./store.js').Store): void>|undefined} handlers
151
+ * @param {'after'|'before'} kind
152
+ */
153
+ function callBlockHook(handlerName, rootEl, store, handlers, kind) {
154
+ if (!handlerName) return;
155
+ const handler = handlers ? handlers[handlerName] : undefined;
156
+ if (typeof handler !== 'function') {
157
+ const available = handlers ? Object.keys(handlers) : [];
158
+ if (kind === 'after') errors.blockAfterNotFound(handlerName, available, rootEl);
159
+ else errors.blockBeforeNotFound(handlerName, available, rootEl);
160
+ return;
161
+ }
162
+ handler(rootEl, store);
163
+ }
164
+
165
+ /**
166
+ * Deep-clones a node list into a fresh DocumentFragment.
167
+ * @param {Node[]} nodes
168
+ * @returns {DocumentFragment}
169
+ */
170
+ function cloneToFragment(nodes) {
171
+ const frag = document.createDocumentFragment();
172
+ for (const node of nodes) frag.appendChild(node.cloneNode(true));
173
+ return frag;
174
+ }
175
+
176
+
177
+
178
+ /**
179
+ * Sets up every <for data-live> element inside root.
180
+ * Each one gets a key-based diff mechanism + a store subscription.
181
+ *
182
+ * Only the outermost live-fors are processed here; nested ones are handled
183
+ * by renderFn's own recursive call (render → setupLiveFors) per item.
184
+ *
185
+ * @param {Element|DocumentFragment} root
186
+ * @param {Object} context
187
+ * @param {import('./store.js').Store} store
188
+ * @param {function(DocumentFragment, Object, import('./store.js').Store, Object=): function(): void} renderFn
189
+ * @param {Object<string, function>=} handlers - For data-after/data-before lookups (see module JSDoc).
190
+ * @returns {function(): void} cleanup — cancels all subscriptions + block cleanups
191
+ */
192
+ export function setupLiveFors(root, context, store, renderFn, handlers) {
193
+ const allCleanups = [];
194
+
195
+ // Only the outermost live-fors: not nested inside another live-for or live-if.
196
+ // Nested ones are handled inside renderFn's recursive call (once per item).
197
+ const liveFors = Array.from(root.querySelectorAll('for[data-live]')).filter(
198
+ (el) =>
199
+ !inLiveBlock(el),
200
+ );
201
+
202
+ for (const forEl of liveFors) {
203
+ if (!root.contains(forEl)) continue;
204
+
205
+ const each = forEl.getAttribute('each');
206
+ const as = forEl.getAttribute('as');
207
+ const keyPath = forEl.getAttribute('key');
208
+ const indexAttr = forEl.getAttribute('index');
209
+
210
+ // Missing key — do NOT make reactive. loops.js already skipped this <for>
211
+ // (data-live filter); skip here too, but at least warn and remove it.
212
+ if (!keyPath) {
213
+ errors.missingKey(each ?? '?');
214
+ forEl.remove();
215
+ continue;
216
+ }
217
+
218
+ if (!each || !as) {
219
+ // Missing base attribute — loops.js already skipped this too; remove it.
220
+ forEl.remove();
221
+ continue;
222
+ }
223
+
224
+ // data-diff: which reconcile strategy to use. Unknown value -> warn + simple.
225
+ const diffAttr = forEl.getAttribute('data-diff');
226
+ let diffStrategy = 'simple';
227
+ if (diffAttr != null && diffAttr !== '') {
228
+ if (VALID_DIFF_STRATEGIES.has(diffAttr)) {
229
+ diffStrategy = diffAttr;
230
+ } else {
231
+ errors.unknownDiffStrategy(diffAttr, each);
232
+ }
233
+ }
234
+
235
+ // Hooks: attribute names (null if not set).
236
+ const afterHandlerName = forEl.getAttribute('data-after') || null;
237
+ const beforeHandlerName = forEl.getAttribute('data-before') || null;
238
+
239
+ const ref = nextForRef();
240
+ const startAnchor = document.createComment(`live-for:${ref}`);
241
+ const endAnchor = document.createComment(`/live-for:${ref}`);
242
+
243
+ // 2g: el="tag" -- optional container element wrapping all item blocks.
244
+ // If <for data-live el="ul"> is used, item blocks are placed inside a
245
+ // <ul> wrapper instead of being anchored directly between the comment
246
+ // pair. The container persists across reconciles (only its children
247
+ // change), so identity/state on the container itself is preserved.
248
+ const elTag = forEl.getAttribute('el') || null;
249
+ const container = elTag ? document.createElement(elTag) : null;
250
+
251
+ // 2g: copy any non-reserved attribute (class, id, data-testid, ...) from
252
+ // <for> onto the container, so it can be styled/targeted like a normal element.
253
+ if (container) {
254
+ for (const attr of Array.from(forEl.attributes)) {
255
+ if (!RESERVED_FOR_ATTRS.has(attr.name)) container.setAttribute(attr.name, attr.value);
256
+ }
257
+ }
258
+
259
+ // 2g: appends a node at the current end of the item sequence -- inside
260
+ // the container if el="tag" is used, otherwise right before endAnchor
261
+ // (the anchor plays the role container's "end" would otherwise play).
262
+ function appendNode(node) {
263
+ if (container) container.appendChild(node);
264
+ else endAnchor.parentNode.insertBefore(node, endAnchor);
265
+ }
266
+
267
+ // lcs: inserts a node right before a specific reference node (or at the
268
+ // end, if ref is null/endAnchor) -- used for the backward anchor-chained
269
+ // pass, where "where to insert" varies per item instead of always being
270
+ // "the current end."
271
+ function insertBeforeRef(node, ref) {
272
+ if (container) container.insertBefore(node, ref);
273
+ else endAnchor.parentNode.insertBefore(node, ref ?? endAnchor);
274
+ }
275
+
276
+ // Extract the template nodes while forEl is still in the DOM (before replaceWith).
277
+ const templateNodes = Array.from(forEl.childNodes).map((n) => n.cloneNode(true));
278
+
279
+ /**
280
+ * @typedef {{ nodes: Node[], cleanup: function(): void }} Block
281
+ * keyedBlocks: Map preserves insertion order; O(1) key lookup.
282
+ * Order information is rebuilt in newKeyOrder on each reconcile.
283
+ * @type {Map<*, Block>}
284
+ */
285
+ const keyedBlocks = new Map();
286
+
287
+ // The TRUE current key order, kept in sync after every reconcile — see
288
+ // the module JSDoc ("WHY AN EXPLICIT orderedKeys ARRAY") for why this
289
+ // can't just be `Array.from(keyedBlocks.keys())`.
290
+ let orderedKeys = [];
291
+
292
+ /**
293
+ * Renders a brand-new item and inserts its nodes via `insert`.
294
+ * @param {*} keyVal
295
+ * @param {*} item
296
+ * @param {number} idx
297
+ * @param {function(Node): void} insert
298
+ */
299
+ function mountNewItem(keyVal, item, idx, insert) {
300
+ const itemCtx = { ...context, [as]: item };
301
+ if (indexAttr) itemCtx[indexAttr] = idx;
302
+ const frag = cloneToFragment(templateNodes);
303
+ const cleanup = renderFn(frag, itemCtx, store, handlers);
304
+ const nodes = Array.from(frag.childNodes);
305
+ keyedBlocks.set(keyVal, { nodes, cleanup });
306
+ for (const node of nodes) insert(node);
307
+ // data-after: node(s) are now actually in the DOM (insert() above did a real DOM op).
308
+ callBlockHook(afterHandlerName, firstElementNode(nodes), store, handlers, 'after');
309
+ }
310
+
311
+ /**
312
+ * simple strategy: forward pass, local in-place guard (see module JSDoc).
313
+ * @param {Array<*>} newKeyOrder
314
+ * @param {Map<*, {item: *, idx: number}>} newItemMap
315
+ */
316
+ function reconcileSimple(newKeyOrder, newItemMap) {
317
+ let expectedPrev = container ? null : startAnchor; // expected preceding node for the in-place check
318
+
319
+ for (const keyVal of newKeyOrder) {
320
+ if (keyedBlocks.has(keyVal)) {
321
+ const block = keyedBlocks.get(keyVal);
322
+ const firstNode = block.nodes[0];
323
+ if (firstNode && firstNode.previousSibling === expectedPrev) {
324
+ // Already correct: just advance expectedPrev
325
+ expectedPrev = block.nodes[block.nodes.length - 1];
326
+ } else {
327
+ for (const node of block.nodes) appendNode(node);
328
+ expectedPrev = block.nodes[block.nodes.length - 1];
329
+ }
330
+ } else {
331
+ const { item, idx } = newItemMap.get(keyVal);
332
+ mountNewItem(keyVal, item, idx, appendNode);
333
+ const block = keyedBlocks.get(keyVal);
334
+ expectedPrev = block.nodes[block.nodes.length - 1];
335
+ }
336
+ }
337
+ }
338
+
339
+ /**
340
+ * lcs strategy: LIS-based minimal reorder (see module JSDoc).
341
+ * Must run BACKWARD (last item to first), anchoring each move on the
342
+ * node placed in the previous iteration -- a forward "always append to
343
+ * the end" pass (like simple's) is NOT correct here, because skipped
344
+ * (stay-put) items are deliberately left at their OLD physical position,
345
+ * which is not necessarily "the end" relative to items processed so far.
346
+ *
347
+ * @param {Array<*>} newKeyOrder
348
+ * @param {Map<*, {item: *, idx: number}>} newItemMap
349
+ */
350
+ function reconcileLcs(newKeyOrder, newItemMap) {
351
+ const oldIndexOf = new Map(orderedKeys.map((k, i) => [k, i]));
352
+
353
+ // Old-position sequence of survivors, in NEW order.
354
+ const survivorOldIdx = [];
355
+ const survivorNewPos = [];
356
+ newKeyOrder.forEach((keyVal, newPos) => {
357
+ if (keyedBlocks.has(keyVal)) {
358
+ survivorOldIdx.push(oldIndexOf.get(keyVal));
359
+ survivorNewPos.push(newPos);
360
+ }
361
+ });
362
+
363
+ const lisIndices = longestIncreasingSubsequenceIndices(survivorOldIdx);
364
+ const stayPutNewPositions = new Set();
365
+ for (const i of lisIndices) stayPutNewPositions.add(survivorNewPos[i]);
366
+
367
+ let anchor = null; // null means "insert at the end" (endAnchor / container append)
368
+
369
+ for (let newPos = newKeyOrder.length - 1; newPos >= 0; newPos--) {
370
+ const keyVal = newKeyOrder[newPos];
371
+
372
+ if (keyedBlocks.has(keyVal)) {
373
+ const block = keyedBlocks.get(keyVal);
374
+ if (!stayPutNewPositions.has(newPos)) {
375
+ // Inner loop goes FORWARD even though the outer loop is backward:
376
+ // each call inserts right before the SAME fixed `anchor`, so a
377
+ // forward pass naturally reproduces the nodes' original relative
378
+ // order (node 0, then node 1 lands between node 0 and anchor,
379
+ // etc.) -- a backward inner pass would reverse a multi-node
380
+ // block's internal order (e.g. surrounding whitespace text nodes
381
+ // ending up swapped with the element between them).
382
+ for (const node of block.nodes) insertBeforeRef(node, anchor);
383
+ }
384
+ anchor = block.nodes[0];
385
+ } else {
386
+ const { item, idx } = newItemMap.get(keyVal);
387
+ const itemCtx = { ...context, [as]: item };
388
+ if (indexAttr) itemCtx[indexAttr] = idx;
389
+ const frag = cloneToFragment(templateNodes);
390
+ const cleanup = renderFn(frag, itemCtx, store, handlers);
391
+ const nodes = Array.from(frag.childNodes);
392
+ keyedBlocks.set(keyVal, { nodes, cleanup });
393
+ for (const node of nodes) insertBeforeRef(node, anchor); // forward -- see note above
394
+ callBlockHook(afterHandlerName, firstElementNode(nodes), store, handlers, 'after');
395
+ anchor = nodes[0];
396
+ }
397
+ }
398
+ }
399
+
400
+ /**
401
+ * replace strategy: no diffing -- tear down every existing block and
402
+ * render the whole list from scratch, every reconcile. Identity is
403
+ * NEVER preserved; existing blocks' cleanup() always runs first.
404
+ *
405
+ * @param {Array} newList
406
+ */
407
+ function reconcileReplace(newList) {
408
+ for (const block of keyedBlocks.values()) {
409
+ // data-before: nodes are still attached at this point.
410
+ callBlockHook(beforeHandlerName, firstElementNode(block.nodes), store, handlers, 'before');
411
+ block.cleanup(); // subscriptions first (detached-node cleanup guard)
412
+ for (const node of block.nodes) node.parentNode?.removeChild(node);
413
+ }
414
+ keyedBlocks.clear();
415
+
416
+ const seenKeys = new Set();
417
+ for (let i = 0; i < newList.length; i++) {
418
+ const item = newList[i];
419
+ const itemCtx = { ...context, [as]: item };
420
+ if (indexAttr) itemCtx[indexAttr] = i;
421
+ const keyVal = getByPath(itemCtx, keyPath);
422
+
423
+ if (seenKeys.has(keyVal)) {
424
+ errors.duplicateKey(String(keyVal ?? ''), each);
425
+ continue;
426
+ }
427
+ seenKeys.add(keyVal);
428
+ mountNewItem(keyVal, item, i, appendNode);
429
+ }
430
+
431
+ orderedKeys = Array.from(keyedBlocks.keys());
432
+ }
433
+
434
+ /**
435
+ * Diffs the current blocks against a new list, dispatching to the
436
+ * configured diffStrategy (simple/lcs/replace).
437
+ *
438
+ * @param {Array} newList - New array from store.get(each)
439
+ */
440
+ function reconcile(newList) {
441
+ if (!Array.isArray(newList)) newList = [];
442
+
443
+ if (diffStrategy === 'replace') {
444
+ reconcileReplace(newList);
445
+ return;
446
+ }
447
+
448
+ // Build new key map (shared by simple + lcs)
449
+ const newKeyOrder = [];
450
+ const newKeySet = new Set();
451
+ const newItemMap = new Map();
452
+
453
+ for (let i = 0; i < newList.length; i++) {
454
+ const item = newList[i];
455
+ const itemCtx = { ...context, [as]: item };
456
+ if (indexAttr) itemCtx[indexAttr] = i;
457
+ const keyVal = getByPath(itemCtx, keyPath);
458
+
459
+ if (newKeySet.has(keyVal)) {
460
+ errors.duplicateKey(String(keyVal ?? ''), each);
461
+ continue;
462
+ }
463
+ newKeySet.add(keyVal);
464
+ newKeyOrder.push(keyVal);
465
+ newItemMap.set(keyVal, { item, idx: i });
466
+ }
467
+
468
+ // Append fast-path -- if old keys are a prefix of new keys, skip the
469
+ // full diff and only append the new tail items. Valid for simple AND
470
+ // lcs alike (a pure append never needs to move an existing survivor).
471
+ // Covers ~95% of chat/log append scenarios with zero extra DOM work.
472
+ if (orderedKeys.length <= newKeyOrder.length &&
473
+ orderedKeys.every((k, i) => newKeyOrder[i] === k)) {
474
+ const tailKeys = newKeyOrder.slice(orderedKeys.length);
475
+ for (const keyVal of tailKeys) {
476
+ const { item, idx } = newItemMap.get(keyVal);
477
+ mountNewItem(keyVal, item, idx, appendNode);
478
+ }
479
+ orderedKeys = newKeyOrder.slice();
480
+ return;
481
+ }
482
+
483
+ // Full diff path
484
+
485
+ // a. Delete removed blocks (shared by simple + lcs)
486
+ const toDelete = [];
487
+ for (const keyVal of keyedBlocks.keys()) {
488
+ if (!newKeySet.has(keyVal)) toDelete.push(keyVal);
489
+ }
490
+ for (const keyVal of toDelete) {
491
+ const block = keyedBlocks.get(keyVal);
492
+ // data-before: nodes are still attached at this point.
493
+ callBlockHook(beforeHandlerName, firstElementNode(block.nodes), store, handlers, 'before');
494
+ block.cleanup(); // subscriptions first (detached-node cleanup guard)
495
+ for (const node of block.nodes) node.parentNode?.removeChild(node);
496
+ keyedBlocks.delete(keyVal);
497
+ }
498
+
499
+ // b+c. Insert new, reorder all in new order (identity always preserved for survivors).
500
+ if (diffStrategy === 'lcs') {
501
+ reconcileLcs(newKeyOrder, newItemMap);
502
+ } else {
503
+ reconcileSimple(newKeyOrder, newItemMap);
504
+ }
505
+
506
+ orderedKeys = newKeyOrder.slice();
507
+ }
508
+
509
+ // Initial render
510
+ const initialList = Array.isArray(store.get(each)) ? store.get(each) : [];
511
+ const seenKeys = new Set();
512
+
513
+ for (let i = 0; i < initialList.length; i++) {
514
+ const item = initialList[i];
515
+ const itemCtx = { ...context, [as]: item };
516
+ if (indexAttr) itemCtx[indexAttr] = i;
517
+ const keyVal = getByPath(itemCtx, keyPath);
518
+
519
+ if (seenKeys.has(keyVal)) {
520
+ errors.duplicateKey(String(keyVal ?? ''), each);
521
+ continue;
522
+ }
523
+ seenKeys.add(keyVal);
524
+
525
+ const frag = cloneToFragment(templateNodes);
526
+ const cleanup = renderFn(frag, itemCtx, store, handlers);
527
+ const nodes = Array.from(frag.childNodes);
528
+ keyedBlocks.set(keyVal, { nodes, cleanup });
529
+ }
530
+
531
+ orderedKeys = Array.from(keyedBlocks.keys());
532
+
533
+ // Replace <for> with the anchor pair (+ container, if el="tag") + initial blocks.
534
+ const allInitialNodes = Array.from(keyedBlocks.values()).flatMap((b) => b.nodes);
535
+ if (container) {
536
+ // 2g: container mode -- initial blocks go inside the container; the
537
+ // anchor pair still brackets the container itself (consistency with
538
+ // bindings-blocks.js, and a stable insertion point if ever needed).
539
+ for (const node of allInitialNodes) container.appendChild(node);
540
+ forEl.replaceWith(startAnchor, container, endAnchor);
541
+ } else {
542
+ forEl.replaceWith(startAnchor, ...allInitialNodes, endAnchor);
543
+ }
544
+
545
+ // data-after also fires for each item on the initial render, now that
546
+ // every item is actually placed in the DOM -- matches bindings-blocks.js's
547
+ // initial-render behavior and covers "initialize a widget for the first
548
+ // items rendered on mount" use cases.
549
+ for (const block of keyedBlocks.values()) {
550
+ callBlockHook(afterHandlerName, firstElementNode(block.nodes), store, handlers, 'after');
551
+ }
552
+
553
+ // ── Subscription + cleanup registration ─────────────────────────────
554
+ allCleanups.push(store.subscribe(each, reconcile));
555
+
556
+ // This live-for's combined cleanup: cancels all block subscriptions.
557
+ // DOM removal is not done here — unmount or re-mount handles that.
558
+ allCleanups.push(() => {
559
+ for (const block of keyedBlocks.values()) block.cleanup();
560
+ keyedBlocks.clear();
561
+ });
562
+ }
563
+
564
+ return function cleanup() {
565
+ for (const unsub of allCleanups) unsub();
566
+ allCleanups.length = 0; // guard against double invocation
567
+ };
568
+ }