@rohal12/spindle 0.59.3 → 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
@@ -21,7 +21,11 @@ import {
21
21
  } from './saves/save-manager';
22
22
  import { getBackendType } from './saves/storage';
23
23
  import { registerClass } from './class-registry';
24
- import { frozenCopy, getActiveMutationScope } from './execute-mutation';
24
+ import {
25
+ frozenCopy,
26
+ getActiveMutationScope,
27
+ mutateState,
28
+ } from './execute-mutation';
25
29
  import { getByPath, setByPath } from './utils/object-path';
26
30
  import { changedNames, checkVariableName, ownValue } from './utils/namespace';
27
31
  import { historyQueries } from './expression';
@@ -298,6 +302,16 @@ function createStoryAPI(): StoryAPI {
298
302
  // while mutation code runs ({do}, ctx.mutate, watcher run actions), it
299
303
  // follows the code's own pending writes (program order, #215): see
300
304
  // routeStoreUpdate.
305
+ if (entries.some(([k]) => k.includes('.'))) {
306
+ // A write below a variable goes through the commit mutation code
307
+ // uses (nested in the running code, if any: it continues from the
308
+ // code's pending writes, and they are committed first): an update of a draft would give the written path new
309
+ // objects and leave the other references to the old ones (#295).
310
+ mutateState((work, adopt) => {
311
+ for (const [k, v] of entries) setOne(work, k, adopt(v));
312
+ });
313
+ return;
314
+ }
301
315
  useStoryStore.getState().updateVariables((draft) => {
302
316
  for (const [k, v] of entries) setOne(draft, k, v);
303
317
  });
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,27 +616,124 @@ 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
- * Where each object (or array, or other value) of `root` first appears, in
597
- * depth-first order. Plain objects are entered; arrays and other values are
598
- * leaves. A path at or below one of `skip` is left out.
624
+ * Every path each object (or array, or other value) of `root` is at, the
625
+ * first appearance first, in depth-first order. Plain objects and arrays are
626
+ * entered, where first met only (an object held in an array is one object,
627
+ * however it was reached); other values are leaves. A path at or below one
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.
599
634
  */
600
- function firstPaths(
601
- root: Record<string, unknown>,
635
+ function objectPaths(
636
+ root: object,
602
637
  skip: ReadonlySet<string> = new Set(),
603
- ): Map<object, string[]> {
604
- const found = new Map<object, string[]>();
605
- (function walk(node: Record<string, unknown>, path: string[]): void {
638
+ entries = false,
639
+ ): Map<object, string[][]> {
640
+ const all = new Map<object, string[][]>();
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 {
606
656
  for (const key of Object.keys(node)) {
607
657
  const value = node[key];
608
- if (!isObjectValue(value) || found.has(value)) continue;
658
+ if (!isObjectValue(value)) continue;
609
659
  const at = [...path, key];
610
660
  if (skip.has(pathKey(at))) continue;
611
- found.set(value, at);
612
- if (isMergeable(value)) walk(value, 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
+ }
680
+ }
681
+ index++;
613
682
  }
614
- })(root, []);
615
- return found;
683
+ }
684
+ return all;
685
+ }
686
+
687
+ /** Where each object of `root` first appears (see objectPaths). */
688
+ function firstPaths(
689
+ root: object,
690
+ skip?: ReadonlySet<string>,
691
+ entries?: boolean,
692
+ ): Map<object, string[]> {
693
+ return new Map(
694
+ [...objectPaths(root, skip, entries)].map(([object, paths]) => [
695
+ object,
696
+ paths[0]!,
697
+ ]),
698
+ );
699
+ }
700
+
701
+ /**
702
+ * The objects `root` holds at more than one path (an object two variables
703
+ * refer to, or one that refers to itself), with every path they are at,
704
+ * parents before children.
705
+ */
706
+ export function sharedPaths(root: object): string[][][] {
707
+ return [...objectPaths(root).values()].filter((paths) => paths.length > 1);
708
+ }
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;
616
737
  }
617
738
 
