j-templates 8.2.0 → 9.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/Utils/json.js CHANGED
@@ -1,4 +1,15 @@
1
- import { ArraysEqual } from "./array";
1
+ /** How an inserted splice item gets its value */
2
+ export var SPLICE_ACTION;
3
+ (function (SPLICE_ACTION) {
4
+ /** The element at `from` in the array before the splice is applied */
5
+ SPLICE_ACTION[SPLICE_ACTION["MOVE"] = 1] = "MOVE";
6
+ /** A new value */
7
+ SPLICE_ACTION[SPLICE_ACTION["INSERT"] = 2] = "INSERT";
8
+ })(SPLICE_ACTION || (SPLICE_ACTION = {}));
9
+ /** The default strategy: every changed object and array is diffed into, arrays by position */
10
+ export const JsonDiffDefaultStrategy = () => "diff";
11
+ /** The default identity: an element is its own identity, so elements are compared by === */
12
+ export const JsonDiffDefaultIdentity = (value) => value;
2
13
  const jsonProto = Object.getPrototypeOf({});
3
14
  const arrayProto = Object.getPrototypeOf([]);
4
15
  const strProto = Object.getPrototypeOf("");
@@ -96,262 +107,578 @@ export function JsonDeepClone(value, type = JsonType(value)) {
96
107
  }
97
108
  }
98
109
  /**
99
- * Applies a diff result to a root value in place: moves first, then writes.
100
- * @param resolve - Returns the object to walk into for `parent[prop]`; the walk stores it back at
101
- * `parent[prop]` when it's a different object
110
+ * Applies a diff result to a root value in place, one record at a time in order. A diff doesn't own
111
+ * its values (see JsonDiff), so each value written or inserted is deep-cloned first: the root never
112
+ * shares an object with the diff, the value it was computed from, or another record, unless
113
+ * `clone` says otherwise.
114
+ * - A write assigns its value at its path
115
+ * - A delete removes the property its path ends with
116
+ * - A splice record splices the array at its path in place (see JsonApplySplices)
117
+ * @param root - The value to update; must be an object or array
118
+ * @param diff - The diff result to apply
102
119
  * @param assign - Assigns a written value (already cloned) to its parent
103
- * @param move - Assigns a moved value to its new slot. A move relocates a value that's already in
104
- * the root, so it's kept separate from `assign`, which handles new values.
120
+ * @param remove - Removes a deleted property from its parent
121
+ * @param spliced - Called with each array a splice record changed, after it's changed
122
+ * @param clone - Copies each value before it's written or inserted. A caller that already owns the
123
+ * diff's values can pass one that keeps them, or parts of them, as they are.
124
+ * @throws For a write or delete with an empty path: the root can't be replaced or removed, only
125
+ * changed inside. JsonDiff records one only when the root's JSON type changes.
105
126
  */
