@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.
Files changed (66) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +4833 -1603
  3. package/dist/pkg/macro-registry.json +7 -7
  4. package/dist/pkg/story-variables.js +1658 -189
  5. package/package.json +5 -2
  6. package/src/automation/runner.ts +2 -1
  7. package/src/class-registry.ts +277 -90
  8. package/src/components/Passage.tsx +2 -2
  9. package/src/components/PassageDialog.tsx +2 -5
  10. package/src/components/StoryInterface.tsx +2 -4
  11. package/src/components/macros/Button.tsx +9 -32
  12. package/src/components/macros/Checkbox.tsx +10 -4
  13. package/src/components/macros/Computed.tsx +19 -13
  14. package/src/components/macros/Dialog.tsx +4 -1
  15. package/src/components/macros/For.tsx +33 -4
  16. package/src/components/macros/If.tsx +8 -0
  17. package/src/components/macros/Include.tsx +15 -24
  18. package/src/components/macros/MacroError.tsx +2 -1
  19. package/src/components/macros/MacroLink.tsx +14 -46
  20. package/src/components/macros/Meter.tsx +26 -40
  21. package/src/components/macros/Nobr.tsx +1 -0
  22. package/src/components/macros/PassageDisplay.tsx +3 -0
  23. package/src/components/macros/Print.tsx +4 -0
  24. package/src/components/macros/Radiobutton.tsx +27 -2
  25. package/src/components/macros/SaveManager.tsx +39 -14
  26. package/src/components/macros/Span.tsx +1 -0
  27. package/src/components/macros/StoryTitle.tsx +1 -0
  28. package/src/components/macros/Switch.tsx +13 -0
  29. package/src/components/macros/Unset.tsx +30 -10
  30. package/src/components/macros/VarDisplay.tsx +21 -4
  31. package/src/components/macros/Watch.tsx +88 -40
  32. package/src/components/macros/Widget.tsx +20 -1
  33. package/src/components/macros/WidgetInvocation.tsx +20 -158
  34. package/src/components/macros/arg-utils.ts +226 -0
  35. package/src/components/macros/detached-body.tsx +68 -0
  36. package/src/components/macros/option-utils.ts +10 -5
  37. package/src/define-macro.ts +44 -38
  38. package/src/execute-mutation.ts +499 -28
  39. package/src/expression.ts +88 -272
  40. package/src/hooks/use-action.ts +18 -3
  41. package/src/hooks/use-interpolate.ts +36 -5
  42. package/src/index.tsx +10 -1
  43. package/src/interpolation.ts +394 -96
  44. package/src/js-lexer.ts +1460 -0
  45. package/src/markup/code-attributes.ts +64 -0
  46. package/src/markup/markdown.ts +188 -9
  47. package/src/markup/render.tsx +552 -113
  48. package/src/markup/tokenizer.ts +601 -119
  49. package/src/prng.ts +8 -8
  50. package/src/registry.ts +35 -0
  51. package/src/saves/save-manager.ts +368 -153
  52. package/src/saves/storage.ts +24 -7
  53. package/src/saves/types.ts +20 -6
  54. package/src/store.ts +549 -137
  55. package/src/story-api.ts +46 -81
  56. package/src/story-init.ts +1 -1
  57. package/src/story-variables.ts +98 -102
  58. package/src/triggers.ts +6 -2
  59. package/src/utils/error-message.ts +12 -0
  60. package/src/utils/live-locals.ts +10 -3
  61. package/src/utils/namespace.ts +71 -0
  62. package/src/utils/object-path.ts +194 -0
  63. package/src/utils/stable-key.ts +82 -0
  64. package/src/widgets/widget-registry.ts +9 -0
  65. package/types/index.d.ts +43 -7
  66. package/types/tooling.d.ts +1 -0
@@ -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
- * Write the keys `code` changed (`clone` vs the pre-execution `before`) into
7
- * an Immer draft of the same namespace, and delete the keys it removed.
8
- * Untouched keys are left alone, so updates made during execution survive.
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
- function commitNamespace(
11
- draft: Record<string, unknown>,
12
- clone: Record<string, unknown>,
13
- before: Record<string, unknown>,
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
- for (const key of Object.keys(clone)) {
16
- if (!deepEqual(clone[key], before[key])) {
17
- draft[key] = clone[key];
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(before)) {
21
- if (!(key in clone)) {
22
- delete draft[key];
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
- const state = useStoryStore.getState();
33
- const vars = deepClone(state.variables);
34
- const temps = deepClone(state.temporary);
35
- const trans = deepClone(state.transient);
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 = deepClone(mergedLocals, { keepUnregistered: true });
504
+ const localsClone = asNamespace(
505
+ deepClone(mergedLocals, { keepUnregistered: true }),
506
+ );
42
507
 
43
- execute(code, vars, temps, localsClone, trans);
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
- // Commit every store change in one update: watchers must see the whole
46
- // mutation, and a watcher's run action must not be overwritten by keys
47
- // this commit writes after it fired.
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 in localsClone)) {
533
+ if (!hasOwn(localsClone, key)) {
63
534
  scopeUpdate(key, undefined);
64
535
  }
65
536
  }