@rohal12/spindle 0.51.3 → 0.52.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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +4833 -1603
- package/dist/pkg/macro-registry.json +7 -7
- package/dist/pkg/story-variables.js +1658 -189
- package/package.json +5 -2
- package/src/automation/runner.ts +2 -1
- package/src/class-registry.ts +277 -90
- package/src/components/Passage.tsx +2 -2
- package/src/components/PassageDialog.tsx +2 -5
- package/src/components/StoryInterface.tsx +2 -4
- package/src/components/macros/Button.tsx +9 -32
- package/src/components/macros/Checkbox.tsx +10 -4
- package/src/components/macros/Computed.tsx +19 -13
- package/src/components/macros/Dialog.tsx +4 -1
- package/src/components/macros/For.tsx +33 -4
- package/src/components/macros/If.tsx +8 -0
- package/src/components/macros/Include.tsx +15 -24
- package/src/components/macros/MacroError.tsx +2 -1
- package/src/components/macros/MacroLink.tsx +14 -46
- package/src/components/macros/Meter.tsx +26 -40
- package/src/components/macros/Nobr.tsx +1 -0
- package/src/components/macros/PassageDisplay.tsx +3 -0
- package/src/components/macros/Print.tsx +4 -0
- package/src/components/macros/Radiobutton.tsx +27 -2
- package/src/components/macros/SaveManager.tsx +39 -14
- package/src/components/macros/Span.tsx +1 -0
- package/src/components/macros/StoryTitle.tsx +1 -0
- package/src/components/macros/Switch.tsx +13 -0
- package/src/components/macros/Unset.tsx +30 -10
- package/src/components/macros/VarDisplay.tsx +21 -4
- package/src/components/macros/Watch.tsx +88 -40
- package/src/components/macros/Widget.tsx +20 -1
- package/src/components/macros/WidgetInvocation.tsx +20 -158
- package/src/components/macros/arg-utils.ts +226 -0
- package/src/components/macros/detached-body.tsx +68 -0
- package/src/components/macros/option-utils.ts +10 -5
- package/src/define-macro.ts +44 -38
- package/src/execute-mutation.ts +499 -28
- package/src/expression.ts +88 -272
- package/src/hooks/use-action.ts +18 -3
- package/src/hooks/use-interpolate.ts +36 -5
- package/src/index.tsx +10 -1
- package/src/interpolation.ts +394 -96
- package/src/js-lexer.ts +1460 -0
- package/src/markup/code-attributes.ts +64 -0
- package/src/markup/markdown.ts +188 -9
- package/src/markup/render.tsx +552 -113
- package/src/markup/tokenizer.ts +601 -119
- package/src/prng.ts +8 -8
- package/src/registry.ts +35 -0
- package/src/saves/save-manager.ts +368 -153
- package/src/saves/storage.ts +24 -7
- package/src/saves/types.ts +20 -6
- package/src/store.ts +549 -137
- package/src/story-api.ts +46 -81
- package/src/story-init.ts +1 -1
- package/src/story-variables.ts +98 -102
- package/src/triggers.ts +6 -2
- package/src/utils/error-message.ts +12 -0
- package/src/utils/live-locals.ts +10 -3
- package/src/utils/namespace.ts +71 -0
- package/src/utils/object-path.ts +194 -0
- package/src/utils/stable-key.ts +82 -0
- package/src/widgets/widget-registry.ts +9 -0
- package/types/index.d.ts +43 -7
- package/types/tooling.d.ts +1 -0
package/src/execute-mutation.ts
CHANGED
|
@@ -1,55 +1,526 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Immer,
|
|
3
|
+
current as currentDraft,
|
|
4
|
+
freeze,
|
|
5
|
+
isDraft,
|
|
6
|
+
type Draft,
|
|
7
|
+
type Patch,
|
|
8
|
+
} from 'immer';
|
|
1
9
|
import { useStoryStore } from './store';
|
|
10
|
+
import type { StoryState, VariableNamespaces } from './store';
|
|
2
11
|
import { execute } from './expression';
|
|
3
12
|
import { deepClone, deepEqual } from './class-registry';
|
|
13
|
+
import { deleteByPath, getByPath, setByPath } from './utils/object-path';
|
|
14
|
+
import { asNamespace } from './utils/namespace';
|
|
15
|
+
|
|
16
|
+
type NamespaceName = keyof VariableNamespaces;
|
|
17
|
+
|
|
18
|
+
const NAMESPACES: readonly NamespaceName[] = [
|
|
19
|
+
'variables',
|
|
20
|
+
'temporary',
|
|
21
|
+
'transient',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** A write to one property path of a namespace, or its deletion. */
|
|
25
|
+
type PathChange =
|
|
26
|
+
| { path: string[]; deleted: false; value: unknown }
|
|
27
|
+
| { path: string[]; deleted: true };
|
|
4
28
|
|
|
5
29
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
30
|
+
* The state of one executing mutation. The code reads and writes `work`.
|
|
31
|
+
* `base` is what `work` started as, with every write made elsewhere during
|
|
32
|
+
* execution (any other store update, the commits of nested mutations)
|
|
33
|
+
* applied to both, so diffing `work` against `base` yields exactly the
|
|
34
|
+
* code's own changes, and a write made elsewhere after the code's own write
|
|
35
|
+
* to the same path leaves no difference there: the later write wins.
|
|
9
36
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
37
|
+
interface MutationScope {
|
|
38
|
+
work: VariableNamespaces;
|
|
39
|
+
base: VariableNamespaces;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The mutations now executing, innermost last. */
|
|
43
|
+
const activeScopes: MutationScope[] = [];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The working copies of the store namespaces that the innermost executing
|
|
47
|
+
* mutation reads and writes, or undefined outside of one. Story.get reads
|
|
48
|
+
* them so the code sees its own pending writes.
|
|
49
|
+
*/
|
|
50
|
+
export function getActiveMutationScope(): VariableNamespaces | undefined {
|
|
51
|
+
return activeScopes[activeScopes.length - 1]?.work;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The story state in program order: the store's, with the variable
|
|
56
|
+
* namespaces of the innermost running mutation, which hold its pending
|
|
57
|
+
* writes, while mutation code runs. Readers that can run in the middle of
|
|
58
|
+
* mutation code (watcher conditions, {computed}, input bindings) read this,
|
|
59
|
+
* so they never see the store without the code's writes so far.
|
|
60
|
+
*/
|
|
61
|
+
export function readState(): StoryState {
|
|
62
|
+
const state = useStoryStore.getState();
|
|
63
|
+
const scope = getActiveMutationScope();
|
|
64
|
+
return scope ? { ...state, ...scope } : state;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const cloneValue = <T>(value: T): T =>
|
|
68
|
+
deepClone(value, { keepUnregistered: true });
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A frozen copy of a value read from a working copy, matching what reads
|
|
72
|
+
* from the (frozen) store return: writing to it fails instead of changing
|
|
73
|
+
* the code's pending state behind its back.
|
|
74
|
+
*/
|
|
75
|
+
export function frozenCopy<T>(value: T): T {
|
|
76
|
+
return freeze(cloneValue(value), true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Objects merged property by property: plain objects and (registered)
|
|
81
|
+
* class instances. Arrays, Map, Set, Date and RegExp are values that are
|
|
82
|
+
* replaced as a whole, since their elements have no stable identity to
|
|
83
|
+
* merge by (a shift moves every index).
|
|
84
|
+
*/
|
|
85
|
+
function isMergeable(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
return (
|
|
87
|
+
typeof value === 'object' &&
|
|
88
|
+
value !== null &&
|
|
89
|
+
!Array.isArray(value) &&
|
|
90
|
+
!(value instanceof Map) &&
|
|
91
|
+
!(value instanceof Set) &&
|
|
92
|
+
!(value instanceof Date) &&
|
|
93
|
+
!(value instanceof RegExp)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const hasOwn = (obj: object, key: string): boolean =>
|
|
98
|
+
Object.prototype.hasOwnProperty.call(obj, key);
|
|
99
|
+
|
|
100
|
+
/** Collect the property paths where `work` differs from `base`. */
|
|
101
|
+
function diff(
|
|
102
|
+
base: Record<string, unknown>,
|
|
103
|
+
work: Record<string, unknown>,
|
|
104
|
+
path: string[],
|
|
105
|
+
changes: PathChange[],
|
|
106
|
+
ancestors: Set<object>,
|
|
14
107
|
): void {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
108
|
+
// Stop at cycles; shared (non-cyclic) references are visited per path.
|
|
109
|
+
if (ancestors.has(work)) return;
|
|
110
|
+
ancestors.add(work);
|
|
111
|
+
for (const key of Object.keys(work)) {
|
|
112
|
+
const b = base[key];
|
|
113
|
+
const w = work[key];
|
|
114
|
+
if (!hasOwn(base, key)) {
|
|
115
|
+
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
116
|
+
} else if (Object.is(b, w)) {
|
|
117
|
+
// The same value (an untouched subtree of an Immer update)
|
|
118
|
+
continue;
|
|
119
|
+
} else if (
|
|
120
|
+
isMergeable(b) &&
|
|
121
|
+
isMergeable(w) &&
|
|
122
|
+
Object.getPrototypeOf(b) === Object.getPrototypeOf(w)
|
|
123
|
+
) {
|
|
124
|
+
diff(b, w, [...path, key], changes, ancestors);
|
|
125
|
+
} else if (!deepEqual(b, w)) {
|
|
126
|
+
changes.push({ path: [...path, key], deleted: false, value: w });
|
|
18
127
|
}
|
|
19
128
|
}
|
|
20
|
-
for (const key of Object.keys(
|
|
21
|
-
if (!(key
|
|
22
|
-
|
|
129
|
+
for (const key of Object.keys(base)) {
|
|
130
|
+
if (!hasOwn(work, key))
|
|
131
|
+
changes.push({ path: [...path, key], deleted: true });
|
|
132
|
+
}
|
|
133
|
+
ancestors.delete(work);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether `ns` already holds what `change` would write. */
|
|
137
|
+
function isApplied(ns: Record<string, unknown>, change: PathChange): boolean {
|
|
138
|
+
const parent = getByPath(ns, change.path.slice(0, -1));
|
|
139
|
+
if (parent === null || typeof parent !== 'object') return change.deleted;
|
|
140
|
+
const key = change.path[change.path.length - 1]!;
|
|
141
|
+
if (!hasOwn(parent, key)) return change.deleted;
|
|
142
|
+
return (
|
|
143
|
+
!change.deleted &&
|
|
144
|
+
deepEqual((parent as Record<string, unknown>)[key], change.value)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function applyChange(ns: Record<string, unknown>, change: PathChange): void {
|
|
149
|
+
if (change.deleted) deleteByPath(ns, change.path);
|
|
150
|
+
else setByPath(ns, change.path, change.value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply a write that reached the store while mutations execute to their
|
|
155
|
+
* `work` and `base` copies (see MutationScope), so the code sees it and
|
|
156
|
+
* its commit neither drops it nor reverts it. Called from inside the store
|
|
157
|
+
* update that makes the write, before subscribers (watchers) run, so writes
|
|
158
|
+
* those make land in the copies after this one. When a copy cannot take the
|
|
159
|
+
* path (the code replaced or deleted an intermediate object), its root is
|
|
160
|
+
* replaced with the store's root as written.
|
|
161
|
+
*/
|
|
162
|
+
function mirror(
|
|
163
|
+
draft: VariableNamespaces,
|
|
164
|
+
ns: NamespaceName,
|
|
165
|
+
change: PathChange,
|
|
166
|
+
scopes: readonly MutationScope[] = activeScopes,
|
|
167
|
+
): void {
|
|
168
|
+
const root = change.path[0]!;
|
|
169
|
+
for (const scope of scopes) {
|
|
170
|
+
for (const copy of [scope.work[ns], scope.base[ns]]) {
|
|
171
|
+
try {
|
|
172
|
+
applyChange(
|
|
173
|
+
copy,
|
|
174
|
+
change.deleted
|
|
175
|
+
? change
|
|
176
|
+
: { ...change, value: cloneValue(change.value) },
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
const stored = draft[ns][root];
|
|
180
|
+
if (hasOwn(draft[ns], root)) {
|
|
181
|
+
copy[root] = cloneValue(
|
|
182
|
+
isDraft(stored) ? currentDraft(stored) : stored,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
delete copy[root];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
23
188
|
}
|
|
24
189
|
}
|
|
25
190
|
}
|
|
26
191
|
|
|
192
|
+
const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Runs store updates on the running code's working copies. They stay the
|
|
196
|
+
* code's own, so nothing may freeze them.
|
|
197
|
+
*/
|
|
198
|
+
const scratch = new Immer({ autoFreeze: false });
|
|
199
|
+
|
|
200
|
+
/** Set while commitScopes() hands its commit to the store. */
|
|
201
|
+
let committing = false;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The path below a namespace that an Immer patch at `segments` changed, as
|
|
205
|
+
* the merge sees values: arrays, Map, Set, Date and RegExp change as a
|
|
206
|
+
* whole (see isMergeable), so a patch inside one is a change of it.
|
|
207
|
+
*/
|
|
208
|
+
function changedPath(
|
|
209
|
+
ns: Record<string, unknown>,
|
|
210
|
+
segments: readonly (string | number)[],
|
|
211
|
+
): string[] {
|
|
212
|
+
const path: string[] = [];
|
|
213
|
+
let node: unknown = ns;
|
|
214
|
+
for (const segment of segments) {
|
|
215
|
+
if (!isMergeable(node)) break;
|
|
216
|
+
const key = String(segment);
|
|
217
|
+
path.push(key);
|
|
218
|
+
node = hasOwn(node, key) ? node[key] : undefined;
|
|
219
|
+
}
|
|
220
|
+
return path;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The change that brings `path` to what it holds in `ns`. */
|
|
224
|
+
function changeTo(ns: Record<string, unknown>, path: string[]): PathChange {
|
|
225
|
+
const parent = getByPath(ns, path.slice(0, -1));
|
|
226
|
+
const key = path[path.length - 1]!;
|
|
227
|
+
return isMergeable(parent) && hasOwn(parent, key)
|
|
228
|
+
? { path, deleted: false, value: parent[key] }
|
|
229
|
+
: { path, deleted: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The property paths of a namespace that an update wrote (Immer `patches`
|
|
234
|
+
* from `before` to `after`), each with what it holds afterwards: where the
|
|
235
|
+
* update assigned an object, the whole object is the change, so a write
|
|
236
|
+
* replaces exactly what it replaced in program order.
|
|
237
|
+
*/
|
|
238
|
+
function writtenPaths(
|
|
239
|
+
before: Record<string, unknown>,
|
|
240
|
+
after: Record<string, unknown>,
|
|
241
|
+
patches: readonly Patch[],
|
|
242
|
+
): PathChange[] {
|
|
243
|
+
if (patches.some((p) => p.path.length === 1)) {
|
|
244
|
+
// The namespace itself was replaced: each root that differs
|
|
245
|
+
const roots = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
246
|
+
return [...roots]
|
|
247
|
+
.filter((root) => !hasOwn(before, root) || before[root] !== after[root])
|
|
248
|
+
.map((root) => changeTo(after, [root]));
|
|
249
|
+
}
|
|
250
|
+
const seen = new Set<string>();
|
|
251
|
+
const changes: PathChange[] = [];
|
|
252
|
+
for (const patch of patches) {
|
|
253
|
+
const path = changedPath(after, patch.path.slice(1));
|
|
254
|
+
const key = JSON.stringify(path);
|
|
255
|
+
if (seen.has(key)) continue;
|
|
256
|
+
seen.add(key);
|
|
257
|
+
changes.push(changeTo(after, path));
|
|
258
|
+
}
|
|
259
|
+
return changes;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Route a store update made while mutation code runs into program order.
|
|
264
|
+
* The store calls this for every update (see storyStateGuard in store.ts);
|
|
265
|
+
* it returns undefined, leaving the update as it is, when no mutation code
|
|
266
|
+
* runs or the update is a commit of mutation code. Otherwise the update is
|
|
267
|
+
* one made by something the code called: Story.set, an input binding or a
|
|
268
|
+
* {computed}/{unset} the code set off, a direct store action, a watcher.
|
|
269
|
+
*
|
|
270
|
+
* In program order such an update happens after the code's writes so far,
|
|
271
|
+
* so those are committed first, in one store update: subscribers (watchers,
|
|
272
|
+
* variableChanged handlers) see the program-order state before this write,
|
|
273
|
+
* and an error the code throws later does not undo them. The update then
|
|
274
|
+
* happens to the running code's state: its recipe runs on the innermost
|
|
275
|
+
* mutation's working copies (so it reads the code's state, and a path the
|
|
276
|
+
* code's state cannot take throws before this write is made, as an
|
|
277
|
+
* assignment would). The property paths it wrote there are written to the
|
|
278
|
+
* code's working copy at once, so the code reads them next, and are
|
|
279
|
+
* returned as the recipe for the store: each write reaches the store at
|
|
280
|
+
* once (should the store's state not take the path, the code's state of
|
|
281
|
+
* the whole root is written instead). Each write is also mirrored into
|
|
282
|
+
* every executing mutation (see mirror()), so their commits neither drop
|
|
283
|
+
* nor revert it. Changes to the rest of the store state are applied as
|
|
284
|
+
* they are.
|
|
285
|
+
*/
|
|
286
|
+
export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
287
|
+
recipe: (draft: Draft<S>) => void,
|
|
288
|
+
): ((draft: Draft<S>) => void) | undefined {
|
|
289
|
+
if (committing) {
|
|
290
|
+
committing = false;
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
if (activeScopes.length === 0) return undefined;
|
|
294
|
+
|
|
295
|
+
// The store first takes the code's pending writes, which came before this
|
|
296
|
+
// one, so it goes from one program-order state to the next (watchers that
|
|
297
|
+
// commit fires run before this write, as they would in program order)
|
|
298
|
+
commitScopes(running());
|
|
299
|
+
const inner = activeScopes[activeScopes.length - 1]!;
|
|
300
|
+
const view = {
|
|
301
|
+
...(useStoryStore.getState() as unknown as S),
|
|
302
|
+
variables: inner.work.variables,
|
|
303
|
+
temporary: inner.work.temporary,
|
|
304
|
+
transient: inner.work.transient,
|
|
305
|
+
};
|
|
306
|
+
const [next, patches] = scratch.produceWithPatches(
|
|
307
|
+
view,
|
|
308
|
+
recipe as (draft: Draft<S>) => void,
|
|
309
|
+
);
|
|
310
|
+
const changes = NAMESPACES.map((ns) => {
|
|
311
|
+
const own = patches.filter((p) => p.path[0] === ns);
|
|
312
|
+
return [
|
|
313
|
+
ns,
|
|
314
|
+
own.length ? writtenPaths(view[ns], next[ns], own) : [],
|
|
315
|
+
] as const;
|
|
316
|
+
});
|
|
317
|
+
const owned = (change: PathChange): PathChange =>
|
|
318
|
+
change.deleted ? change : { ...change, value: cloneValue(change.value) };
|
|
319
|
+
for (const [ns, list] of changes) {
|
|
320
|
+
for (const change of list) applyChange(inner.work[ns], owned(change));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return (draft) => {
|
|
324
|
+
const target = draft as unknown as Record<string, unknown>;
|
|
325
|
+
for (const key of Object.keys(next)) {
|
|
326
|
+
const value = (next as unknown as Record<string, unknown>)[key];
|
|
327
|
+
if (
|
|
328
|
+
!NAMESPACE_KEYS.has(key) &&
|
|
329
|
+
value !== (view as unknown as Record<string, unknown>)[key]
|
|
330
|
+
) {
|
|
331
|
+
target[key] = value;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const namespaces = draft as unknown as VariableNamespaces;
|
|
335
|
+
for (const [ns, list] of changes) {
|
|
336
|
+
for (const change of list) {
|
|
337
|
+
const root = change.path[0]!;
|
|
338
|
+
try {
|
|
339
|
+
applyChange(namespaces[ns], owned(change));
|
|
340
|
+
} catch {
|
|
341
|
+
if (hasOwn(inner.work[ns], root)) {
|
|
342
|
+
namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
343
|
+
} else {
|
|
344
|
+
delete namespaces[ns][root];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
mirror(namespaces, ns, change);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const cloneNamespaces = (from: VariableNamespaces): VariableNamespaces => ({
|
|
354
|
+
variables: deepClone(from.variables),
|
|
355
|
+
temporary: deepClone(from.temporary),
|
|
356
|
+
transient: deepClone(from.transient),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/** A mutation to commit, and whether its code goes on running after. */
|
|
360
|
+
interface Commit {
|
|
361
|
+
scope: MutationScope;
|
|
362
|
+
keepRunning: boolean;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Commit the property paths each mutation's code changed (work vs base) on
|
|
367
|
+
* top of the current store state, so writes made elsewhere during execution
|
|
368
|
+
* to other paths of the same objects survive. `commits` lists running
|
|
369
|
+
* mutations from the outermost in: each one's changes are applied after
|
|
370
|
+
* those of the mutations it runs inside (which it started from, so they
|
|
371
|
+
* came first in program order) and handed to them. All of it is one store
|
|
372
|
+
* update, so the store goes from one program-order state to another:
|
|
373
|
+
* watchers and other subscribers never see some pending writes without
|
|
374
|
+
* the ones made before them.
|
|
375
|
+
*
|
|
376
|
+
* A mutation whose code goes on running (`keepRunning`) keeps its working
|
|
377
|
+
* copy its own (the store freezes what it is given), and its base is moved
|
|
378
|
+
* up to the working copy so nothing commits twice.
|
|
379
|
+
*/
|
|
380
|
+
function commitScopes(commits: readonly Commit[]): void {
|
|
381
|
+
const all = commits.map(({ scope, keepRunning }) => ({
|
|
382
|
+
scope,
|
|
383
|
+
keepRunning,
|
|
384
|
+
changes: NAMESPACES.map((ns) => {
|
|
385
|
+
const list: PathChange[] = [];
|
|
386
|
+
diff(scope.base[ns], scope.work[ns], [], list, new Set());
|
|
387
|
+
return [ns, list] as const;
|
|
388
|
+
}),
|
|
389
|
+
}));
|
|
390
|
+
const changed = all.filter(({ changes }) =>
|
|
391
|
+
changes.some(([, list]) => list.length > 0),
|
|
392
|
+
);
|
|
393
|
+
if (changed.length === 0) return;
|
|
394
|
+
// Before the store update: watchers it fires may commit again (a goto)
|
|
395
|
+
for (const { scope, keepRunning } of changed) {
|
|
396
|
+
if (keepRunning) scope.base = cloneNamespaces(scope.work);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// One store update for everything: watchers must see the whole state,
|
|
400
|
+
// and a watcher's run action must not be overwritten by paths this commit
|
|
401
|
+
// writes after it fired. It is the commit itself, not an update made by
|
|
402
|
+
// running code (see routeStoreUpdate).
|
|
403
|
+
committing = true;
|
|
404
|
+
try {
|
|
405
|
+
useStoryStore.getState().updateVariables(commitRecipe);
|
|
406
|
+
} finally {
|
|
407
|
+
committing = false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function commitRecipe(draft: VariableNamespaces): void {
|
|
411
|
+
all.forEach(({ scope, keepRunning, changes }, i) => {
|
|
412
|
+
const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
|
|
413
|
+
for (const [ns, list] of changes) {
|
|
414
|
+
const replaced = new Set<string>();
|
|
415
|
+
for (const change of list) {
|
|
416
|
+
const root = change.path[0]!;
|
|
417
|
+
// A changed path already holding the value keeps its reference
|
|
418
|
+
if (replaced.has(root) || isApplied(draft[ns], change)) continue;
|
|
419
|
+
try {
|
|
420
|
+
applyChange(
|
|
421
|
+
draft[ns],
|
|
422
|
+
change.deleted ? change : { ...change, value: own(change.value) },
|
|
423
|
+
);
|
|
424
|
+
} catch {
|
|
425
|
+
// An intermediate object the code wrote into is gone from the
|
|
426
|
+
// store: the code's view of the whole root wins.
|
|
427
|
+
draft[ns][root] = own(scope.work[ns][root]);
|
|
428
|
+
replaced.add(root);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Hand the changes to the mutations this one runs inside, before
|
|
432
|
+
// watchers fired by this update run.
|
|
433
|
+
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
434
|
+
for (const change of list) mirror(draft, ns, change, enclosing);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Every running mutation, to commit with its code going on. */
|
|
441
|
+
const running = (): Commit[] =>
|
|
442
|
+
activeScopes.map((scope) => ({ scope, keepRunning: true }));
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Bring a suspended mutation's copies up to the store after an action
|
|
446
|
+
* replaced or changed state under it (navigation clears temporaries, back
|
|
447
|
+
* and restart replace variables). Roots that still match keep the objects
|
|
448
|
+
* the code may hold.
|
|
449
|
+
*/
|
|
450
|
+
function resync(scope: MutationScope): void {
|
|
451
|
+
const state = useStoryStore.getState();
|
|
452
|
+
for (const ns of NAMESPACES) {
|
|
453
|
+
const work = scope.work[ns];
|
|
454
|
+
const stored = state[ns];
|
|
455
|
+
for (const key of Object.keys(work)) {
|
|
456
|
+
if (!hasOwn(stored, key)) delete work[key];
|
|
457
|
+
}
|
|
458
|
+
for (const key of Object.keys(stored)) {
|
|
459
|
+
if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) {
|
|
460
|
+
work[key] = deepClone(stored[key]);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
scope.base = cloneNamespaces(state);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Run an action that snapshots or replaces story state (saving, navigating,
|
|
469
|
+
* restarting) in program order with mutation code running now: the code's
|
|
470
|
+
* writes so far are committed first, the action runs against the store with
|
|
471
|
+
* no mutation active, and the code then continues from the resulting state.
|
|
472
|
+
* Outside mutation code the action just runs.
|
|
473
|
+
*/
|
|
474
|
+
export function runWithCommittedMutations<T>(action: () => T): T {
|
|
475
|
+
if (activeScopes.length === 0) return action();
|
|
476
|
+
commitScopes(running());
|
|
477
|
+
const suspended = activeScopes.splice(0);
|
|
478
|
+
try {
|
|
479
|
+
return action();
|
|
480
|
+
} finally {
|
|
481
|
+
activeScopes.push(...suspended);
|
|
482
|
+
for (const scope of suspended) resync(scope);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
27
486
|
export function executeMutation(
|
|
28
487
|
code: string,
|
|
29
488
|
mergedLocals: Record<string, unknown>,
|
|
30
489
|
scopeUpdate: (key: string, value: unknown) => void,
|
|
31
490
|
): void {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
491
|
+
// A mutation started while another executes (a watcher run action fired
|
|
492
|
+
// by a Story.set in its code) continues from the enclosing code's pending
|
|
493
|
+
// state rather than the store, as a direct call at that point would.
|
|
494
|
+
const start = getActiveMutationScope() ?? useStoryStore.getState();
|
|
495
|
+
const scope: MutationScope = {
|
|
496
|
+
work: cloneNamespaces(start),
|
|
497
|
+
base: cloneNamespaces(start),
|
|
498
|
+
};
|
|
36
499
|
// Locals are deep-cloned like the store namespaces: a loop item or widget
|
|
37
500
|
// argument taken from story state is Immer-frozen, and an unfrozen object
|
|
38
501
|
// mutated in place would keep its reference, so a nested assignment
|
|
39
502
|
// (`@item.name = "x"`) would be lost or never reach the scope updater.
|
|
40
503
|
// Unregistered class instances (DOM nodes etc.) stay shared by reference.
|
|
41
|
-
const localsClone =
|
|
504
|
+
const localsClone = asNamespace(
|
|
505
|
+
deepClone(mergedLocals, { keepUnregistered: true }),
|
|
506
|
+
);
|
|
42
507
|
|
|
43
|
-
|
|
508
|
+
activeScopes.push(scope);
|
|
509
|
+
try {
|
|
510
|
+
execute(
|
|
511
|
+
code,
|
|
512
|
+
scope.work.variables,
|
|
513
|
+
scope.work.temporary,
|
|
514
|
+
localsClone,
|
|
515
|
+
scope.work.transient,
|
|
516
|
+
);
|
|
517
|
+
} finally {
|
|
518
|
+
activeScopes.pop();
|
|
519
|
+
}
|
|
44
520
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
state.updateVariables((draft) => {
|
|
49
|
-
commitNamespace(draft.variables, vars, state.variables);
|
|
50
|
-
commitNamespace(draft.temporary, temps, state.temporary);
|
|
51
|
-
commitNamespace(draft.transient, trans, state.transient);
|
|
52
|
-
});
|
|
521
|
+
// With the pending writes of the mutations this one runs inside, which
|
|
522
|
+
// came before its own
|
|
523
|
+
commitScopes([...running(), { scope, keepRunning: false }]);
|
|
53
524
|
|
|
54
525
|
for (const key of Object.keys(localsClone)) {
|
|
55
526
|
if (!deepEqual(localsClone[key], mergedLocals[key])) {
|
|
@@ -59,7 +530,7 @@ export function executeMutation(
|
|
|
59
530
|
|
|
60
531
|
// Detect deleted locals
|
|
61
532
|
for (const key of Object.keys(mergedLocals)) {
|
|
62
|
-
if (!(key
|
|
533
|
+
if (!hasOwn(localsClone, key)) {
|
|
63
534
|
scopeUpdate(key, undefined);
|
|
64
535
|
}
|
|
65
536
|
}
|