106
- export function JsonApplyDiff(root, diff,
107
- /* resolve?: (parent: any, prop: string | number) => any, */
108
- assign) {
109
- const moves = diff.filter(function (diff) {
110
- return diff.type === "move";
111
- });
112
- JsonApplyMoves(root, moves, assign);
113
- const writes = diff.filter(function (diff) {
114
- return diff.type === "write";
115
- });
116
- JsonApplyWrites(root, writes, assign);
127
+ export function JsonApplyDiff(root, diff, assign = DefaultAssign, remove = DefaultRemove, spliced, clone = JsonDeepClone) {
128
+ // The objects along the last path walked, so records under the same parent walk it once:
129
+ // walked[y] is [path[y - 1], the object at path[0..y)]
130
+ const walked = [["", root]];
131
+ for (let x = 0; x < diff.length; x++) {
132
+ const record = diff[x];
133
+ const path = record.path;
134
+ if (path.length === 0 && record.type !== "splice")
135
+ throw new Error("Unable to replace the root of a diff: apply it to an object holding the root");
136
+ if (record.type !== "splice") {
137
+ const parent = Walk(walked, path, path.length - 1);
138
+ const prop = path[path.length - 1];
139
+ record.type === "write" ? assign(parent, prop, clone(record.value)) : remove(parent, prop);
140
+ // The value at the path was replaced or removed, so anything walked through it is gone
141
+ walked.length = Math.min(walked.length, path.length);
142
+ }
143
+ else {
144
+ const array = Walk(walked, path, path.length);
145
+ if (!Array.isArray(array))
146
+ throw new Error(`Unable to splice ${path.join(".")}: not an array`);
147
+ JsonApplySplices(array, record.splices, clone);
148
+ // The array is the same object, but its elements moved
149
+ walked.length = Math.min(walked.length, path.length + 1);
150
+ spliced?.(array);
151
+ }
152
+ }
117
153
  }
118
154
  function DefaultAssign(parent, prop, value) {
119
155
  parent[prop] = value;
120
156
  }
157
+ function DefaultRemove(parent, prop) {
158
+ delete parent[prop];
159
+ }
160
+ /** The object at `path[0..depth)`, walking from the deepest object in `walked` still on the path */
161
+ function Walk(walked, path, depth) {
162
+ for (let y = 0; y < depth; y++) {
163
+ const property = path[y];
164
+ const index = y + 1;
165
+ if (index < walked.length && walked[index][0] === property)
166
+ continue;
167
+ const child = walked[y][1][property];
168
+ if (index < walked.length) {
169
+ walked[index] = [property, child];
170
+ // Deeper entries were under the old object; clearing the next one makes the walk refresh each
171
+ if (index + 1 < walked.length)
172
+ walked[index + 1][0] = null;
173
+ }
174
+ else
175
+ walked.push([property, child]);
176
+ }
177
+ return walked[depth][1];
178
+ }
179
+ /** Sets an array's elements to `items` in place. Unlike `splice(0, length, ...items)`, any length works. */
180
+ function ReplaceArrayContents(array, items) {
181
+ for (let x = 0; x < items.length; x++)
182
+ array[x] = items[x];
183
+ array.length = items.length;
184
+ }
121
185
  /**
122
- * Applies a diff result to a root value in place. A diff doesn't own its values (see JsonDiff),
123
- * so each value is deep-cloned before it's written: the root never shares an object with the
124
- * diff, the value it was computed from, or another record.
125
- * - A single record with an empty path replaces the root's contents, keeping its identity
126
- * - Otherwise each record's value is assigned at its path
127
- * @param root - The value to update; must be an object or array
128
- * @param diff - The diff result to apply
129
- * @param callback - Called with the object written to, the property written ("" when an array
130
- * root is replaced), and whether that property is new to the object
131
- * @throws If a root replacement would change the root's JSON type, or the root is a primitive
186
+ * Applies splices to an array in place, in one pass however many there are: the new contents are
187
+ * built from the old ones, then written back into the same array. A moved item is the old element
188
+ * at its `from`, as is; an inserted item's value is deep-cloned.
189
+ * @param splices - In ascending, non-overlapping order, as JsonDiffArraySplice returns them: each
190
+ * one's index is at or after where the one before it ended
191
+ * @param clone - Copies each inserted value; see JsonApplyDiff
192
+ * @throws When a splice starts before the one before it ended
132
193
  */
133
- export function JsonApplyWrites(root, diff, assign = DefaultAssign) {
134
- const pathTuples = [["", root]];
135
- if (diff.length === 1 && diff[0].path.length === 0) {
136
- const diffRow = diff[0];
137
- const diffPatch = JsonDeepClone(diffRow.value);
138
- const type = JsonType(root);
139
- const patchType = JsonType(diffPatch);
140
- if (type !== patchType)
141
- throw new Error("Unable to change type of root value");
142
- switch (type) {
143
- case "array": {
144
- root.splice(0, root.length, ...diffPatch);
145
- break;
146
- }
147
- case "object": {
148
- const keys = Object.keys(root);
149
- const patchKeys = Object.keys(diffPatch);
150
- for (let x = 0; x < keys.length; x++)
151
- if (!patchKeys.includes(keys[x]))
152
- delete root[keys[x]];
153
- Object.assign(root, diffPatch);
154
- break;
155
- }
156
- case "value":
157
- throw new Error("Unable to replace root value type: " + root);
158
- }
194
+ export function JsonApplySplices(array, splices, clone = JsonDeepClone) {
195
+ if (splices.length === 0)
159
196
  return;
197
+ // `array` isn't changed until the end, so it's read as the original throughout
198
+ const result = [];
199
+ // The next old element to copy. The array as the splices so far left it is `result`, then the old
200
+ // elements from `read` on.
201
+ let read = 0;
202
+ for (let x = 0; x < splices.length; x++) {
203
+ const splice = splices[x];
204
+ if (splice.index < result.length)
205
+ throw new Error(`Unable to apply splice at ${splice.index}: splices must be in ascending, non-overlapping order`);
206
+ // Copy the old elements up to the splice; an index past the end splices at the end, as
207
+ // Array.prototype.splice does
208
+ for (; result.length < splice.index && read < array.length; read++)
209
+ result.push(array[read]);
210
+ read = Math.min(read + splice.delete, array.length);
211
+ for (let y = 0; y < splice.insert.length; y++) {
212
+ const item = splice.insert[y];
213
+ result.push(item.type === SPLICE_ACTION.MOVE ? array[item.from] : clone(item.value));
214
+ }
160
215
  }
161
- for (let x = 0; x < diff.length; x++) {
162
- const { path, value } = diff[x];
163
- let y = 0;
164
- for (; y < path.length - 1; y++) {
165
- const property = path[y];
166
- const tupleIndex = y + 1;
167
- if (tupleIndex >= pathTuples.length || pathTuples[tupleIndex][0] !== property) {
168
- const parent = pathTuples[y][1];
169
- const child = parent[property];
170
- if (tupleIndex < pathTuples.length) {
171
- pathTuples[tupleIndex] = [property, child];
172
- const next = tupleIndex + 1;
173
- if (next < pathTuples.length)
174
- pathTuples[next][0] = null;
175
- }
176
- else
177
- pathTuples.push([property, child]);
178
- }
216
+ for (; read < array.length; read++)
217
+ result.push(array[read]);
218
+ ReplaceArrayContents(array, result);
219
+ }
220
+ /**
221
+ * Whether two JSON values differ, without recording how: it stops at the first difference and
222
+ * skips any subtree both values share, so it costs no more than walking what differs. An
223
+ * undefined property counts as absent, as in JSON. Arrays are compared by position.
224
+ * @param newValue - The new value
225
+ * @param oldValue - The old value to compare against
226
+ * @returns true when the values differ
227
+ */
228
+ export function JsonHasChanges(newValue, oldValue) {
229
+ if (newValue === oldValue)
230
+ return false;
231
+ const type = JsonType(newValue);
232
+ if (type !== JsonType(oldValue))
233
+ return true;
234
+ switch (type) {
235
+ case "array": {
236
+ const newArray = newValue;
237
+ const oldArray = oldValue;
238
+ if (newArray.length !== oldArray.length)
239
+ return true;
240
+ for (let x = 0; x < newArray.length; x++)
241
+ if (JsonHasChanges(newArray[x], oldArray[x]))
242
+ return true;
243
+ return false;
179
244
  }
180
- if (y >= path.length - 1) {
181
- const assignValue = pathTuples[y][1];
182
- const assignData = JsonDeepClone(value);
183
- assign(assignValue, path[y], assignData);
245
+ case "object": {
246
+ const newObject = newValue;
247
+ const oldObject = oldValue;
248
+ const newKeys = Object.keys(newObject);
249
+ let defined = 0;
250
+ for (let x = 0; x < newKeys.length; x++) {
251
+ const value = newObject[newKeys[x]];
252
+ if (value === undefined)
253
+ continue;
254
+ defined++;
255
+ if (!Object.hasOwn(oldObject, newKeys[x]) || JsonHasChanges(value, oldObject[newKeys[x]]))
256
+ return true;
257
+ }
258
+ // Every defined key of the new value is in the old one, so they differ only when the old
259
+ // one defines more
260
+ const oldKeys = Object.keys(oldObject);
261
+ if (oldKeys.length === defined)
262
+ return false;
263
+ let oldDefined = 0;
264
+ for (let x = 0; x < oldKeys.length; x++)
265
+ if (oldObject[oldKeys[x]] !== undefined)
266
+ oldDefined++;
267
+ return oldDefined !== defined;
184
268
  }
269
+ default:
270
+ return true;
185
271
  }
186
272
  }
187
- function ReadPath(value, path) {
188
- let curr = value;
189
- let x = 0;
190
- for (; curr && x < path.length; x++)
191
- curr = curr[path[x]];
192
- return curr;
193
- }
194
- function JsonApplyMoves(root, diff, move = DefaultAssign) {
195
- if (diff.length === 0)
196
- return;
197
- let moveRoot = null;
198
- let basePath = null;
199
- let copy = null;
200
- for (let x = diff.length - 1; x >= 0; x--) {
201
- if (!ArraysEqual(diff[x].path, basePath)) {
202
- basePath = diff[x].path;
203
- moveRoot = ReadPath(root, diff[x].path); //, resolve);
204
- const type = JsonType(moveRoot);
205
- copy = type === "array" ? moveRoot.slice() : type === "object" ? Object.assign({}, moveRoot) : root;
273
+ /**
274
+ * `newValue`, with every part that has no changes from the same place in `oldValue` (as
275
+ * JsonHasChanges compares them) replaced by `oldValue`'s object, so unchanged parts keep their
276
+ * identity. Neither value is changed: a container of `newValue` is copied only when one of its
277
+ * children is replaced, and kept as it is otherwise. Arrays are compared by position.
278
+ *
279
+ * old: { a: { x: 1 }, b: { y: 1 } }
280
+ * new: { a: { x: 1 }, b: { y: 2 } }
281
+ * result: { a: old.a, b: new.b }, a new object
282
+ *
283
+ * @param newValue - The new value
284
+ * @param oldValue - The value whose objects are kept where nothing changed
285
+ * @returns `oldValue` itself when nothing changed, otherwise `newValue` or a copy of it holding
286
+ * `oldValue`'s unchanged parts
287
+ */
288
+ export function JsonMaintainUnchanged(newValue, oldValue) {
289
+ if (newValue === oldValue)
290
+ return newValue;
291
+ const type = JsonType(newValue);
292
+ if (type !== JsonType(oldValue))
293
+ return newValue;
294
+ switch (type) {
295
+ case "array": {
296
+ const newArray = newValue;
297
+ const oldArray = oldValue;
298
+ let copy;
299
+ let unchanged = newArray.length === oldArray.length;
300
+ for (let x = 0; x < newArray.length; x++) {
301
+ const value = x < oldArray.length ? JsonMaintainUnchanged(newArray[x], oldArray[x]) : newArray[x];
302
+ unchanged &&= value === oldArray[x];
303
+ if (value !== newArray[x])
304
+ (copy ??= newArray.slice())[x] = value;
305
+ }
306
+ return unchanged ? oldValue : (copy ?? newValue);
206
307
  }
207
- const sourceValue = copy[diff[x].sourceProp];
208
- move(moveRoot, diff[x].targetProp, sourceValue);
308
+ case "object": {
309
+ const newObject = newValue;
310
+ const oldObject = oldValue;
311
+ const newKeys = Object.keys(newObject);
312
+ let copy;
313
+ let unchanged = true;
314
+ let defined = 0;
315
+ for (let x = 0; x < newKeys.length; x++) {
316
+ const key = newKeys[x];
317
+ const newProp = newObject[key];
318
+ if (newProp === undefined)
319
+ continue;
320
+ defined++;
321
+ if (!Object.hasOwn(oldObject, key)) {
322
+ unchanged = false;
323
+ continue;
324
+ }
325
+ const value = JsonMaintainUnchanged(newProp, oldObject[key]);
326
+ unchanged &&= value === oldObject[key];
327
+ if (value !== newProp)
328
+ SetOwnProperty(copy ??= { ...newObject }, key, value);
329
+ }
330
+ // Every defined key of the new value matched, so the old one is kept unless it defines more
331
+ if (unchanged) {
332
+ const oldKeys = Object.keys(oldObject);
333
+ let oldDefined = 0;
334
+ for (let x = 0; x < oldKeys.length && oldDefined <= defined; x++)
335
+ if (oldObject[oldKeys[x]] !== undefined)
336
+ oldDefined++;
337
+ if (oldDefined === defined)
338
+ return oldValue;
339
+ }
340
+ return (copy ?? newValue);
341
+ }
342
+ default:
343
+ return newValue;
209
344
  }
210
345
  }
346
+ /** Sets a key as an own property, even `__proto__`, which plain assignment would make the prototype */
347
+ export function SetOwnProperty(target, key, value) {
348
+ if (key === "__proto__")
349
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
350
+ else
351
+ target[key] = value;
352
+ }
211
353
  /**
212
354
  * Computes the differences between two JSON values.
213
- * Returns an array of paths and values that changed from oldValue to newValue. A record doesn't
355
+ * Returns an array of paths and values that changed from oldValue to newValue. A value is only
356
+ * written whole where its JSON type changes (or a strategy says so); an object or array of the
357
+ * same type is always diffed into, however much of it changed. So the root itself is only ever
358
+ * written when its type changes, and a diff of values of the same type can always be applied
359
+ * in place. A record doesn't
214
360
  * own its value: it references `newValue`'s objects, so two records can share one, and
215
361
  * JsonApplyDiff clones each value as it's applied.
216
362
  * @template T - The type of the JSON values being compared
217
363
  * @param newValue - The new value
218
364
  * @param oldValue - The old value to compare against
219
- * @param rootPath - Optional dot-separated string for the base path (for internal recursion)
220
- * @param initResult - Optional initial result array (for internal recursion)
365
+ * @param rootPath - Optional dot-separated string for the base path
366
+ * @param strategy - Optional choice of how each changed object or array is diffed; without one,
367
+ * everything is diffed by walking into it, arrays by position
368
+ * @param identity - Optional identity a spliced array's elements are compared by; without one,
369
+ * elements are compared by ===. See JsonDiffIdentityFunc
221
370
  * @returns Array of change records with path and new value
222
371
  */
223
- export function JsonDiff(newValue, oldValue, rootPath, initResult) {
224
- const result = initResult ?? [];
225
- const startPath = rootPath ? rootPath.split(".") : [];
226
- JsonDiffRecursive(startPath, newValue, oldValue, result);
227
- result.sort(function (a, b) {
228
- const aNum = a.type === "write" ? 1 : 0;
229
- const bNum = b.type === "write" ? 1 : 0;
230
- return aNum - bNum;
231
- });
372
+ export function JsonDiff(newValue, oldValue, rootPath, strategy, identity) {
373
+ const result = [];
374
+ JsonDiffInto(newValue, oldValue, rootPath ? rootPath.split(".") : [], result, strategy, identity);
232
375
  return result;
233
376
  }
234
377
  /**
235
- * Recursive helper for JsonDiff that traverses and compares values.
378
+ * Appends the differences between two JSON values to `result`, so several values can be diffed into
379
+ * one result.
380
+ * @param path - The path the values are at
381
+ * @param strategy - See JsonDiff
382
+ * @param identity - See JsonDiff
383
+ * @returns The result
384
+ */
385
+ export function JsonDiffInto(newValue, oldValue, path, result, strategy = JsonDiffDefaultStrategy, identity = JsonDiffDefaultIdentity) {
386
+ JsonDiffRecursive(path, newValue, oldValue, result, { strategy, identity });
387
+ return result;
388
+ }
389
+ /**
390
+ * Recursive helper for JsonDiff that traverses and compares values. Values of the same JSON type
391
+ * are diffed into; anything else is written.
236
392
  * @internal
237
393
  * @param path - Current path being evaluated
238
394
  * @param newValue - New value at this path
239
395
  * @param oldValue - Old value at this path
240
396
  * @param resp - Result array to populate with changes
241
- * @returns true if the entire subtree changed, false otherwise
397
+ * @param options - The diff's strategy and identity, see JsonDiff
242
398
  */
243
- function JsonDiffRecursive(path, newValue, oldValue, resp) {
399
+ function JsonDiffRecursive(path, newValue, oldValue, resp, options) {
244
400
  if (newValue === oldValue)
245
- return false;
401
+ return;
246
402
  const newType = JsonType(newValue);
247
403
  const oldType = JsonType(oldValue);
248
- const changedPathLength = resp.length;
249
- let allChildrenChanged = true;
250
- if (newType === oldType)
404
+ const choice = newType !== "value" ? options.strategy(newValue, oldValue, path) : undefined;
405
+ if (choice === "skip")
406
+ return;
407
+ if (newType === oldType && choice !== "write")
251
408
  switch (newType) {
252
409
  case "array": {
253
- allChildrenChanged = JsonDiffArrays(path, newValue, oldValue, resp);
254
- break;
410
+ if (choice === "splice") {
411
+ const splices = JsonDiffArraySplice(newValue, oldValue, options.identity);
412
+ splices.length > 0 && resp.push({ type: "splice", path, splices });
413
+ }
414
+ else
415
+ JsonDiffArrays(path, newValue, oldValue, resp, options);
416
+ return;
255
417
  }
256
418
  case "object": {
257
- allChildrenChanged = JsonDiffObjects(path, newValue, oldValue, resp);
258
- break;
419
+ JsonDiffObjects(path, newValue, oldValue, resp, options);
420
+ return;
259
421
  }
260
422
  }
261
- if (allChildrenChanged) {
262
- resp.length > changedPathLength && resp.splice(changedPathLength);
263
- resp.push({
264
- type: "write",
265
- path,
266
- value: newValue,
267
- });
268
- return true;
423
+ resp.push({
424
+ type: "write",
425
+ path,
426
+ value: newValue,
427
+ });
428
+ }
429
+ /**
430
+ * The splices that turn `oldValue` into `newValue`, matching elements by identity without walking
431
+ * into them: two elements are the same when their identities are ===. With the default identity,
432
+ * that's the elements themselves. A matched element is never diffed into, even when it isn't === to
433
+ * its match. An identity that repeats is matched in order, and a repeat with no old element left to
434
+ * match is inserted.
435
+ *
436
+ * It works in four stages, followed through this example in the comments below:
437
+ *
438
+ * old: [A, B, C, D, E, F]
439
+ * new: [A, E, B, X, D, F]
440
+ *
441
+ * 1. Trim the elements identical at the start and end (A and F): they can't need a change.
442
+ * 2. Match each remaining new element to the old index holding the same element, or -1.
443
+ * 3. Choose which matched elements stay in place: the longest run of them still in their old
444
+ * order (B, D). Keeping the longest run is what keeps the number of moves as small as possible.
445
+ * 4. Walk both arrays from kept element to kept element. Between two kept elements, delete the
446
+ * old elements that aren't kept (C, E) and insert the new ones that aren't (E as a move, X as
447
+ * a new value).
448
+ *
449
+ * @returns The splices, in the order to apply them; none when the arrays hold the same elements.
450
+ * Applied in order to [A, B, C, D, E, F], with each move read from the array before any splice:
451
+ *
452
+ * splice(1, 0, E) [A, E, B, C, D, E, F]
453
+ * splice(3, 1, X) [A, E, B, X, D, E, F]
454
+ * splice(5, 1) [A, E, B, X, D, F]
455
+ *
456
+ * @param identity - What elements are compared by; see JsonDiffIdentityFunc. Elements that are
457
+ * === are the same element without calling it, and it's called at most a few times for each
458
+ * other element, so it must give the same identity for the same value.
459
+ */
460
+ export function JsonDiffArraySplice(newValue, oldValue, identity = JsonDiffDefaultIdentity) {
461
+ const newLength = newValue.length;
462
+ const oldLength = oldValue.length;
463
+ // Every comparison below is between identities. Inserted values are still read from newValue.
464
+ const same = (a, b) => a === b || identity(a) === identity(b);
465
+ // Stage 1: trim the elements with the same identity at the start and end. An append, a delete or a single
466
+ // replaced element leaves little or nothing in the middle, so the stages after this cost little.
467
+ //
468
+ // start: A === A, B !== E → start = 1
469
+ // end: F === F, E !== D → newEnd = oldEnd = 5
470
+ // middle old (indexes 1-4): [B, C, D, E]
471
+ // middle new (indexes 1-4): [E, B, X, D]
472
+ const shared = Math.min(newLength, oldLength);
473
+ let start = 0;
474
+ for (; start < shared && same(newValue[start], oldValue[start]); start++) { }
475
+ if (start === newLength && start === oldLength)
476
+ return [];
477
+ let newEnd = newLength;
478
+ let oldEnd = oldLength;
479
+ for (; newEnd > start && oldEnd > start && same(newValue[newEnd - 1], oldValue[oldEnd - 1]); newEnd--, oldEnd--) { }
480
+ // Stage 2: match each new element in the middle to an old index holding an element with the same
481
+ // identity.
482
+ //
483
+ // oldIndexes maps each old identity to every index it's at. The lists are filled from the end, so
484
+ // each holds its indexes highest first and pop() hands them out lowest first: the first copy of a
485
+ // repeated value in the new array matches its first copy in the old array, and so on. Once a
486
+ // value's indexes run out, any further copy has no match.
487
+ //
488
+ // oldIndexes: B → [1], C → [2], D → [3], E → [4]
489
+ const oldIndexes = new Map();
490
+ for (let x = oldEnd - 1; x >= start; x--) {
491
+ const id = identity(oldValue[x]);
492
+ const indexes = oldIndexes.get(id);
493
+ indexes ? indexes.push(x) : oldIndexes.set(id, [x]);
494
+ }
495
+ // matched[x] is the old index the new element at start + x matched, or -1 when it has none.
496
+ //
497
+ // new: E B X D
498
+ // matched: 4 1 -1 3
499
+ const middle = newEnd - start;
500
+ const matched = new Int32Array(middle);
501
+ for (let x = 0; x < middle; x++)
502
+ matched[x] = oldIndexes.get(identity(newValue[start + x]))?.pop() ?? -1;
503
+ // Stage 3: choose which matched elements stay in place. They're the longest run whose old
504
+ // indexes increase from left to right, because only those can keep their order without moving.
505
+ //
506
+ // matched: 4 1 -1 3
507
+ // longest increasing run: 1, 3 → B and D are kept
508
+ // E (4) is matched but out of order → it will be moved
509
+ // X (-1) has no match → it will be inserted
510
+ //
511
+ // keptNew is indexed by position in the new middle; keptOld marks the same kept elements by
512
+ // their position in the old middle.
513
+ //
514
+ // keptNew (E, B, X, D): 0 1 0 1
515
+ // keptOld (B, C, D, E): 1 0 1 0 → C and E aren't kept, so they will be deleted
516
+ const keptNew = KeptMatches(matched);
517
+ const keptOld = new Uint8Array(oldEnd - start);
518
+ for (let x = 0; x < middle; x++)
519
+ if (keptNew[x])
520
+ keptOld[matched[x] - start] = 1;
521
+ // Stage 4: walk both arrays at once. The kept elements appear in the same order in both, so after
522
+ // skipping the elements that aren't kept, both cursors stop at the same kept element (or both
523
+ // reach the end). Each stop emits one splice:
524
+ // - delete: the old elements skipped, which aren't kept
525
+ // - insert: the new elements skipped, as a move when they were matched, otherwise as a new value
526
+ //
527
+ // A splice's index is the new cursor's position when it starts. The splices before it have already
528
+ // made the array match the new array up to that position, so that's where this one goes.
529
+ //
530
+ // index 1: old skips nothing before B; new skips E (matched 4) → { 1, delete 0, [MOVE 4] }
531
+ // index 3: old skips C before D; new skips X (no match) → { 3, delete 1, [INSERT X] }
532
+ // index 5: old skips E to the end; new has nothing left → { 5, delete 1, [] }
533
+ //
534
+ // The last stop is what deletes old elements left over at the end of the array.
535
+ const splices = [];
536
+ let oldIndex = start;
537
+ let newIndex = start;
538
+ while (oldIndex < oldEnd || newIndex < newEnd) {
539
+ const index = newIndex;
540
+ let deleteCount = 0;
541
+ for (; oldIndex < oldEnd && !keptOld[oldIndex - start]; oldIndex++)
542
+ deleteCount++;
543
+ const insert = [];
544
+ for (; newIndex < newEnd && !keptNew[newIndex - start]; newIndex++) {
545
+ const from = matched[newIndex - start];
546
+ insert.push(from < 0 ? { type: SPLICE_ACTION.INSERT, value: newValue[newIndex] } : { type: SPLICE_ACTION.MOVE, from });
547
+ }
548
+ // Two kept elements next to each other in both arrays leave nothing to splice between them
549
+ if (deleteCount > 0 || insert.length > 0)
550
+ splices.push({ index, delete: deleteCount, insert });
551
+ // Step past the kept element both cursors stopped at. At the end, this takes both past the end
552
+ // and the loop stops.
553
+ oldIndex++;
554
+ newIndex++;
555
+ }
556
+ return splices;
557
+ }
558
+ /**
559
+ * Which matched elements to keep in place: the longest run of them, from left to right, whose old
560
+ * indexes increase. That's the longest increasing subsequence of `matched`, ignoring the -1s, and
561
+ * every matched element left out of it has to move. O(n log n).
562
+ *
563
+ * For example, matched [4, 1, -1, 3] keeps the elements at 1 and 3 (old indexes 1 and 3), and a
564
+ * rotate, matched [4, 0, 1, 2, 3], keeps everything but the first, so only that one moves.
565
+ *
566
+ * @param matched - Each new element's matched old index, or -1 when it has none
567
+ * @returns 1 for each element kept, by position in `matched`
568
+ */
569
+ function KeptMatches(matched) {
570
+ const length = matched.length;
571
+ // previous[x]: the element before x in the longest run found that ends at x, or -1. Following it
572
+ // back from the end of the longest run gives the whole run.
573
+ const previous = new Int32Array(length);
574
+ // tails[k]: of every increasing run of length k + 1 found so far, the element ending the one with
575
+ // the smallest old index. A smaller ending is better, since more elements can extend it. Their old
576
+ // indexes always increase along tails, which is what lets the search below be a binary search.
577
+ const tails = [];
578
+ for (let x = 0; x < length; x++) {
579
+ const value = matched[x];
580
+ // An unmatched element can't be kept
581
+ if (value < 0)
582
+ continue;
583
+ // Find the first run in tails whose ending old index isn't below this element's. This element
584
+ // can extend the run before it (every ending before it is lower), so it ends a run of length
585
+ // low + 1: either a better ending for that length, or, past the end of tails, a new longest run.
586
+ let low = 0;
587
+ let high = tails.length;
588
+ while (low < high) {
589
+ const mid = (low + high) >> 1;
590
+ if (matched[tails[mid]] < value)
591
+ low = mid + 1;
592
+ else
593
+ high = mid;
594
+ }
595
+ previous[x] = low > 0 ? tails[low - 1] : -1;
596
+ tails[low] = x;
269
597
  }
270
- return false;
598
+ // The last entry of tails ends a longest run; follow previous back from it to mark the whole run
599
+ //
600
+ // matched [4, 1, -1, 3]:
601
+ // x=0 (4): tails [0] (run: 4)
602
+ // x=1 (1): tails [1] (1 is a better ending than 4 for length 1)
603
+ // x=3 (3): tails [1, 3] (3 extends the run ending at 1; previous[3] = 1)
604
+ // back from 3 → 1 → done: kept = [0, 1, 0, 1]
605
+ const kept = new Uint8Array(length);
606
+ for (let x = tails.length > 0 ? tails[tails.length - 1] : -1; x >= 0; x = previous[x])
607
+ kept[x] = 1;
608
+ return kept;
271
609
  }
272
- const MOVE_SYMBOL = Symbol("MOVE_SYMBOL");
273
610
  /**
274
- * Compares two arrays and records differences.
611
+ * Compares two arrays by position and records differences: each changed position, then the
612
+ * length when it changed. Elements identical at the start, and at the end when the lengths are the
613
+ * same, aren't diffed.
275
614
  * @internal
276
615
  * @param path - Current path being evaluated
277
616
  * @param newValue - New array
278
617
  * @param oldValue - Old array to compare against
279
618
  * @param resp - Result array to populate with changes
280
- * @returns true if the entire array changed, false otherwise
619
+ * @param options - The diff's strategy and identity, see JsonDiff
281
620
  */
282
- function JsonDiffArrays(path, newValue, oldValue, resp) {
283
- if (oldValue.length === 0 || newValue.length === 0) {
284
- return oldValue.length !== newValue.length;
285
- }
286
- let startIndex = 0;
287
- for (; startIndex < newValue.length && startIndex < oldValue.length && newValue[startIndex] === oldValue[startIndex]; startIndex++) { }
621
+ function JsonDiffArrays(path, newValue, oldValue, resp, options) {
622
+ const sameLength = newValue.length === oldValue.length;
623
+ const shared = Math.min(newValue.length, oldValue.length);
624
+ let start = 0;
625
+ for (; start < shared && newValue[start] === oldValue[start]; start++) { }
288
626
  // Arrays are equal, no diffing required
289
- if (startIndex === newValue.length && startIndex === oldValue.length)
290
- return false;
291
- const newArraySlice = newValue.slice();
292
- const oldArraySlice = oldValue.slice();
293
- for (let x = startIndex; x < oldValue.length; x++) {
294
- const newIndex = newValue.indexOf(oldValue[x], startIndex);
295
- if (newIndex >= 0 && newIndex !== x) {
296
- oldArraySlice[x] = MOVE_SYMBOL;
297
- newArraySlice[newIndex] = MOVE_SYMBOL;
298
- resp.push({
299
- type: "move",
300
- path: path,
301
- sourceProp: x,
302
- targetProp: newIndex
303
- });
304
- }
305
- }
306
- for (let x = startIndex; x < newValue.length; x++) {
307
- const arrayPath = path.concat(x);
308
- if (newArraySlice[x] !== MOVE_SYMBOL)
309
- JsonDiffRecursive(arrayPath, newArraySlice[x], oldArraySlice[x], resp);
310
- }
311
- if (oldValue.length !== newValue.length)
627
+ if (sameLength && start === newValue.length)
628
+ return;
629
+ let end = newValue.length;
630
+ if (sameLength)
631
+ for (; end > start && newValue[end - 1] === oldValue[end - 1]; end--) { }
632
+ for (let x = start; x < end; x++)
633
+ // Checked before the path is built: most elements of a diffed array are often unchanged
634
+ newValue[x] !== oldValue[x] && JsonDiffRecursive(path.concat(x), newValue[x], oldValue[x], resp, options);
635
+ if (!sameLength)
312
636
  resp.push({
313
637
  type: "write",
314
638
  path: path.concat("length"),
315
639
  value: newValue.length,
316
640
  });
317
- return false;
318
641
  }
319
642
  /**
320
- * Compares two objects and records differences.
321
- * Uses sorted keys and two-pointer technique for efficient comparison.
643
+ * Compares two objects and records differences, walking their sorted keys together: a key in
644
+ * both is diffed, a key only the new value has is written, and a key only the old value has is
645
+ * deleted. A new key holding undefined counts as absent and isn't written.
322
646
  * @internal
323
647
  * @param path - Current path being evaluated
324
648
  * @param newValue - New object
325
649
  * @param oldValue - Old object to compare against
326
650
  * @param resp - Result array to populate with changes
327
- * @returns true if the entire object should be replaced (keys removed), false otherwise
651
+ * @param options - The diff's strategy and identity, see JsonDiff
328
652
  */
329
- function JsonDiffObjects(path, newValue, oldValue, resp) {
653
+ function JsonDiffObjects(path, newValue, oldValue, resp, options) {
330
654
  const newKeys = Object.keys(newValue).sort();
331
655
  const oldKeys = Object.keys(oldValue).sort();
332
- if (newKeys.length === 0 && oldKeys.length === 0)
333
- return false;
334
- if (newKeys.length < oldKeys.length)
335
- return true;
336
656
  let newKeyIndex = 0;
337
657
  let oldKeyIndex = 0;
338
- while (newKeyIndex < newKeys.length) {
339
- const childPath = path.concat(newKeys[newKeyIndex]);
340
- if (oldKeyIndex < oldKeys.length &&
341
- newKeys[newKeyIndex] === oldKeys[oldKeyIndex]) {
342
- JsonDiffRecursive(childPath, newValue[newKeys[newKeyIndex]], oldValue[oldKeys[oldKeyIndex]], resp);
343
- oldKeyIndex++;
658
+ while (newKeyIndex < newKeys.length || oldKeyIndex < oldKeys.length) {
659
+ const newKey = newKeys[newKeyIndex];
660
+ const oldKey = oldKeys[oldKeyIndex];
661
+ // Both lists are sorted the same way `<` compares strings
662
+ if (oldKeyIndex >= oldKeys.length || (newKeyIndex < newKeys.length && newKey < oldKey)) {
663
+ newValue[newKey] !== undefined && resp.push({
664
+ type: "write",
665
+ path: path.concat(newKey),
666
+ value: newValue[newKey],
667
+ });
668
+ newKeyIndex++;
344
669
  }
345
- else if (newValue[newKeys[newKeyIndex]] !== undefined) {
670
+ else if (newKeyIndex >= newKeys.length || oldKey < newKey) {
346
671
  resp.push({
347
- type: "write",
348
- path: childPath,
349
- value: newValue[newKeys[newKeyIndex]],
672
+ type: "delete",
673
+ path: path.concat(oldKey),
350
674
  });
675
+ oldKeyIndex++;
676
+ }
677
+ else {
678
+ // Checked before the path is built: most properties of a diffed object are often unchanged
679
+ newValue[newKey] !== oldValue[oldKey] && JsonDiffRecursive(path.concat(newKey), newValue[newKey], oldValue[oldKey], resp, options);
680
+ newKeyIndex++;
681
+ oldKeyIndex++;
351
682
  }
352
- newKeyIndex++;
353
683
  }
354
- if (oldKeyIndex < oldKeys.length)
355
- return true;
356
- return false;
357
684
  }