@rohal12/spindle 0.59.4 → 0.59.5

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/store.ts CHANGED
@@ -44,8 +44,16 @@ import {
44
44
  deletePlaythroughData as smDeletePlaythroughData,
45
45
  } from './saves/save-manager';
46
46
 
47
- import { deepClone, mergeKeys, mergesWith, shareEqual } from './structural';
48
- import { shallowCopy } from './utils/object-path';
47
+ import {
48
+ changesBetween,
49
+ deepClone,
50
+ existingObjects,
51
+ locateObjects,
52
+ mergesWith,
53
+ shareEqual,
54
+ type PathChange,
55
+ } from './structural';
56
+ import { getByPath } from './utils/object-path';
49
57
  import { noPassageError, showRuntimeError } from './runtime-errors';
50
58
  import {
51
59
  snapshotPRNG,
@@ -708,11 +716,13 @@ function loadedEntryMoment(
708
716
  * the `beforesave` hooks) into the payload's snapshot of the saved moment. A
709
717
  * load restores that snapshot, the state on entering the passage, and runs
710
718
  * the passage again, so data a hook adds to a save would otherwise be lost
711
- * on load (#227). Only the property paths the hooks changed are written (into
712
- * copies of the objects on those paths, the rest staying shared): a
713
- * whole variable would bring along what the passage did to the rest of it,
714
- * which the passage then does again on load (#232). Only the payload's copy
715
- * changes: the live history keeps the recorded snapshot (#159).
719
+ * on load (#227). Only the property paths the hooks changed are written (see
720
+ * changesBetween): a whole variable would bring along what the passage did
721
+ * to the rest of it, which the passage then does again on load (#232). The
722
+ * references the hooks made stay: an object they put in two variables is one
723
+ * object in the snapshot, and one they made another variable refer to is the
724
+ * snapshot's own object of it (#302). Only the payload's copy changes: the
725
+ * live history keeps the recorded snapshot (#159).
716
726
  */
717
727
  function keepHookWrites(
718
728
  payload: SavePayload,
@@ -721,41 +731,67 @@ function keepHookWrites(
721
731
  ): void {
722
732
  const moment = payload.history[payload.historyIndex];
723
733
  if (!moment) return;
724
- mergeHookWrites(moment.variables, before, after, new Set());
734
+ const changes = changesBetween(before, after, true);
735
+ if (changes.length === 0) return;
736
+ // A copy of its own, so the writes keep the snapshot's references (which
737
+ // the history shares with other moments) as they are
738
+ const work = deepClone(moment.variables);
739
+ const seen = existingObjects(locateObjects(after, changes), work);
740
+ const own = <T>(value: T): T => deepClone(value, { seen });
741
+ for (const change of changes) writeHookChange(work, after, change, own);
742
+ moment.variables = shareEqual(moment.variables, work);
725
743
  }
726
744
 
727
745
  /**
728
- * Apply the hooks' changes between `before` and `after`, two objects or two
729
- * arrays, to the snapshot's copy of them, `target` (see mergeKeys: array
730
- * elements merge by index). A changed value is merged key by key where the
731
- * snapshot holds the same kind of value. Where it lacks the key or holds
732
- * another kind (the passage created or replaced it), the hooks' whole value
733
- * is written.
746
+ * Write `change` (of the hooks, to `after`) into `work`. The value goes in at
747
+ * the first place `work` lacks the objects on the path of one of the kind
748
+ * `after` holds (the passage created or replaced it): there, the hooks' whole
749
+ * value of it is written. Into an array, elements removed from the end are
750
+ * removed at the same indices and elements added are appended; changes at
751
+ * indices the array lacks are dropped (it was resized, so its indices do not
752
+ * line up with the live ones).
734
753
  */
735
- function mergeHookWrites(
736
- target: Record<string, unknown>,
737
- before: Record<string, unknown>,
754
+ function writeHookChange(
755
+ work: Record<string, unknown>,
738
756
  after: Record<string, unknown>,
739
- ancestors: Set<object>,
757
+ change: PathChange,
758
+ own: <T>(value: T) => T,
740
759
  ): void {
741
- ancestors.add(after);
742
- mergeKeys(target, before, after, (key, b, a) => {
743
- if (
744
- !hasOwn(target, key) ||
745
- !mergesWith(a, target[key], true) ||
746
- // Stop at cycles: deepEqual() and deepClone() handle them
747
- ancestors.has(a)
748
- ) {
749
- return false;
760
+ const { path } = change;
761
+ let holder: Record<string, unknown> = work;
762
+ let depth = 0;
763
+ for (; depth < path.length - 1; depth++) {
764
+ const key = path[depth]!;
765
+ const child = hasOwn(holder, key) ? holder[key] : undefined;
766
+ const held = getByPath(after, path.slice(0, depth + 1));
767
+ if (!mergesWith(child, held, true)) {
768
+ writeKey(holder, key, false, own(held));
769
+ return;
750
770
  }
751
- // The snapshot's values are the history's own (immutable, see
752
- // plainCopy): merge into a copy, made along the merged path only
753
- const copy = shallowCopy(target[key] as object);
754
- target[key] = copy;
755
- mergeHookWrites(copy, b, a, ancestors);
756
- return true;
757
- });
758
- ancestors.delete(after);
771
+ holder = child as Record<string, unknown>;
772
+ }
773
+ const key = path[depth]!;
774
+ if (change.deleted) writeKey(holder, key, true, undefined);
775
+ else writeKey(holder, key, false, own(change.value), change.appended);
776
+ }
777
+
778
+ function writeKey(
779
+ holder: Record<string, unknown>,
780
+ key: string,
781
+ deleted: boolean,
782
+ value: unknown,
783
+ appended?: boolean,
784
+ ): void {
785
+ if (Array.isArray(holder)) {
786
+ const index = Number(key);
787
+ if (deleted) holder.length = Math.min(holder.length, index);
788
+ else if (appended) holder.push(value);
789
+ else if (index < holder.length) holder[index] = value;
790
+ } else if (deleted) {
791
+ delete holder[key];
792
+ } else {
793
+ setOwn(holder, key, value);
794
+ }
759
795
  }
760
796
 
761
797
  /** Restore the PRNG from a snapshot, or reset it without one. */
package/src/story-api.ts CHANGED
@@ -307,8 +307,8 @@ function createStoryAPI(): StoryAPI {
307
307
  // uses (nested in the running code, if any: it continues from the
308
308
  // code's pending writes, and they are committed first): an update of a draft would give the written path new
309
309
  // objects and leave the other references to the old ones (#295).
310
- mutateState((work) => {
311
- for (const [k, v] of entries) setOne(work, k, v);
310
+ mutateState((work, adopt) => {
311
+ for (const [k, v] of entries) setOne(work, k, adopt(v));
312
312
  });
313
313
  return;
314
314
  }
package/src/structural.ts CHANGED
@@ -42,6 +42,12 @@ export interface DeepCloneOptions {
42
42
  * is the copy already made. The map is filled as values are copied.
43
43
  */
44
44
  seen?: Map<object, object>;
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).
49
+ */
50
+ mapKey?: (copy: object, original: object) => object;
45
51
  }
46
52
 
47
53
  type TypedArrayCtor = new (
@@ -133,7 +139,14 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
133
139
  if (val instanceof Map) {
134
140
  const copy = keep(obj, new Map());
135
141
  for (const [k, v] of val) {
136
- copy.set(clone(k), clone(v));
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
+ );
137
150
  }
138
151
  return copy;
139
152
  }
@@ -533,6 +546,8 @@ export type PathChange =
533
546
  path: string[];
534
547
  deleted: false;
535
548
  value: unknown;
549
+ /** Set for an element an array gained beyond the length it had. */
550
+ appended?: true;
536
551
  /**
537
552
  * Set where the value is the same content as before and only which
538
553
  * object the path refers to changed (see aliasChanges), so a store
@@ -545,11 +560,13 @@ export type PathChange =
545
560
  /**
546
561
  * The property paths where `after` differs from `before`, two objects of
547
562
  * one class: objects that merge (see isMergeable) are compared property by
548
- * property, any other value as a whole.
563
+ * property, any other value as a whole; with `arrays`, arrays are compared
564
+ * by index too (see keyChanges).
549
565
  */
550
566
  export function diffPaths(
551
567
  before: Record<string, unknown>,
552
568
  after: Record<string, unknown>,
569
+ arrays = false,
553
570
  ): PathChange[] {
554
571
  const changes: PathChange[] = [];
555
572
  const ancestors = new Set<object>();
@@ -561,7 +578,7 @@ export function diffPaths(
561
578
  // Stop at cycles; shared (non-cyclic) references are visited per path.
562
579
  if (ancestors.has(a)) return;
563
580
  ancestors.add(a);
564
- for (const change of keyChanges(b, a, false)) {
581
+ for (const change of keyChanges(b, a, arrays)) {
565
582
  const at = [...path, change.key];
566
583
  if (change.kind === 'nested') {
567
584
  walk(change.before, change.after, at);
@@ -569,7 +586,14 @@ export function diffPaths(
569
586
  changes.push(
570
587
  change.kind === 'deleted'
571
588
  ? { path: at, deleted: true }
572
- : { path: at, deleted: false, value: change.after },
589
+ : {
590
+ path: at,
591
+ deleted: false,
592
+ value: change.after,
593
+ ...(change.kind === 'added' && Array.isArray(a)
594
+ ? { appended: true as const }
595
+ : {}),
596
+ },
573
597
  );
574
598
  }
575
599
  }
@@ -592,35 +616,71 @@ export function underChange(
592
616
  return false;
593
617
  }
594
618
 
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}`;
622
+
595
623
  /**
596
624
  * Every path each object (or array, or other value) of `root` is at, the
597
625
  * first appearance first, in depth-first order. Plain objects and arrays are
598
626
  * entered, where first met only (an object held in an array is one object,
599
627
  * however it was reached); other values are leaves. A path at or below one
600
628
  * of `skip` is left out.
629
+ *
630
+ * With `entries`, the objects held in a Map or Set (as key, value or member)
631
+ * are placed too, after everything else: one met nowhere else is at the path
632
+ * 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.
601
634
  */
602
635
  function objectPaths(
603
636
  root: object,
604
637
  skip: ReadonlySet<string> = new Set(),
638
+ entries = false,
605
639
  ): Map<object, string[][]> {
606
640
  const all = new Map<object, string[][]>();
607
- (function walk(node: Record<string, unknown>, path: string[]): void {
641
+ const collections: [Map<unknown, unknown> | Set<unknown>, string[]][] = [];
642
+ function place(value: object, at: string[]): void {
643
+ const known = all.get(value);
644
+ if (known) {
645
+ known.push(at);
646
+ return;
647
+ }
648
+ all.set(value, [at]);
649
+ if (isMergeable(value) || Array.isArray(value)) {
650
+ walk(value as Record<string, unknown>, at);
651
+ } else if (entries && (value instanceof Map || value instanceof Set)) {
652
+ collections.push([value, at]);
653
+ }
654
+ }
655
+ function walk(node: Record<string, unknown>, path: string[]): void {
608
656
  for (const key of Object.keys(node)) {
609
657
  const value = node[key];
610
658
  if (!isObjectValue(value)) continue;
611
659
  const at = [...path, key];
612
660
  if (skip.has(pathKey(at))) continue;
613
- const known = all.get(value);
614
- if (known) {
615
- known.push(at);
616
- continue;
617
- }
618
- all.set(value, [at]);
619
- if (isMergeable(value) || Array.isArray(value)) {
620
- walk(value as Record<string, unknown>, at);
661
+ place(value, at);
662
+ }
663
+ }
664
+ walk(root as Record<string, unknown>, []);
665
+ 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
+ }
621
680
  }
681
+ index++;
622
682
  }
623
- })(root as Record<string, unknown>, []);
683
+ }
624
684
  return all;
625
685
  }
626
686
 
@@ -628,9 +688,13 @@ function objectPaths(
628
688
  function firstPaths(
629
689
  root: object,
630
690
  skip?: ReadonlySet<string>,
691
+ entries?: boolean,
631
692
  ): Map<object, string[]> {
632
693
  return new Map(
633
- [...objectPaths(root, skip)].map(([object, paths]) => [object, paths[0]!]),
694
+ [...objectPaths(root, skip, entries)].map(([object, paths]) => [
695
+ object,
696
+ paths[0]!,
697
+ ]),
634
698
  );
635
699
  }
636
700
 
@@ -643,6 +707,35 @@ export function sharedPaths(root: object): string[][][] {
643
707
  return [...objectPaths(root).values()].filter((paths) => paths.length > 1);
644
708
  }
645
709
 
710
+ /**
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.
715
+ */
716
+ function entryAliasChanged(
717
+ x: object,
718
+ y: object,
719
+ earlier: ReadonlyMap<object, string[]>,
720
+ later: ReadonlyMap<object, string[]>,
721
+ ): 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;
735
+ }
736
+ return false;
737
+ }
738
+
646
739
  /**
647
740
  * The paths whose object is another one in the references of `after` than in
648
741
  * `before`, as to where its first appearance is: `$a = $b` makes `a` another
@@ -655,8 +748,8 @@ export function aliasChanges(
655
748
  before: Record<string, unknown>,
656
749
  after: Record<string, unknown>,
657
750
  ): PathChange[] {
658
- const earlier = firstPaths(before);
659
- const later = firstPaths(after);
751
+ const earlier = firstPaths(before, undefined, true);
752
+ const later = firstPaths(after, undefined, true);
660
753
  const changes: PathChange[] = [];
661
754
  (function walk(
662
755
  b: Record<string, unknown>,
@@ -674,6 +767,11 @@ export function aliasChanges(
674
767
  const k = pathKey(at);
675
768
  if (pathKey(wasAt) !== pathKey(isAt)) {
676
769
  changes.push({ path: at, deleted: false, value: y, alias: true });
770
+ } else if (
771
+ pathKey(isAt) === k &&
772
+ entryAliasChanged(x, y, earlier, later)
773
+ ) {
774
+ changes.push({ path: at, deleted: false, value: y, alias: true });
677
775
  }
678
776
  // Only paths that are first appearances are entered: elsewhere the
679
777
  // contents are those of the object met first.
@@ -690,6 +788,30 @@ export function aliasChanges(
690
788
  return changes;
691
789
  }
692
790
 
791
+ /**
792
+ * The changes from `before` to `after`, as paths below them: the property
793
+ * paths that differ (see diffPaths), and those that hold equal content but
794
+ * another object than they did (see aliasChanges), those first. Compared as
795
+ * one object, so an object that two of their keys share is one object,
796
+ * whichever of them it is reached through.
797
+ */
798
+ export function changesBetween(
799
+ before: Record<string, unknown>,
800
+ after: Record<string, unknown>,
801
+ arrays = false,
802
+ ): PathChange[] {
803
+ const written = diffPaths(before, after, arrays);
804
+ const keys = new Set(written.map((c) => pathKey(c.path)));
805
+ const aliases: PathChange[] = [];
806
+ for (const change of aliasChanges(before, after)) {
807
+ // Written whole by a change at it or above it
808
+ if (underChange(change.path, keys)) continue;
809
+ keys.add(pathKey(change.path));
810
+ aliases.push(change);
811
+ }
812
+ return [...aliases, ...written];
813
+ }
814
+
693
815
  /**
694
816
  * Where the objects of `source` are, other than at or below the paths of
695
817
  * `changes` (which write something new there), for `existingObjects`.