@ape-egg/vibe 1.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.
package/iterate.js ADDED
@@ -0,0 +1,451 @@
1
+ import parse from './parse.js';
2
+ import affected from './affected.js';
3
+ import hydrate from './hydrate.js';
4
+ import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
+
6
+ // Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
7
+ // See: _vibe-compiled-iteration-batch.js for implementation details
8
+ // This is a preview of what Vibe Compiled (Phase 2) will do automatically
9
+ import * as fastPath from './_vibe-compiled-iteration-batch.js';
10
+
11
+ /**
12
+ * Find a comment node with matching text content in the given nodes.
13
+ */
14
+ const findComment = (nodes, text) => {
15
+ for (let i = 0; i < nodes.length; i++) {
16
+ const node = nodes[i];
17
+ if (node.nodeType === 8 && node.textContent.trim() === text.trim()) {
18
+ return node;
19
+ }
20
+ }
21
+ return null;
22
+ };
23
+
24
+ /**
25
+ * Clone a parsed tree, mapping element references from original to cloned DOM.
26
+ * Walks both trees in lockstep - no map building needed since structure is identical.
27
+ */
28
+ const cloneTreeWithElements = (originalTree, clonedRoot) => {
29
+ if (!originalTree) return null;
30
+
31
+ const cloned = {
32
+ parsed: originalTree.parsed,
33
+ element: clonedRoot,
34
+ children: {},
35
+ ...(originalTree.attributes && { attributes: originalTree.attributes })
36
+ };
37
+
38
+ if (!originalTree.children) return cloned;
39
+
40
+ const clonedChildNodes = clonedRoot?.childNodes;
41
+
42
+ // Clone children recursively - use index from key to find cloned element
43
+ for (const key in originalTree.children) {
44
+ const child = originalTree.children[key];
45
+ if (!child || typeof child !== 'object') continue;
46
+
47
+ // Handle conditional nodes - need to update comment references
48
+ if (child.type === 'conditional' && clonedChildNodes) {
49
+ const { startComment, elseComment, endComment, branches } = child.meta;
50
+
51
+ // Find corresponding comments in cloned DOM
52
+ const clonedStart = findComment(clonedChildNodes, startComment.textContent);
53
+ const clonedElse = elseComment ? findComment(clonedChildNodes, elseComment.textContent) : null;
54
+ const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
55
+
56
+ cloned.children[key] = {
57
+ type: 'conditional',
58
+ meta: {
59
+ expression: child.meta.expression,
60
+ startComment: clonedStart || startComment,
61
+ elseComment: clonedElse,
62
+ endComment: clonedEnd || endComment,
63
+ branches // Branch templates are reused (they're cloned during mount)
64
+ },
65
+ runtime: {
66
+ activeBranch: undefined,
67
+ activeInstance: null,
68
+ templateRemoved: false
69
+ },
70
+ children: {}
71
+ };
72
+ continue;
73
+ }
74
+
75
+ // Handle iteration nodes - need to update comment references and fresh runtime
76
+ if (child.type === 'iteration' && clonedChildNodes) {
77
+ const { startComment, endComment, template } = child.meta;
78
+
79
+ // Find corresponding comments in cloned DOM
80
+ const clonedStart = findComment(clonedChildNodes, startComment.textContent);
81
+ const clonedEnd = findComment(clonedChildNodes, endComment.textContent);
82
+
83
+ cloned.children[key] = {
84
+ type: 'iteration',
85
+ meta: {
86
+ arrayPath: child.meta.arrayPath,
87
+ itemAlias: child.meta.itemAlias,
88
+ indexAlias: child.meta.indexAlias,
89
+ startComment: clonedStart || startComment,
90
+ endComment: clonedEnd || endComment,
91
+ template // Template is reused (cloned during render)
92
+ },
93
+ runtime: {
94
+ instances: [],
95
+ templateRemoved: false
96
+ },
97
+ children: {}
98
+ };
99
+ continue;
100
+ }
101
+
102
+ // Extract index from key (format: "nodename_index") - avoid regex for speed
103
+ const underscoreIdx = key.lastIndexOf('_');
104
+ const childIndex = underscoreIdx >= 0 ? parseInt(key.slice(underscoreIdx + 1), 10) : -1;
105
+ const clonedChild = childIndex >= 0 && clonedChildNodes ? clonedChildNodes[childIndex] : null;
106
+
107
+ cloned.children[key] = cloneTreeWithElements(child, clonedChild);
108
+ }
109
+
110
+ return cloned;
111
+ };
112
+
113
+ /**
114
+ * Initialize a block instance from template nodes.
115
+ * Uses cached template tree when available for speed.
116
+ *
117
+ * @param {NodeList} templateNodes - Template nodes to clone
118
+ * @param {Object} scopedState - Scoped state (with localVars/parentScope already applied)
119
+ * @param {Object} cachedTree - Optional cached parsed tree from template
120
+ * @returns {Object} { element, tree, clonedNodes }
121
+ */
122
+ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) => {
123
+ let tree;
124
+ let clonedNodes = [];
125
+ let firstElement = null;
126
+
127
+ // Fast path: clone cached tree with new element references
128
+ if (cachedTree) {
129
+ // Clone nodes directly
130
+ for (let i = 0; i < templateNodes.length; i++) {
131
+ const cloned = templateNodes[i].cloneNode(true);
132
+ clonedNodes.push(cloned);
133
+ if (!firstElement && cloned.nodeType === 1) {
134
+ firstElement = cloned;
135
+ }
136
+ }
137
+ // Use virtual root to match template structure (avoids creating DOM container)
138
+ // cloneTreeWithElements only needs childNodes property
139
+ tree = cloneTreeWithElements(cachedTree, { childNodes: clonedNodes });
140
+ } else {
141
+ // Slow path: clone and parse from scratch
142
+ for (let i = 0; i < templateNodes.length; i++) {
143
+ const cloned = templateNodes[i].cloneNode(true);
144
+ clonedNodes.push(cloned);
145
+ if (!firstElement && cloned.nodeType === 1) {
146
+ firstElement = cloned;
147
+ }
148
+ }
149
+
150
+ // Fallback for text-only templates
151
+ if (!firstElement && clonedNodes.length > 0) {
152
+ const container = document.createElement('span');
153
+ clonedNodes.forEach(node => container.appendChild(node.cloneNode(true)));
154
+ firstElement = container;
155
+ }
156
+
157
+ tree = firstElement ? parse(firstElement) : null;
158
+ }
159
+
160
+ if (tree) {
161
+ const affectedElements = affected(tree, {}, scopedState);
162
+ hydrate(affectedElements, scopedState);
163
+ }
164
+
165
+ return {
166
+ element: firstElement,
167
+ tree,
168
+ clonedNodes
169
+ };
170
+ };
171
+
172
+ // Create a proxied state with scoped variables (item, index, array)
173
+ export const createScopedState = (globalState, localVars, parentScope = {}) => {
174
+ return new Proxy(globalState, {
175
+ get(target, prop) {
176
+ // 1. Check local scope first (item, index, array)
177
+ if (prop in localVars) {
178
+ return localVars[prop];
179
+ }
180
+
181
+ // 2. Check parent scope (for nested iterations)
182
+ if (prop in parentScope) {
183
+ return parentScope[prop];
184
+ }
185
+
186
+ // 3. Fall back to global state
187
+ return Reflect.get(target, prop);
188
+ },
189
+
190
+ set(target, prop, value) {
191
+ // Only allow setting global state, not local vars
192
+ if (prop in localVars) {
193
+ console.warn(`Cannot modify iteration variable '${prop}'`);
194
+ return false;
195
+ }
196
+ if (prop in parentScope) {
197
+ console.warn(`Cannot modify parent iteration variable '${prop}'`);
198
+ return false;
199
+ }
200
+ return Reflect.set(target, prop, value);
201
+ },
202
+
203
+ ownKeys(target) {
204
+ // Return all keys: local vars, parent scope, and global state
205
+ const localKeys = Object.keys(localVars);
206
+ const parentKeys = Object.keys(parentScope);
207
+ const globalKeys = Reflect.ownKeys(target);
208
+ return [...new Set([...localKeys, ...parentKeys, ...globalKeys])];
209
+ },
210
+
211
+ has(target, prop) {
212
+ // Check if property exists in local scope, parent scope, or global state
213
+ return prop in localVars || prop in parentScope || Reflect.has(target, prop);
214
+ },
215
+
216
+ getOwnPropertyDescriptor(target, prop) {
217
+ // Provide property descriptor for local vars and parent scope
218
+ if (prop in localVars) {
219
+ return { configurable: true, enumerable: true, value: localVars[prop] };
220
+ }
221
+ if (prop in parentScope) {
222
+ return { configurable: true, enumerable: true, value: parentScope[prop] };
223
+ }
224
+ return Reflect.getOwnPropertyDescriptor(target, prop);
225
+ }
226
+ });
227
+ };
228
+
229
+ // Render all iterations in the parsed tree
230
+ export const renderAllIterations = (tree, state, linkList, parentScope = {}) => {
231
+ // If this is an iteration node, render it
232
+ if (tree.type === 'iteration') {
233
+ renderIteration(tree, state, linkList, parentScope);
234
+ return;
235
+ }
236
+
237
+ // Recursively render iterations in child nodes
238
+ if (tree.children) {
239
+ for (const key in tree.children) {
240
+ const child = tree.children[key];
241
+ if (child && typeof child === 'object') {
242
+ renderAllIterations(child, state, linkList, parentScope);
243
+ }
244
+ }
245
+ }
246
+ };
247
+
248
+ // Callback for rendering conditionals - set by conditionals.js to avoid circular import
249
+ let _renderAllConditionals = () => {};
250
+ export const setRenderAllConditionals = (fn) => { _renderAllConditionals = fn; };
251
+
252
+ // Initial render of an iteration block
253
+ export const renderIteration = (iterationNode, state, linkList, parentScope = {}) => {
254
+ const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
255
+
256
+ // Already rendered - updates go through updateIteration
257
+ if (iterationNode.runtime.instances?.length > 0) return;
258
+
259
+ // Remove template nodes from DOM on first render
260
+ if (!iterationNode.runtime.templateRemoved) {
261
+ let node = startComment.nextSibling;
262
+ while (node && node !== endComment) {
263
+ const next = node.nextSibling;
264
+ node.parentNode?.removeChild(node);
265
+ node = next;
266
+ }
267
+ iterationNode.runtime.templateRemoved = true;
268
+ }
269
+
270
+ const parent = startComment.parentNode;
271
+
272
+ const array = resolvePath(state, arrayPath);
273
+ if (!Array.isArray(array) || array.length === 0) {
274
+ iterationNode.runtime.instances = [];
275
+ return;
276
+ }
277
+
278
+ // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
279
+ if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template)) {
280
+ fastPath.renderFast(iterationNode, array, state, parent, endComment);
281
+ return;
282
+ }
283
+
284
+ // Standard path: clone and hydrate each item (handles nested iterations/conditionals)
285
+ const instances = [];
286
+ const templateNodes = [...template.element.childNodes];
287
+
288
+ for (let i = 0; i < array.length; i++) {
289
+ const item = array[i];
290
+ const localVars = { [itemAlias]: item, [indexAlias]: i };
291
+ const scopedState = createScopedState(state, localVars, parentScope);
292
+
293
+ // Clone, parse, hydrate
294
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
295
+
296
+ // Insert cloned nodes
297
+ clonedNodes.forEach(node => parent.insertBefore(node, endComment));
298
+
299
+ // Recursively render nested iterations and conditionals
300
+ if (tree) {
301
+ const nestedScope = { ...parentScope, ...localVars };
302
+ renderAllIterations(tree, scopedState, linkList, nestedScope);
303
+ _renderAllConditionals(tree, scopedState, linkList, nestedScope);
304
+ }
305
+
306
+ instances.push({ element, tree, item, index: i, clonedNodes });
307
+ }
308
+
309
+ iterationNode.runtime.instances = instances;
310
+ };
311
+
312
+ // Update an iteration block when array changes
313
+ export const updateIteration = (iterationNode, newState, oldState, linkList, parentScope = {}) => {
314
+ if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
315
+
316
+ const { arrayPath, template, startComment, endComment } = iterationNode.meta;
317
+ const oldArray = resolvePath(oldState, arrayPath) || [];
318
+ const newArray = resolvePath(newState, arrayPath) || [];
319
+
320
+ // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
321
+ // Use for bulk operations (large arrays or empty→full transitions)
322
+ const isEmptyToFull = oldArray.length === 0 && newArray.length > 0;
323
+ const isFullToEmpty = oldArray.length > 0 && newArray.length === 0;
324
+ const isLargeArray = newArray.length > 100 || oldArray.length > 100;
325
+
326
+ if (window.__VIBE_FAST_ITERATION__ && fastPath.canUseFastPath(template) && (isEmptyToFull || isFullToEmpty || isLargeArray)) {
327
+ fastPath.updateFast(iterationNode, newArray, newState, startComment, endComment);
328
+ return;
329
+ }
330
+
331
+ // Standard diff-based updates
332
+ const oldKeys = oldArray.map((item, i) => getItemKey(item, i));
333
+ const newKeys = newArray.map((item, i) => getItemKey(item, i));
334
+ const operations = computeDiff(oldKeys, newKeys, oldArray, newArray);
335
+
336
+ operations.forEach(op => {
337
+ switch (op.type) {
338
+ case 'REMOVE': removeInstance(iterationNode, op.index); break;
339
+ case 'ADD': addInstance(iterationNode, op.item, op.index, newState, linkList, parentScope); break;
340
+ case 'MOVE': moveInstance(iterationNode, op.from, op.to); break;
341
+ case 'UPDATE': updateInstance(iterationNode, op.index, op.item, newState, linkList, parentScope); break;
342
+ }
343
+ });
344
+
345
+ iterationNode.runtime.instances.forEach((inst, i) => { inst.index = i; });
346
+ };
347
+
348
+ // Add a new instance at the specified index
349
+ const addInstance = (iterationNode, item, index, state, linkList, parentScope) => {
350
+ const { itemAlias, indexAlias, template, startComment, endComment } = iterationNode.meta;
351
+
352
+ const localVars = { [itemAlias]: item, [indexAlias]: index };
353
+ const scopedState = createScopedState(state, localVars, parentScope);
354
+ const templateNodes = [...template.element.childNodes];
355
+
356
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
357
+
358
+ // Find insertion point
359
+ const insertBefore = index < iterationNode.runtime.instances.length
360
+ ? iterationNode.runtime.instances[index].clonedNodes?.[0] || iterationNode.runtime.instances[index].element
361
+ : endComment;
362
+
363
+ // Insert cloned nodes
364
+ clonedNodes.forEach(node => startComment.parentNode.insertBefore(node, insertBefore));
365
+
366
+ // Recursively render nested iterations and conditionals
367
+ if (tree) {
368
+ const nestedScope = { ...parentScope, ...localVars };
369
+ renderAllIterations(tree, scopedState, linkList, nestedScope);
370
+ _renderAllConditionals(tree, scopedState, linkList, nestedScope);
371
+ }
372
+
373
+ iterationNode.runtime.instances.splice(index, 0, { element, tree, item, index, clonedNodes });
374
+ };
375
+
376
+ // Remove an instance at the specified index
377
+ const removeInstance = (iterationNode, index) => {
378
+ if (index < 0 || index >= iterationNode.runtime.instances.length) return;
379
+
380
+ const instance = iterationNode.runtime.instances[index];
381
+
382
+ // Remove all cloned nodes from DOM
383
+ (instance.clonedNodes || [instance.element]).forEach(node => node?.parentNode?.removeChild(node));
384
+
385
+ iterationNode.runtime.instances.splice(index, 1);
386
+ };
387
+
388
+ // Move an instance from one position to another
389
+ const moveInstance = (iterationNode, fromIndex, toIndex) => {
390
+ if (fromIndex === toIndex) return;
391
+ if (fromIndex < 0 || fromIndex >= iterationNode.runtime.instances.length) return;
392
+ if (toIndex < 0 || toIndex >= iterationNode.runtime.instances.length) return;
393
+
394
+ const instance = iterationNode.runtime.instances[fromIndex];
395
+ const nodes = instance.clonedNodes || [instance.element];
396
+ const parent = nodes[0]?.parentNode;
397
+
398
+ iterationNode.runtime.instances.splice(fromIndex, 1);
399
+ iterationNode.runtime.instances.splice(toIndex, 0, instance);
400
+
401
+ // Find new insertion point
402
+ const nextInstance = iterationNode.runtime.instances[toIndex + 1];
403
+ const insertBefore = nextInstance
404
+ ? (nextInstance.clonedNodes?.[0] || nextInstance.element)
405
+ : iterationNode.meta.endComment;
406
+
407
+ // Move all nodes
408
+ nodes.forEach(node => parent.insertBefore(node, insertBefore));
409
+ };
410
+
411
+ // Update an instance with new item data
412
+ const updateInstance = (iterationNode, index, newItem, state, linkList, parentScope = {}) => {
413
+ if (index < 0 || index >= iterationNode.runtime.instances.length) return;
414
+
415
+ const { itemAlias, indexAlias, template } = iterationNode.meta;
416
+ const instance = iterationNode.runtime.instances[index];
417
+
418
+ const localVars = { [itemAlias]: newItem, [indexAlias]: index };
419
+ const scopedState = createScopedState(state, localVars, parentScope);
420
+ const templateNodes = [...template.element.childNodes];
421
+
422
+ const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template);
423
+
424
+ // Remove old nodes, insert new ones in same position
425
+ const oldNodes = instance.clonedNodes || [instance.element];
426
+ const insertBefore = oldNodes[oldNodes.length - 1]?.nextSibling;
427
+ const parent = oldNodes[0]?.parentNode;
428
+
429
+ oldNodes.forEach(node => node?.parentNode?.removeChild(node));
430
+ clonedNodes.forEach(node => parent.insertBefore(node, insertBefore));
431
+
432
+ // Recursively render nested iterations and conditionals
433
+ if (tree) {
434
+ const nestedScope = { ...parentScope, ...localVars };
435
+ renderAllIterations(tree, scopedState, linkList, nestedScope);
436
+ _renderAllConditionals(tree, scopedState, linkList, nestedScope);
437
+ }
438
+
439
+ // Update instance
440
+ instance.element = element;
441
+ instance.tree = tree;
442
+ instance.item = newItem;
443
+ instance.clonedNodes = clonedNodes;
444
+ };
445
+
446
+ export default {
447
+ renderIteration,
448
+ updateIteration,
449
+ renderAllIterations,
450
+ createScopedState
451
+ };
@@ -0,0 +1,261 @@
1
+ // Utility functions for array iteration
2
+ import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
3
+
4
+ // Resolve nested paths in state (e.g., "user.items" -> state.user.items)
5
+ export const resolvePath = (obj, path) => {
6
+ if (!path || !obj) return undefined;
7
+ return path.split(".").reduce((acc, part) => acc?.[part], obj);
8
+ };
9
+
10
+ // Clone template element preserving structure
11
+ export const cloneTemplate = (templateElement) => {
12
+ return templateElement.cloneNode(true);
13
+ };
14
+
15
+ // Find matching <!-- /each --> comment with depth tracking
16
+ export const findEndComment = (nodes, startIndex) => {
17
+ let depth = 1;
18
+ for (let i = startIndex; i < nodes.length; i++) {
19
+ if (nodes[i].nodeName === "#comment") {
20
+ const text = nodes[i].textContent.trim();
21
+ if (ITERATION_START_REGEX.test(text)) {
22
+ depth++;
23
+ } else if (text === "/each") {
24
+ depth--;
25
+ if (depth === 0) {
26
+ return i;
27
+ }
28
+ }
29
+ }
30
+ }
31
+ throw new Error("Unmatched <!-- each --> comment: missing <!-- /each -->");
32
+ };
33
+
34
+ // Find matching <!-- /if --> and optional <!-- else --> with depth tracking
35
+ // Returns { elseIndex: number | null, endIndex: number }
36
+ export const findConditionalEnd = (nodes, startIndex) => {
37
+ let depth = 1;
38
+ let elseIndex = null;
39
+
40
+ for (let i = startIndex; i < nodes.length; i++) {
41
+ if (nodes[i].nodeName === "#comment") {
42
+ const text = nodes[i].textContent.trim();
43
+
44
+ // Check for nested if
45
+ if (CONDITIONAL_START_REGEX.test(text)) {
46
+ depth++;
47
+ }
48
+ // Check for else at current depth
49
+ else if (text === "else" && depth === 1 && elseIndex === null) {
50
+ elseIndex = i;
51
+ }
52
+ // Check for /if
53
+ else if (text === "/if") {
54
+ depth--;
55
+ if (depth === 0) {
56
+ return { elseIndex, endIndex: i };
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ throw new Error("Unmatched <!-- if --> comment: missing <!-- /if -->");
63
+ };
64
+
65
+ // Generate stable hash for objects
66
+ export const stableHash = (obj) => {
67
+ if (obj === null || obj === undefined) return "null";
68
+ if (typeof obj !== "object") return String(obj);
69
+
70
+ try {
71
+ // Sort keys for stable hashing
72
+ const str = JSON.stringify(obj, Object.keys(obj).sort());
73
+ return simpleHash(str);
74
+ } catch (e) {
75
+ // Fallback for circular references
76
+ return simpleHash(String(obj));
77
+ }
78
+ };
79
+
80
+ // Simple hash function
81
+ const simpleHash = (str) => {
82
+ let hash = 0;
83
+ for (let i = 0; i < str.length; i++) {
84
+ const char = str.charCodeAt(i);
85
+ hash = (hash << 5) - hash + char;
86
+ hash = hash & hash; // Convert to 32-bit integer
87
+ }
88
+ return Math.abs(hash).toString(36);
89
+ };
90
+
91
+ // Deep equality check
92
+ export const deepEqual = (a, b) => {
93
+ if (a === b) return true;
94
+
95
+ if (a === null || b === null || a === undefined || b === undefined) {
96
+ return a === b;
97
+ }
98
+
99
+ if (typeof a !== "object" || typeof b !== "object") {
100
+ return a === b;
101
+ }
102
+
103
+ if (Array.isArray(a) !== Array.isArray(b)) {
104
+ return false;
105
+ }
106
+
107
+ if (Array.isArray(a)) {
108
+ if (a.length !== b.length) return false;
109
+ for (let i = 0; i < a.length; i++) {
110
+ if (!deepEqual(a[i], b[i])) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ const keysA = Object.keys(a);
116
+ const keysB = Object.keys(b);
117
+
118
+ if (keysA.length !== keysB.length) return false;
119
+
120
+ for (const key of keysA) {
121
+ if (!keysB.includes(key)) return false;
122
+ if (!deepEqual(a[key], b[key])) return false;
123
+ }
124
+
125
+ return true;
126
+ };
127
+
128
+ // Longest Common Subsequence algorithm
129
+ export const longestCommonSubsequence = (arr1, arr2) => {
130
+ const m = arr1.length;
131
+ const n = arr2.length;
132
+ const dp = Array(m + 1)
133
+ .fill(null)
134
+ .map(() => Array(n + 1).fill(0));
135
+
136
+ // Build LCS table
137
+ for (let i = 1; i <= m; i++) {
138
+ for (let j = 1; j <= n; j++) {
139
+ if (arr1[i - 1] === arr2[j - 1]) {
140
+ dp[i][j] = dp[i - 1][j - 1] + 1;
141
+ } else {
142
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
143
+ }
144
+ }
145
+ }
146
+
147
+ // Backtrack to find LCS
148
+ const lcs = [];
149
+ let i = m,
150
+ j = n;
151
+ while (i > 0 && j > 0) {
152
+ if (arr1[i - 1] === arr2[j - 1]) {
153
+ lcs.unshift(arr1[i - 1]);
154
+ i--;
155
+ j--;
156
+ } else if (dp[i - 1][j] > dp[i][j - 1]) {
157
+ i--;
158
+ } else {
159
+ j--;
160
+ }
161
+ }
162
+
163
+ return lcs;
164
+ };
165
+
166
+ // Generate unique key for array items
167
+ export const getItemKey = (item, index) => {
168
+ // 1. If item has 'id' property, use it
169
+ if (item && typeof item === "object" && "id" in item) {
170
+ return `id_${item.id}`;
171
+ }
172
+
173
+ // 2. If item is primitive, use value + index
174
+ if (typeof item !== "object" || item === null) {
175
+ return `val_${item}_${index}`;
176
+ }
177
+
178
+ // 3. For objects without id, use stable hash
179
+ return `hash_${stableHash(item)}_${index}`;
180
+ };
181
+
182
+ // Compute diff operations between old and new arrays
183
+ export const computeDiff = (oldKeys, newKeys, oldArray, newArray) => {
184
+ const operations = [];
185
+
186
+ // Use LCS to find common elements
187
+ const common = longestCommonSubsequence(oldKeys, newKeys);
188
+
189
+ // Build index maps for quick lookup
190
+ const newKeyMap = new Map(newKeys.map((key, idx) => [key, idx]));
191
+ const oldKeyMap = new Map(oldKeys.map((key, idx) => [key, idx]));
192
+
193
+ // Track which keys we've processed
194
+ const processedOld = new Set();
195
+ const processedNew = new Set();
196
+
197
+ // First pass: identify items to keep and check for updates
198
+ common.forEach((key) => {
199
+ const oldIdx = oldKeyMap.get(key);
200
+ const newIdx = newKeyMap.get(key);
201
+ processedOld.add(oldIdx);
202
+ processedNew.add(newIdx);
203
+
204
+ // Check if item content changed
205
+ if (!deepEqual(oldArray[oldIdx], newArray[newIdx])) {
206
+ operations.push({
207
+ type: "UPDATE",
208
+ index: newIdx,
209
+ oldIndex: oldIdx,
210
+ item: newArray[newIdx],
211
+ });
212
+ }
213
+
214
+ // Check if position changed
215
+ if (oldIdx !== newIdx) {
216
+ operations.push({
217
+ type: "MOVE",
218
+ from: oldIdx,
219
+ to: newIdx,
220
+ key,
221
+ });
222
+ }
223
+ });
224
+
225
+ // Second pass: identify removals
226
+ oldKeys.forEach((key, idx) => {
227
+ if (!processedOld.has(idx)) {
228
+ operations.push({
229
+ type: "REMOVE",
230
+ index: idx,
231
+ key,
232
+ });
233
+ }
234
+ });
235
+
236
+ // Third pass: identify additions
237
+ newKeys.forEach((key, idx) => {
238
+ if (!processedNew.has(idx)) {
239
+ operations.push({
240
+ type: "ADD",
241
+ index: idx,
242
+ item: newArray[idx],
243
+ key,
244
+ });
245
+ }
246
+ });
247
+
248
+ // Sort operations: REMOVE first (from end), then MOVE, then ADD, then UPDATE
249
+ return operations.sort((a, b) => {
250
+ const priority = { REMOVE: 0, MOVE: 1, ADD: 2, UPDATE: 3 };
251
+ if (priority[a.type] !== priority[b.type]) {
252
+ return priority[a.type] - priority[b.type];
253
+ }
254
+ // For REMOVE, process from end to beginning
255
+ if (a.type === "REMOVE") {
256
+ return b.index - a.index;
257
+ }
258
+ // For others, process in order
259
+ return a.index - b.index;
260
+ });
261
+ };