@rohal12/spindle 0.59.5 → 0.59.7

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/src/structural.ts CHANGED
@@ -43,11 +43,11 @@ export interface DeepCloneOptions {
43
43
  */
44
44
  seen?: Map<object, object>;
45
45
  /**
46
- * Called with each key a Map is given in the copy and the key it was copied
47
- * from; the key it returns is the one the copy takes (for a store draft,
48
- * which Immer does not finalize in a Map key).
46
+ * Called with each Map key and Set member in the copy and the object it
47
+ * was copied from; the object it returns is the one the copy takes (for a
48
+ * store draft, which Immer does not finalize there).
49
49
  */
50
- mapKey?: (copy: object, original: object) => object;
50
+ settle?: (copy: object, original: object) => object;
51
51
  }
52
52
 
53
53
  type TypedArrayCtor = new (
@@ -109,8 +109,9 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
109
109
  const copy = keep(val, Object.create(Object.getPrototypeOf(val)));
110
110
  for (const key of [...ERROR_HIDDEN_KEYS, 'stack']) {
111
111
  if (hasOwn(val, key)) {
112
+ const held = (val as unknown as Record<string, unknown>)[key];
112
113
  Object.defineProperty(copy, key, {
113
- value: clone((val as unknown as Record<string, unknown>)[key]),
114
+ value: settled(clone(held), held),
114
115
  writable: true,
115
116
  configurable: true,
116
117
  });
@@ -121,6 +122,13 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
121
122
  return undefined;
122
123
  }
123
124
 
125
+ function settled(copy: unknown, original: unknown): unknown {
126
+ const settle = options.settle;
127
+ return settle && isObjectValue(copy) && isObjectValue(original)
128
+ ? settle(copy, original)
129
+ : copy;
130
+ }
131
+
124
132
  function clone(val: unknown): unknown {
125
133
  if (val === null || typeof val !== 'object') return val;
126
134
 
@@ -138,24 +146,13 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
138
146
 
139
147
  if (val instanceof Map) {
140
148
  const copy = keep(obj, new Map());
141
- for (const [k, v] of val) {
142
- const key = clone(k);
143
- const settle = options.mapKey;
144
- copy.set(
145
- settle && isObjectValue(key) && isObjectValue(k)
146
- ? settle(key, k)
147
- : key,
148
- clone(v),
149
- );
150
- }
149
+ for (const [k, v] of val) copy.set(settled(clone(k), k), clone(v));
151
150
  return copy;
152
151
  }
153
152
 
154
153
  if (val instanceof Set) {
155
154
  const copy = keep(obj, new Set());
156
- for (const v of val) {
157
- copy.add(clone(v));
158
- }
155
+ for (const v of val) copy.add(settled(clone(v), v));
159
156
  return copy;
160
157
  }
161
158
 
@@ -476,8 +473,15 @@ export function mergesWith(
476
473
 
477
474
  /** How a value differs from an earlier version at one key. */
478
475
  export type KeyChange =
479
- | { key: string; kind: 'added' | 'changed'; after: unknown }
480
- | { key: string; kind: 'deleted' }
476
+ | {
477
+ key: string;
478
+ kind: 'added' | 'changed';
479
+ after: unknown;
480
+ /** Holes between this element an array gained and the one before. */
481
+ gap?: number;
482
+ }
483
+ | { key: string; kind: 'deleted'; hole?: true }
484
+ | { key: string; kind: 'holes'; after: number }
481
485
  | {
482
486
  key: string;
483
487
  kind: 'nested';
@@ -493,7 +497,9 @@ export type KeyChange =
493
497
  * is none either. Values that merge are reported as 'nested', to be
494
498
  * compared key by key; they may still be equal. With `arrays`, arrays
495
499
  * merge too: elements at the indices both hold are compared, and the
496
- * elements one has beyond the other's length are added or deleted.
500
+ * elements one has beyond the other's length are added or deleted. An
501
+ * index that holds no element (a hole) is no element, not an undefined one,
502
+ * and holes the length gained at the end are the change of `length`, by how many.
497
503
  */
498
504
  export function keyChanges(
499
505
  before: Record<string, unknown>,
@@ -519,14 +525,31 @@ export function keyChanges(
519
525
  const b = before as unknown as unknown[];
520
526
  const a = after as unknown[];
521
527
  for (let i = 0; i < Math.min(b.length, a.length); i++) {
522
- compare(String(i), b[i], a[i]);
528
+ // A hole is no element: not the same as an undefined one
529
+ const key = String(i);
530
+ if (i in b && i in a) compare(key, b[i], a[i]);
531
+ else if (i in b) changes.push({ key, kind: 'deleted', hole: true });
532
+ else if (i in a) changes.push({ key, kind: 'added', after: a[i] });
523
533
  }
534
+ let next = b.length;
524
535
  for (let i = b.length; i < a.length; i++) {
525
- changes.push({ key: String(i), kind: 'added', after: a[i] });
536
+ if (!(i in a)) continue;
537
+ const gap = i - next;
538
+ changes.push({
539
+ key: String(i),
540
+ kind: 'added',
541
+ after: a[i],
542
+ ...(gap ? { gap } : {}),
543
+ });
544
+ next = i + 1;
526
545
  }
527
546
  for (let i = a.length; i < b.length; i++) {
528
547
  changes.push({ key: String(i), kind: 'deleted' });
529
548
  }
549
+ // Holes at the end are in no index, only in the length
550
+ if (a.length > next) {
551
+ changes.push({ key: 'length', kind: 'holes', after: a.length - next });
552
+ }
530
553
  return changes;
531
554
  }
532
555
 
@@ -548,6 +571,8 @@ export type PathChange =
548
571
  value: unknown;
549
572
  /** Set for an element an array gained beyond the length it had. */
550
573
  appended?: true;
574
+ /** Holes an appended element follows (the array is sparse). */
575
+ gap?: number;
551
576
  /**
552
577
  * Set where the value is the same content as before and only which
553
578
  * object the path refers to changed (see aliasChanges), so a store
@@ -555,7 +580,12 @@ export type PathChange =
555
580
  */
556
581
  alias?: true;
557
582
  }
558
- | { path: string[]; deleted: true };
583
+ | {
584
+ path: string[];
585
+ deleted: true;
586
+ /** Set for an element an array lost without shrinking: a hole. */
587
+ hole?: true;
588
+ };
559
589
 
560
590
  /**
561
591
  * The property paths where `after` differs from `before`, two objects of
@@ -570,14 +600,25 @@ export function diffPaths(
570
600
  ): PathChange[] {
571
601
  const changes: PathChange[] = [];
572
602
  const ancestors = new Set<object>();
603
+ // Pairs found equal, so a graph that shares objects is not walked once per
604
+ // path to them (exponentially many), and how often a walk met a cycle
605
+ const unchanged = new WeakMap<object, Set<object>>();
606
+ let cuts = 0;
573
607
  (function walk(
574
608
  b: Record<string, unknown>,
575
609
  a: Record<string, unknown>,
576
610
  path: string[],
577
611
  ): void {
578
- // Stop at cycles; shared (non-cyclic) references are visited per path.
579
- if (ancestors.has(a)) return;
612
+ // Stop at cycles; shared (non-cyclic) references that differ are visited
613
+ // per path, as each is a change there.
614
+ if (ancestors.has(a)) {
615
+ cuts++;
616
+ return;
617
+ }
618
+ if (unchanged.get(a)?.has(b)) return;
580
619
  ancestors.add(a);
620
+ const found = changes.length;
621
+ const cutsBefore = cuts;
581
622
  for (const change of keyChanges(b, a, arrays)) {
582
623
  const at = [...path, change.key];
583
624
  if (change.kind === 'nested') {
@@ -585,18 +626,33 @@ export function diffPaths(
585
626
  } else {
586
627
  changes.push(
587
628
  change.kind === 'deleted'
588
- ? { path: at, deleted: true }
629
+ ? {
630
+ path: at,
631
+ deleted: true,
632
+ ...(change.hole ? { hole: true } : {}),
633
+ }
589
634
  : {
590
635
  path: at,
591
636
  deleted: false,
592
637
  value: change.after,
593
- ...(change.kind === 'added' && Array.isArray(a)
594
- ? { appended: true as const }
638
+ ...(change.kind === 'added' &&
639
+ Array.isArray(a) &&
640
+ Number(change.key) >= (b as unknown as unknown[]).length
641
+ ? {
642
+ appended: true as const,
643
+ ...(change.gap ? { gap: change.gap } : {}),
644
+ }
595
645
  : {}),
596
646
  },
597
647
  );
598
648
  }
599
649
  }
650
+ // A walk that met a cycle depends on the ancestors it was reached by
651
+ if (changes.length === found && cuts === cutsBefore) {
652
+ const seen = unchanged.get(a) ?? new Set<object>();
653
+ seen.add(b);
654
+ unchanged.set(a, seen);
655
+ }
600
656
  ancestors.delete(a);
601
657
  })(before, after, []);
602
658
  return changes;
@@ -616,9 +672,89 @@ export function underChange(
616
672
  return false;
617
673
  }
618
674
 
619
- /** The segment standing for the entry `index` of a Map or Set in a path. */
620
- const entryKey = (kind: 'k' | 'v' | 's', index: number): string =>
621
- `\0${kind}${index}`;
675
+ /**
676
+ * The segment standing for the entry `index` of a Map or Set in a path, or
677
+ * for the `cause` or `errors` of an Error ('c', 'e'), or for the buffer a
678
+ * typed array or DataView views ('b').
679
+ */
680
+ const entryKey = (
681
+ kind: 'k' | 'v' | 's' | 'c' | 'e' | 'b',
682
+ index: number,
683
+ ): string => `\0${kind}${index}`;
684
+
685
+ export const isEntryKey = (segment: string): boolean =>
686
+ segment.startsWith('\0');
687
+
688
+ /**
689
+ * What a Map, Set, Error or view holds in place of properties (keys and
690
+ * values, members, `cause` and `errors`, the buffer), each with the segment standing for it in
691
+ * a path (see entryKey); undefined for any other value.
692
+ */
693
+ function entryChildren(value: object): [string, unknown][] | undefined {
694
+ const children: [string, unknown][] = [];
695
+ if (value instanceof Map) {
696
+ let index = 0;
697
+ for (const [k, v] of value) {
698
+ children.push([entryKey('k', index), k], [entryKey('v', index), v]);
699
+ index++;
700
+ }
701
+ } else if (value instanceof Set) {
702
+ let index = 0;
703
+ for (const member of value) children.push([entryKey('s', index++), member]);
704
+ } else if (value instanceof Error) {
705
+ for (const key of ['cause', 'errors'] as const) {
706
+ if (hasOwn(value, key)) {
707
+ children.push([
708
+ entryKey(key === 'cause' ? 'c' : 'e', 0),
709
+ (value as any)[key],
710
+ ]);
711
+ }
712
+ }
713
+ } else if (ArrayBuffer.isView(value)) {
714
+ // Views of one buffer share it, which an alias of the buffer changes
715
+ children.push([entryKey('b', 0), value.buffer]);
716
+ } else {
717
+ return undefined;
718
+ }
719
+ return children;
720
+ }
721
+
722
+ /**
723
+ * The value at `path` below `root`, where a path may go through the entries
724
+ * of Maps, Sets and Errors (see objectPaths) as well as properties.
725
+ */
726
+ export function getByEntryPath(root: object, path: readonly string[]): unknown {
727
+ let current: unknown = root;
728
+ let start = 0;
729
+ for (let i = 0; i < path.length; i++) {
730
+ if (!isEntryKey(path[i]!)) continue;
731
+ current = getByPath(current as object, path.slice(start, i));
732
+ if (!isObjectValue(current)) return undefined;
733
+ current = entryChildren(current)?.find(([at]) => at === path[i])?.[1];
734
+ start = i + 1;
735
+ }
736
+ return start === path.length
737
+ ? current
738
+ : getByPath(current as object, path.slice(start));
739
+ }
740
+
741
+ /**
742
+ * Write `value` at the path of an object a Map holds as a value (see
743
+ * objectPaths); other entries are not replaced, as a key or member cannot
744
+ * change without the entry moving. Returns whether it wrote.
745
+ */
746
+ function setEntryValue(
747
+ root: object,
748
+ path: readonly string[],
749
+ value: unknown,
750
+ ): boolean {
751
+ const holder = getByEntryPath(root, path.slice(0, -1));
752
+ const match = /^\0v(\d+)$/.exec(path[path.length - 1]!);
753
+ if (!(holder instanceof Map) || !match) return false;
754
+ const key = [...holder.keys()][Number(match[1])];
755
+ holder.set(key, value);
756
+ return true;
757
+ }
622
758
 
623
759
  /**
624
760
  * Every path each object (or array, or other value) of `root` is at, the
@@ -630,7 +766,8 @@ const entryKey = (kind: 'k' | 'v' | 's', index: number): string =>
630
766
  * With `entries`, the objects held in a Map or Set (as key, value or member)
631
767
  * are placed too, after everything else: one met nowhere else is at the path
632
768
  * of its Map or Set and the entry's segment (see entryKey), which is no path
633
- * a value can be read at, so only to tell where an object is from.
769
+ * a value can be read at (see getByEntryPath), so only to tell where an
770
+ * object is from; so are the `cause` and `errors` of an Error.
634
771
  */
635
772
  function objectPaths(
636
773
  root: object,
@@ -638,7 +775,7 @@ function objectPaths(
638
775
  entries = false,
639
776
  ): Map<object, string[][]> {
640
777
  const all = new Map<object, string[][]>();
641
- const collections: [Map<unknown, unknown> | Set<unknown>, string[]][] = [];
778
+ const collections: [object, string[]][] = [];
642
779
  function place(value: object, at: string[]): void {
643
780
  const known = all.get(value);
644
781
  if (known) {
@@ -648,7 +785,7 @@ function objectPaths(
648
785
  all.set(value, [at]);
649
786
  if (isMergeable(value) || Array.isArray(value)) {
650
787
  walk(value as Record<string, unknown>, at);
651
- } else if (entries && (value instanceof Map || value instanceof Set)) {
788
+ } else if (entries && entryChildren(value)) {
652
789
  collections.push([value, at]);
653
790
  }
654
791
  }
@@ -663,22 +800,8 @@ function objectPaths(
663
800
  }
664
801
  walk(root as Record<string, unknown>, []);
665
802
  for (const [collection, path] of collections) {
666
- let index = 0;
667
- for (const entry of collection.entries()) {
668
- const [k, v] = entry as [unknown, unknown];
669
- const parts: [unknown, string][] =
670
- collection instanceof Set
671
- ? [[k, entryKey('s', index)]]
672
- : [
673
- [k, entryKey('k', index)],
674
- [v, entryKey('v', index)],
675
- ];
676
- for (const [value, segment] of parts) {
677
- if (isObjectValue(value) && !all.has(value)) {
678
- place(value, [...path, segment]);
679
- }
680
- }
681
- index++;
803
+ for (const [segment, value] of entryChildren(collection)!) {
804
+ if (isObjectValue(value)) place(value, [...path, segment]);
682
805
  }
683
806
  }
684
807
  return all;
@@ -704,36 +827,55 @@ function firstPaths(
704
827
  * parents before children.
705
828
  */
706
829
  export function sharedPaths(root: object): string[][][] {
707
- return [...objectPaths(root).values()].filter((paths) => paths.length > 1);
830
+ return [...objectPaths(root, undefined, true).values()].filter(
831
+ (paths) => paths.length > 1,
832
+ );
708
833
  }
709
834
 
710
835
  /**
711
- * Whether the Map or Set `y` holds, at the position of an entry of `x`, another
712
- * object than `x` did (as to where its first appearance is, see aliasChanges):
713
- * `$map.set("k", $a)` over an equal object. Entries are compared by position,
714
- * and only when both hold as many; other differences are changes of content.
836
+ * Whether `y`, which takes the place of `x` at `at` (the first appearance of
837
+ * both), holds an object elsewhere than `x` did, below an entry of a Map, Set
838
+ * or Error (see aliasChanges): `$map.set("k", $a)` over an equal object, or
839
+ * `$map.get("k").child = $a`. Entries are compared by position, and only
840
+ * where both hold as many; other differences are changes of content. Below
841
+ * an entry, objects that merge are compared key by key.
715
842
  */
716
- function entryAliasChanged(
843
+ function aliasMoved(
717
844
  x: object,
718
845
  y: object,
846
+ at: string[],
719
847
  earlier: ReadonlyMap<object, string[]>,
720
848
  later: ReadonlyMap<object, string[]>,
721
849
  ): boolean {
722
- const isSet = x instanceof Set && y instanceof Set;
723
- if (!isSet && !(x instanceof Map && y instanceof Map)) return false;
724
- if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
725
- if (x.size !== y.size) return false;
726
- const moved = (a: unknown, b: unknown): boolean =>
727
- isObjectValue(a) &&
728
- isObjectValue(b) &&
729
- pathKey(earlier.get(a)!) !== pathKey(later.get(b)!);
730
- const ys = [...(y as Map<unknown, unknown>).entries()];
731
- let index = 0;
732
- for (const [k, v] of (x as Map<unknown, unknown>).entries()) {
733
- const [k2, v2] = ys[index++]!;
734
- if (moved(k, k2) || (!isSet && moved(v, v2))) return true;
850
+ const pairs: [string, unknown, unknown][] = [];
851
+ const xs = entryChildren(x);
852
+ const ys = entryChildren(y);
853
+ if (xs && ys) {
854
+ if (Object.getPrototypeOf(x) !== Object.getPrototypeOf(y)) return false;
855
+ if (x instanceof Map || x instanceof Set) {
856
+ if (x.size !== (y as Map<unknown, unknown>).size) return false;
857
+ }
858
+ const held = new Map(ys);
859
+ for (const [segment, a] of xs) {
860
+ if (held.has(segment)) pairs.push([segment, a, held.get(segment)]);
861
+ }
862
+ } else if ((isMergeable(x) || Array.isArray(x)) && mergesWith(x, y, true)) {
863
+ for (const key of Object.keys(y)) {
864
+ if (hasOwn(x, key)) {
865
+ pairs.push([key, (x as any)[key], (y as any)[key]]);
866
+ }
867
+ }
735
868
  }
736
- return false;
869
+ return pairs.some(([segment, a, b]) => {
870
+ if (!isObjectValue(a) || !isObjectValue(b)) return false;
871
+ const here = [...at, segment];
872
+ const isAt = later.get(b)!;
873
+ return (
874
+ pathKey(earlier.get(a)!) !== pathKey(isAt) ||
875
+ (pathKey(isAt) === pathKey(here) &&
876
+ aliasMoved(a, b, here, earlier, later))
877
+ );
878
+ });
737
879
  }
738
880
 
739
881
  /**
@@ -769,7 +911,8 @@ export function aliasChanges(
769
911
  changes.push({ path: at, deleted: false, value: y, alias: true });
770
912
  } else if (
771
913
  pathKey(isAt) === k &&
772
- entryAliasChanged(x, y, earlier, later)
914
+ entryChildren(x) &&
915
+ aliasMoved(x, y, at, earlier, later)
773
916
  ) {
774
917
  changes.push({ path: at, deleted: false, value: y, alias: true });
775
918
  }
@@ -823,6 +966,7 @@ export function locateObjects(
823
966
  return firstPaths(
824
967
  source as Record<string, unknown>,
825
968
  new Set(changes.map((c) => pathKey(c.path))),
969
+ true,
826
970
  );
827
971
  }
828
972
 
@@ -842,7 +986,7 @@ export function existingObjects(
842
986
  if (super.has(obj)) return true;
843
987
  const path = paths.get(obj);
844
988
  if (!path) return false;
845
- const found = getByPath(target, path);
989
+ const found = getByEntryPath(target, path);
846
990
  const like =
847
991
  isObjectValue(found) &&
848
992
  (Array.isArray(found)
@@ -867,50 +1011,22 @@ export function isApplied(target: object, change: PathChange): boolean {
867
1011
  );
868
1012
  }
869
1013
 
1014
+ /**
1015
+ * Make `path` below `root` hold `object` (an object that `sharedPaths` found
1016
+ * at several paths): a property is written like setByPath() does, a Map's
1017
+ * value replaced; an entry that is a key or member is left as it is.
1018
+ */
1019
+ export function relinkPath(
1020
+ root: Record<string, unknown>,
1021
+ path: readonly string[],
1022
+ object: object,
1023
+ ): void {
1024
+ if (path.some(isEntryKey)) setEntryValue(root, path, object);
1025
+ else setByPath(root, path, object);
1026
+ }
1027
+
870
1028
  export function applyChange(target: object, change: PathChange): void {
871
1029
  const root = target as Record<string, unknown>;
872
1030
  if (change.deleted) deleteByPath(root, change.path);
873
1031
  else setByPath(root, change.path, change.value);
874
1032
  }
875
-
876
- /**
877
- * Write how `after` differs from `before` (see keyChanges; arrays merge by
878
- * index) into `target`, another version of the same object or array.
879
- * Values that changed are written whole, as deep copies; for values that
880
- * merge, `mergeNested` may merge them into `target` instead, returning
881
- * whether it did. Into an array, elements removed from the end are removed
882
- * at the same indices and elements added are appended, and changes at
883
- * indices `target` lacks are dropped: where `target` was resized, its
884
- * indices do not line up with `before`'s.
885
- */
886
- export function mergeKeys(
887
- target: Record<string, unknown>,
888
- before: Record<string, unknown>,
889
- after: Record<string, unknown>,
890
- mergeNested?: (
891
- key: string,
892
- before: Record<string, unknown>,
893
- after: Record<string, unknown>,
894
- ) => boolean,
895
- ): void {
896
- const list = Array.isArray(target) ? (target as unknown[]) : undefined;
897
- for (const change of keyChanges(before, after, true)) {
898
- const { key } = change;
899
- if (change.kind === 'deleted') {
900
- if (list) list.length = Math.min(list.length, Number(key));
901
- else delete target[key];
902
- } else if (list && change.kind === 'added') {
903
- list.push(deepClone(change.after));
904
- } else if (list && Number(key) >= list.length) {
905
- continue;
906
- } else if (
907
- change.kind !== 'nested' ||
908
- !(
909
- mergeNested?.(key, change.before, change.after) ||
910
- deepEqual(change.before, change.after)
911
- )
912
- ) {
913
- target[key] = deepClone(change.after);
914
- }
915
- }
916
- }
@@ -7,13 +7,16 @@ import { atomicName } from './value-kinds';
7
7
  * Members of Object.prototype (`constructor`, `toString`, `__proto__`, ...)
8
8
  * are not story state: unless an object holds one as its own property, it
9
9
  * reads as missing, as setByPath() treats it. Other inherited properties
10
- * (class getters, `size` of a Map) are read.
10
+ * (class getters, `size` of a Map) are read, and so are those of a string,
11
+ * number or boolean (`length` of a string), as JavaScript boxes them.
11
12
  */
12
13
  export function getByPath(obj: object, segments: readonly string[]): unknown {
13
14
  let current: unknown = obj;
14
15
  for (const seg of segments) {
15
- if (current == null || typeof current !== 'object') return undefined;
16
- if (seg in Object.prototype && !hasOwn(current, seg)) return undefined;
16
+ if (current == null) return undefined;
17
+ if (seg in Object.prototype && !hasOwn(Object(current), seg)) {
18
+ return undefined;
19
+ }
17
20
  current = (current as Record<string, unknown>)[seg];
18
21
  }
19
22
  return current;