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.
- package/DOCS.md +1456 -0
- package/LICENCE.md +20 -0
- package/README.md +150 -0
- package/dist/index.min.js +1 -0
- package/package.json +53 -0
- package/src/bindings-blocks.js +363 -0
- package/src/bindings-events.js +176 -0
- package/src/bindings-loops.js +568 -0
- package/src/bindings-model.js +262 -0
- package/src/bindings-show.js +97 -0
- package/src/bindings.js +240 -0
- package/src/conditionals.js +153 -0
- package/src/errors.js +414 -0
- package/src/index.js +312 -0
- package/src/loops.js +117 -0
- package/src/partials.js +158 -0
- package/src/shared.js +103 -0
- package/src/store.js +265 -0
- package/src/template.js +263 -0
- package/src/utils.js +84 -0
|
@@ -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
|
+
}
|