618
739
  /**
@@ -627,8 +748,8 @@ export function aliasChanges(
627
748
  before: Record<string, unknown>,
628
749
  after: Record<string, unknown>,
629
750
  ): PathChange[] {
630
- const earlier = firstPaths(before);
631
- const later = firstPaths(after);
751
+ const earlier = firstPaths(before, undefined, true);
752
+ const later = firstPaths(after, undefined, true);
632
753
  const changes: PathChange[] = [];
633
754
  (function walk(
634
755
  b: Record<string, unknown>,
@@ -646,31 +767,63 @@ export function aliasChanges(
646
767
  const k = pathKey(at);
647
768
  if (pathKey(wasAt) !== pathKey(isAt)) {
648
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 });
649
775
  }
650
776
  // Only paths that are first appearances are entered: elsewhere the
651
777
  // contents are those of the object met first.
652
778
  if (
653
- isMergeable(x) &&
654
- mergesWith(x, y, false) &&
779
+ (isMergeable(x) || Array.isArray(x)) &&
780
+ mergesWith(x, y, true) &&
655
781
  pathKey(isAt) === k &&
656
782
  pathKey(wasAt) === k
657
783
  ) {
658
- walk(x, y as Record<string, unknown>, at);
784
+ walk(x as Record<string, unknown>, y as Record<string, unknown>, at);
659
785
  }
660
786
  }
661
787
  })(before, after, []);
662
788
  return changes;
663
789
  }
664
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
+
665
815
  /**
666
816
  * Where the objects of `source` are, other than at or below the paths of
667
817
  * `changes` (which write something new there), for `existingObjects`.
668
818
  */
669
819
  export function locateObjects(
670
- source: Record<string, unknown>,
820
+ source: object,
671
821
  changes: readonly PathChange[],
672
822
  ): Map<object, string[]> {
673
- return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
823
+ return firstPaths(
824
+ source as Record<string, unknown>,
825
+ new Set(changes.map((c) => pathKey(c.path))),
826
+ );
674
827
  }
675
828
 
676
829
  /**
@@ -682,7 +835,7 @@ export function locateObjects(
682
835
  */
683
836
  export function existingObjects(
684
837
  paths: ReadonlyMap<object, string[]>,
685
- target: Record<string, unknown>,
838
+ target: object,
686
839
  ): Map<object, object> {
687
840
  return new (class extends Map<object, object> {
688
841
  override has(obj: object): boolean {
@@ -703,10 +856,7 @@ export function existingObjects(
703
856
  }
704
857
 
705
858
  /** Whether `target` already holds what `change` would write. */
706
- export function isApplied(
707
- target: Record<string, unknown>,
708
- change: PathChange,
709
- ): boolean {
859
+ export function isApplied(target: object, change: PathChange): boolean {
710
860
  const parent = getByPath(target, change.path.slice(0, -1));
711
861
  if (parent === null || typeof parent !== 'object') return change.deleted;
712
862
  const key = change.path[change.path.length - 1]!;
@@ -717,12 +867,10 @@ export function isApplied(
717
867
  );
718
868
  }
719
869
 
720
- export function applyChange(
721
- target: Record<string, unknown>,
722
- change: PathChange,
723
- ): void {
724
- if (change.deleted) deleteByPath(target, change.path);
725
- else setByPath(target, change.path, change.value);
870
+ export function applyChange(target: object, change: PathChange): void {
871
+ const root = target as Record<string, unknown>;
872
+ if (change.deleted) deleteByPath(root, change.path);
873
+ else setByPath(root, change.path, change.value);
726
874
  }
727
875
 
728
876
  /**
@@ -9,10 +9,7 @@ import { atomicName } from './value-kinds';
9
9
  * reads as missing, as setByPath() treats it. Other inherited properties
10
10
  * (class getters, `size` of a Map) are read.
11
11
  */
12
- export function getByPath(
13
- obj: Record<string, unknown>,
14
- segments: readonly string[],
15
- ): unknown {
12
+ export function getByPath(obj: object, segments: readonly string[]): unknown {
16
13
  let current: unknown = obj;
17
14
  for (const seg of segments) {
18
15
  if (current == null || typeof current !== 'object') return undefined;
@@ -38,7 +35,8 @@ const builtinName = atomicName;
38
35
  */
39
36
  export function shallowCopy(value: object): Record<string, unknown> {
40
37
  const copy = Array.isArray(value)
41
- ? []
38
+ ? // Keep the length: trailing holes are not among the keys
39
+ new Array<unknown>(value.length)
42
40
  : (Object.create(Object.getPrototypeOf(value) as object | null) as object);
43
41
  // Define rather than assign (Object.assign), so that a "__proto__" key
44
42
  // stays a key instead of replacing the copy's prototype