@rohal12/spindle 0.59.4 → 0.59.6
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +342 -118
- package/package.json +1 -1
- package/src/components/macros/Unset.tsx +5 -1
- package/src/define-macro.ts +6 -3
- package/src/execute-mutation.ts +93 -39
- package/src/store.ts +82 -35
- package/src/story-api.ts +2 -2
- package/src/structural.ts +309 -77
- package/src/utils/object-path.ts +6 -3
package/package.json
CHANGED
|
@@ -10,7 +10,11 @@ defineMacro({
|
|
|
10
10
|
const state = useStoryStore.getState();
|
|
11
11
|
const name = ctx.args.variable ?? '';
|
|
12
12
|
|
|
13
|
-
if (name
|
|
13
|
+
if (/^[$_%@][^.]*\./.test(name)) {
|
|
14
|
+
// A field of a variable: delete it as mutation code does, which
|
|
15
|
+
// resolves the path in every namespace
|
|
16
|
+
ctx.mutate(`delete ${name}`);
|
|
17
|
+
} else if (name.startsWith('$')) {
|
|
14
18
|
state.deleteVariable(name.slice(1));
|
|
15
19
|
} else if (name.startsWith('_')) {
|
|
16
20
|
state.deleteTemporary(name.slice(1));
|
package/src/define-macro.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
renderInlineNodes,
|
|
19
19
|
} from './markup/render';
|
|
20
20
|
import type { ASTNode } from './markup/ast';
|
|
21
|
-
import { executeMutation, readState } from './execute-mutation';
|
|
21
|
+
import { executeMutation, mutateState, readState } from './execute-mutation';
|
|
22
22
|
import { evaluate } from './expression';
|
|
23
23
|
import { useStoryStore } from './store';
|
|
24
24
|
import { getByPath, setByPath } from './utils/object-path';
|
|
@@ -228,8 +228,11 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
228
228
|
// In program order, also when mutation code performs the input
|
|
229
229
|
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
230
230
|
ctx.setValue = (value: unknown) => {
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
// The commit mutation code uses, which keeps the references among
|
|
232
|
+
// variables: a write to a draft copies only the path written, and
|
|
233
|
+
// other variables sharing the object would keep the old one.
|
|
234
|
+
mutateState((work, adopt) => {
|
|
235
|
+
setByPath(work.variables, segments, adopt(value), {
|
|
233
236
|
createMissing: true,
|
|
234
237
|
});
|
|
235
238
|
});
|
package/src/execute-mutation.ts
CHANGED
|
@@ -10,19 +10,18 @@ import { useStoryStore } from './store';
|
|
|
10
10
|
import type { StoryState, VariableNamespaces } from './store';
|
|
11
11
|
import { execute } from './expression';
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
changesBetween,
|
|
14
14
|
applyChange,
|
|
15
15
|
deepClone,
|
|
16
|
-
deepEqual,
|
|
17
|
-
diffPaths,
|
|
18
16
|
existingObjects,
|
|
17
|
+
getByEntryPath,
|
|
18
|
+
isEntryKey,
|
|
19
19
|
isApplied,
|
|
20
20
|
isMergeable,
|
|
21
21
|
locateObjects,
|
|
22
|
-
|
|
22
|
+
relinkPath,
|
|
23
23
|
pathKey,
|
|
24
24
|
sharedPaths,
|
|
25
|
-
underChange,
|
|
26
25
|
type PathChange,
|
|
27
26
|
} from './structural';
|
|
28
27
|
import { getByPath, setByPath } from './utils/object-path';
|
|
@@ -96,7 +95,23 @@ function clonerFor(
|
|
|
96
95
|
paths?: ReadonlyMap<object, string[]>,
|
|
97
96
|
): Cloner {
|
|
98
97
|
const seen = paths ? existingObjects(paths, target) : new Map();
|
|
99
|
-
|
|
98
|
+
// Immer does not finalize a draft held as a Map key or Set member, so an
|
|
99
|
+
// existing object met there is put in the draft as the object itself: its
|
|
100
|
+
// base while the code did not change it, which other references to it then
|
|
101
|
+
// agree with.
|
|
102
|
+
const settle = (copy: object, original: object): object => {
|
|
103
|
+
const path = paths?.get(original);
|
|
104
|
+
if (!path || !isDraft(copy)) return copy;
|
|
105
|
+
const plain = currentDraft(copy) as object;
|
|
106
|
+
// Where the object is in an entry of a Map or Set the path cannot be
|
|
107
|
+
// written (see getByEntryPath): that entry keeps its own
|
|
108
|
+
if (!path.some(isEntryKey)) {
|
|
109
|
+
setByPath(target as Record<string, unknown>, path, plain);
|
|
110
|
+
}
|
|
111
|
+
seen.set(original, plain);
|
|
112
|
+
return plain;
|
|
113
|
+
};
|
|
114
|
+
return (value) => deepClone(value, { keepUnregistered: true, seen, settle });
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
/** One cloner per target copy, made when first used. */
|
|
@@ -292,8 +307,10 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
292
307
|
)
|
|
293
308
|
: [];
|
|
294
309
|
});
|
|
295
|
-
// Each target takes its own copies, which share what the written values do
|
|
296
|
-
|
|
310
|
+
// Each target takes its own copies, which share what the written values do,
|
|
311
|
+
// and an object the code holds elsewhere stays that one (`Story.set('b', $a)`)
|
|
312
|
+
const held = locateObjects(namespacesOf(inner.work), changes);
|
|
313
|
+
const cloners = clonersFor(() => held);
|
|
297
314
|
const owned = (change: PathChange, target: object): PathChange =>
|
|
298
315
|
change.deleted
|
|
299
316
|
? change
|
|
@@ -347,10 +364,10 @@ function relink(
|
|
|
347
364
|
const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
|
|
348
365
|
if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
|
|
349
366
|
try {
|
|
350
|
-
const object =
|
|
367
|
+
const object = getByEntryPath(draft, first!);
|
|
351
368
|
if (object === null || typeof object !== 'object') continue;
|
|
352
369
|
for (const path of others)
|
|
353
|
-
|
|
370
|
+
relinkPath(draft as unknown as Record<string, unknown>, path, object);
|
|
354
371
|
} catch {
|
|
355
372
|
// A path the store does not hold: the code's view is not the store's
|
|
356
373
|
}
|
|
@@ -370,6 +387,13 @@ function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
|
|
|
370
387
|
};
|
|
371
388
|
}
|
|
372
389
|
|
|
390
|
+
/** The three variable namespaces of `state`, as an object to walk. */
|
|
391
|
+
const namespacesOf = (state: VariableNamespaces): VariableNamespaces => ({
|
|
392
|
+
variables: state.variables,
|
|
393
|
+
temporary: state.temporary,
|
|
394
|
+
transient: state.transient,
|
|
395
|
+
});
|
|
396
|
+
|
|
373
397
|
/** A mutation to commit, and whether its code goes on running after. */
|
|
374
398
|
interface Commit {
|
|
375
399
|
scope: MutationScope;
|
|
@@ -470,28 +494,16 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
470
494
|
|
|
471
495
|
/**
|
|
472
496
|
* The changes of the code to the namespaces, as paths below them (`['variables',
|
|
473
|
-
* 'a']
|
|
474
|
-
* equal content but another object than they did (see aliasChanges), those
|
|
475
|
-
* first. Compared as one object, so an object the namespaces share is one
|
|
476
|
-
* object, whichever of them it is reached through.
|
|
497
|
+
* 'a']`; see changesBetween).
|
|
477
498
|
*/
|
|
478
|
-
|
|
499
|
+
const changesOf = (
|
|
479
500
|
base: VariableNamespaces,
|
|
480
501
|
work: VariableNamespaces,
|
|
481
|
-
): PathChange[]
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const aliases: PathChange[] = [];
|
|
487
|
-
for (const change of aliasChanges(before, after)) {
|
|
488
|
-
// Written whole by a change at it or above it
|
|
489
|
-
if (underChange(change.path, keys)) continue;
|
|
490
|
-
keys.add(pathKey(change.path));
|
|
491
|
-
aliases.push(change);
|
|
492
|
-
}
|
|
493
|
-
return [...aliases, ...written];
|
|
494
|
-
}
|
|
502
|
+
): PathChange[] =>
|
|
503
|
+
changesBetween(
|
|
504
|
+
base as unknown as Record<string, unknown>,
|
|
505
|
+
work as unknown as Record<string, unknown>,
|
|
506
|
+
);
|
|
495
507
|
|
|
496
508
|
/** Every running mutation, to commit with its code going on. */
|
|
497
509
|
const running = (): Commit[] =>
|
|
@@ -500,14 +512,31 @@ const running = (): Commit[] =>
|
|
|
500
512
|
/**
|
|
501
513
|
* Bring a suspended mutation's copies up to the store after an action
|
|
502
514
|
* replaced or changed state under it (navigation clears temporaries, back
|
|
503
|
-
* and restart replace variables).
|
|
504
|
-
* the code
|
|
515
|
+
* and restart replace variables). What the action changed (see
|
|
516
|
+
* changesBetween) is written into the code's working copy, which keeps the
|
|
517
|
+
* objects the code may hold where nothing changed, and the references among
|
|
518
|
+
* the values written, and to the objects it holds already, as a commit does.
|
|
505
519
|
*/
|
|
506
520
|
function resync(scope: MutationScope): void {
|
|
507
521
|
const state = useStoryStore.getState();
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
522
|
+
const now = namespacesOf(state);
|
|
523
|
+
const changes = changesBetween(
|
|
524
|
+
scope.base as unknown as Record<string, unknown>,
|
|
525
|
+
now as unknown as Record<string, unknown>,
|
|
526
|
+
);
|
|
527
|
+
const copy = clonerFor(scope.work, locateObjects(now, changes));
|
|
528
|
+
for (const change of changes) {
|
|
529
|
+
try {
|
|
530
|
+
applyChange(
|
|
531
|
+
scope.work,
|
|
532
|
+
change.deleted ? change : { ...change, value: copy(change.value) },
|
|
533
|
+
);
|
|
534
|
+
} catch {
|
|
535
|
+
// A path the working copy does not hold: replace its root instead
|
|
536
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
537
|
+
if (hasOwn(now[ns], root)) scope.work[ns][root] = copy(now[ns][root]);
|
|
538
|
+
else delete scope.work[ns][root];
|
|
539
|
+
}
|
|
511
540
|
}
|
|
512
541
|
scope.base = cloneNamespaces(state);
|
|
513
542
|
}
|
|
@@ -537,13 +566,23 @@ export function runWithCommittedMutations<T>(action: () => T): T {
|
|
|
537
566
|
* between values are kept, which a store update made on a draft does not do.
|
|
538
567
|
* Nothing is committed when `run` throws.
|
|
539
568
|
*/
|
|
540
|
-
export function mutateState(
|
|
569
|
+
export function mutateState(
|
|
570
|
+
run: (work: VariableNamespaces, adopt: Cloner) => void,
|
|
571
|
+
): void {
|
|
541
572
|
// The writes of the code this runs inside come first, and stay even if
|
|
542
573
|
// `run` throws
|
|
543
574
|
commitScopes(running());
|
|
575
|
+
// Where the objects `run` may be handed are: in the state it continues from
|
|
576
|
+
const held = locateObjects(
|
|
577
|
+
namespacesOf(getActiveMutationScope() ?? useStoryStore.getState()),
|
|
578
|
+
[],
|
|
579
|
+
);
|
|
544
580
|
const scope = startScope();
|
|
581
|
+
// Copies values into the working copy, an object it holds already staying
|
|
582
|
+
// that one, and what the values share staying shared
|
|
583
|
+
const adopt = clonerFor(scope.work, held);
|
|
545
584
|
try {
|
|
546
|
-
run(scope.work);
|
|
585
|
+
run(scope.work, adopt);
|
|
547
586
|
} finally {
|
|
548
587
|
activeScopes.pop();
|
|
549
588
|
}
|
|
@@ -595,11 +634,26 @@ export function executeMutation(
|
|
|
595
634
|
// came before its own
|
|
596
635
|
commitScopes([...running(), { scope, keepRunning: false }]);
|
|
597
636
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
637
|
+
// Locals the code changed, as content or as the objects they refer to
|
|
638
|
+
// (`@a = @b` over an equal object), and the locals that share an object
|
|
639
|
+
// with one of those: the updater takes each one's value, so they must all
|
|
640
|
+
// be written for the scope to hold one object again
|
|
641
|
+
const changed = new Set(
|
|
642
|
+
changesBetween(mergedLocals, localsClone, true).map((c) => c.path[0]!),
|
|
643
|
+
);
|
|
644
|
+
for (const group of sharedPaths(localsClone)) {
|
|
645
|
+
if (group.some((path) => changed.has(path[0]!))) {
|
|
646
|
+
// Not a value kept by reference (an unregistered class instance)
|
|
647
|
+
for (const path of group) {
|
|
648
|
+
if (localsClone[path[0]!] !== mergedLocals[path[0]!]) {
|
|
649
|
+
changed.add(path[0]!);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
601
652
|
}
|
|
602
653
|
}
|
|
654
|
+
for (const key of Object.keys(localsClone)) {
|
|
655
|
+
if (changed.has(key)) scopeUpdate(key, localsClone[key]);
|
|
656
|
+
}
|
|
603
657
|
|
|
604
658
|
// Detect deleted locals
|
|
605
659
|
for (const key of Object.keys(mergedLocals)) {
|
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 {
|
|
48
|
-
|
|
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 (
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
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,78 @@ function keepHookWrites(
|
|
|
721
731
|
): void {
|
|
722
732
|
const moment = payload.history[payload.historyIndex];
|
|
723
733
|
if (!moment) return;
|
|
724
|
-
|
|
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
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
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
|
|
736
|
-
|
|
737
|
-
before: Record<string, unknown>,
|
|
754
|
+
function writeHookChange(
|
|
755
|
+
work: Record<string, unknown>,
|
|
738
756
|
after: Record<string, unknown>,
|
|
739
|
-
|
|
757
|
+
change: PathChange,
|
|
758
|
+
own: <T>(value: T) => T,
|
|
740
759
|
): void {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
) {
|
|
749
|
-
|
|
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, own(held));
|
|
769
|
+
return;
|
|
750
770
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
771
|
+
holder = child as Record<string, unknown>;
|
|
772
|
+
}
|
|
773
|
+
const key = path[depth]!;
|
|
774
|
+
if (change.deleted) writeKey(holder, key, undefined, change);
|
|
775
|
+
else writeKey(holder, key, own(change.value), change);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function writeKey(
|
|
779
|
+
holder: Record<string, unknown>,
|
|
780
|
+
key: string,
|
|
781
|
+
value: unknown,
|
|
782
|
+
change?: PathChange,
|
|
783
|
+
): void {
|
|
784
|
+
const deleted = change?.deleted ?? false;
|
|
785
|
+
if (Array.isArray(holder)) {
|
|
786
|
+
const index = Number(key);
|
|
787
|
+
if (deleted) {
|
|
788
|
+
// A hole stays where it was; anything else was cut off
|
|
789
|
+
if (change && 'hole' in change) delete holder[index];
|
|
790
|
+
else holder.length = Math.min(holder.length, index);
|
|
791
|
+
} else if (change && 'appended' in change) {
|
|
792
|
+
// After the holes it followed
|
|
793
|
+
holder.length += change.gap ?? 0;
|
|
794
|
+
holder.push(value);
|
|
795
|
+
} else if (key === 'length' && change) {
|
|
796
|
+
// Holes added at the end
|
|
797
|
+
holder.length += value as number;
|
|
798
|
+
} else if (index < holder.length) {
|
|
799
|
+
holder[index] = value;
|
|
800
|
+
}
|
|
801
|
+
} else if (deleted) {
|
|
802
|
+
delete holder[key];
|
|
803
|
+
} else {
|
|
804
|
+
setOwn(holder, key, value);
|
|
805
|
+
}
|
|
759
806
|
}
|
|
760
807
|
|
|
761
808
|
/** 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
|
}
|