@rohal12/spindle 0.51.3 → 0.51.4

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.
@@ -1,27 +1,287 @@
1
+ import { current as currentDraft, freeze, isDraft } from 'immer';
1
2
  import { useStoryStore } from './store';
3
+ import type { VariableNamespaces } from './store';
2
4
  import { execute } from './expression';
3
5
  import { deepClone, deepEqual } from './class-registry';
6
+ import { deleteByPath, getByPath, setByPath } from './utils/object-path';
7
+
8
+ type NamespaceName = keyof VariableNamespaces;
9
+
10
+ const NAMESPACES: readonly NamespaceName[] = [
11
+ 'variables',
12
+ 'temporary',
13
+ 'transient',
14
+ ];
15
+
16
+ /** A write to one property path of a namespace, or its deletion. */
17
+ type PathChange =
18
+ | { path: string[]; deleted: false; value: unknown }
19
+ | { path: string[]; deleted: true };
20
+
21
+ /**
22
+ * The state of one executing mutation. The code reads and writes `work`.
23
+ * `base` is what `work` started as, with every write made elsewhere during
24
+ * execution (Story.set, the commits of nested mutations) applied to both,
25
+ * so diffing `work` against `base` yields exactly the code's own changes,
26
+ * and a write made elsewhere after the code's own write to the same path
27
+ * leaves no difference there: the later write wins.
28
+ */
29
+ interface MutationScope {
30
+ work: VariableNamespaces;
31
+ base: VariableNamespaces;
32
+ }
33
+
34
+ /** The mutations now executing, innermost last. */
35
+ const activeScopes: MutationScope[] = [];
36
+
37
+ /**
38
+ * The working copies of the store namespaces that the innermost executing
39
+ * mutation reads and writes, or undefined outside of one. Story.get reads
40
+ * them so the code sees its own pending writes.
41
+ */
42
+ export function getActiveMutationScope(): VariableNamespaces | undefined {
43
+ return activeScopes[activeScopes.length - 1]?.work;
44
+ }
45
+
46
+ const cloneValue = <T>(value: T): T =>
47
+ deepClone(value, { keepUnregistered: true });
48
+
49
+ /**
50
+ * A frozen copy of a value read from a working copy, matching what reads
51
+ * from the (frozen) store return: writing to it fails instead of changing
52
+ * the code's pending state behind its back.
53
+ */
54
+ export function frozenCopy<T>(value: T): T {
55
+ return freeze(cloneValue(value), true);
56
+ }
57
+
58
+ /**
59
+ * Objects merged property by property: plain objects and (registered)
60
+ * class instances. Arrays, Map, Set, Date and RegExp are values that are
61
+ * replaced as a whole, since their elements have no stable identity to
62
+ * merge by (a shift moves every index).
63
+ */
64
+ function isMergeable(value: unknown): value is Record<string, unknown> {
65
+ return (
66
+ typeof value === 'object' &&
67
+ value !== null &&
68
+ !Array.isArray(value) &&
69
+ !(value instanceof Map) &&
70
+ !(value instanceof Set) &&
71
+ !(value instanceof Date) &&
72
+ !(value instanceof RegExp)
73
+ );
74
+ }
75
+
76
+ const hasOwn = (obj: object, key: string): boolean =>
77
+ Object.prototype.hasOwnProperty.call(obj, key);
78
+
79
+ /** Collect the property paths where `work` differs from `base`. */
80
+ function diff(
81
+ base: Record<string, unknown>,
82
+ work: Record<string, unknown>,
83
+ path: string[],
84
+ changes: PathChange[],
85
+ ancestors: Set<object>,
86
+ ): void {
87
+ // Stop at cycles; shared (non-cyclic) references are visited per path.
88
+ if (ancestors.has(work)) return;
89
+ ancestors.add(work);
90
+ for (const key of Object.keys(work)) {
91
+ const b = base[key];
92
+ const w = work[key];
93
+ if (!hasOwn(base, key)) {
94
+ changes.push({ path: [...path, key], deleted: false, value: w });
95
+ } else if (
96
+ isMergeable(b) &&
97
+ isMergeable(w) &&
98
+ Object.getPrototypeOf(b) === Object.getPrototypeOf(w)
99
+ ) {
100
+ diff(b, w, [...path, key], changes, ancestors);
101
+ } else if (!deepEqual(b, w)) {
102
+ changes.push({ path: [...path, key], deleted: false, value: w });
103
+ }
104
+ }
105
+ for (const key of Object.keys(base)) {
106
+ if (!hasOwn(work, key))
107
+ changes.push({ path: [...path, key], deleted: true });
108
+ }
109
+ ancestors.delete(work);
110
+ }
111
+
112
+ /** Whether `ns` already holds what `change` would write. */
113
+ function isApplied(ns: Record<string, unknown>, change: PathChange): boolean {
114
+ const parent = getByPath(ns, change.path.slice(0, -1));
115
+ if (parent === null || typeof parent !== 'object') return change.deleted;
116
+ const key = change.path[change.path.length - 1]!;
117
+ if (!hasOwn(parent, key)) return change.deleted;
118
+ return (
119
+ !change.deleted &&
120
+ deepEqual((parent as Record<string, unknown>)[key], change.value)
121
+ );
122
+ }
123
+
124
+ function applyChange(ns: Record<string, unknown>, change: PathChange): void {
125
+ if (change.deleted) deleteByPath(ns, change.path);
126
+ else setByPath(ns, change.path, change.value);
127
+ }
4
128
 
5
129
  /**
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.
130
+ * Apply a write that reached the store while mutations execute to their
131
+ * `work` and `base` copies (see MutationScope), so the code sees it and
132
+ * its commit neither drops it nor reverts it. Called from inside the store
133
+ * update that makes the write, before subscribers (watchers) run, so writes
134
+ * those make land in the copies after this one. When a copy cannot take the
135
+ * path (the code replaced or deleted an intermediate object), its root is
136
+ * replaced with the store's root as written.
9
137
  */
10
- function commitNamespace(
11
- draft: Record<string, unknown>,
12
- clone: Record<string, unknown>,
13
- before: Record<string, unknown>,
138
+ function mirror(
139
+ draft: VariableNamespaces,
140
+ ns: NamespaceName,
141
+ change: PathChange,
142
+ scopes: readonly MutationScope[] = activeScopes,
14
143
  ): void {
15
- for (const key of Object.keys(clone)) {
16
- if (!deepEqual(clone[key], before[key])) {
17
- draft[key] = clone[key];
144
+ const root = change.path[0]!;
145
+ for (const scope of scopes) {
146
+ for (const copy of [scope.work[ns], scope.base[ns]]) {
147
+ try {
148
+ applyChange(
149
+ copy,
150
+ change.deleted
151
+ ? change
152
+ : { ...change, value: cloneValue(change.value) },
153
+ );
154
+ } catch {
155
+ const stored = draft[ns][root];
156
+ if (hasOwn(draft[ns], root)) {
157
+ copy[root] = cloneValue(
158
+ isDraft(stored) ? currentDraft(stored) : stored,
159
+ );
160
+ } else {
161
+ delete copy[root];
162
+ }
163
+ }
18
164
  }
19
165
  }
20
- for (const key of Object.keys(before)) {
21
- if (!(key in clone)) {
22
- delete draft[key];
166
+ }
167
+
168
+ /**
169
+ * Mirror a Story.set write into every executing mutation (see mirror()).
170
+ * Call it inside the store update that writes `value` at `path` of `ns`.
171
+ */
172
+ export function mirrorWriteToActiveScopes(
173
+ draft: VariableNamespaces,
174
+ ns: 'variables' | 'transient',
175
+ path: string[],
176
+ value: unknown,
177
+ ): void {
178
+ if (activeScopes.length === 0) return;
179
+ mirror(draft, ns, { path, deleted: false, value });
180
+ }
181
+
182
+ const cloneNamespaces = (from: VariableNamespaces): VariableNamespaces => ({
183
+ variables: deepClone(from.variables),
184
+ temporary: deepClone(from.temporary),
185
+ transient: deepClone(from.transient),
186
+ });
187
+
188
+ /**
189
+ * Commit the property paths `scope`'s code changed (work vs base) on top of
190
+ * the current store state, so writes made elsewhere during execution to
191
+ * other paths of the same objects survive, and hand them to the `enclosing`
192
+ * mutations. With `keepRunning`, the code goes on running after the commit:
193
+ * its working copy must stay its own (the store freezes what it is given),
194
+ * and its base is moved up to the working copy so nothing commits twice.
195
+ */
196
+ function commitScope(
197
+ scope: MutationScope,
198
+ enclosing: readonly MutationScope[],
199
+ keepRunning: boolean,
200
+ ): void {
201
+ const changes = NAMESPACES.map((ns) => {
202
+ const list: PathChange[] = [];
203
+ diff(scope.base[ns], scope.work[ns], [], list, new Set());
204
+ return [ns, list] as const;
205
+ });
206
+ if (changes.every(([, list]) => list.length === 0)) return;
207
+ // Before the store update: watchers it fires may commit again (a goto)
208
+ if (keepRunning) scope.base = cloneNamespaces(scope.work);
209
+ const own = <T>(value: T): T => (keepRunning ? cloneValue(value) : value);
210
+ const current = useStoryStore.getState();
211
+
212
+ // One store update for everything: watchers must see the whole mutation,
213
+ // and a watcher's run action must not be overwritten by paths this commit
214
+ // writes after it fired.
215
+ current.updateVariables((draft) => {
216
+ for (const [ns, list] of changes) {
217
+ const replaced = new Set<string>();
218
+ for (const change of list) {
219
+ const root = change.path[0]!;
220
+ // A changed path already holding the value keeps its reference
221
+ if (replaced.has(root) || isApplied(current[ns], change)) continue;
222
+ try {
223
+ applyChange(
224
+ draft[ns],
225
+ change.deleted ? change : { ...change, value: own(change.value) },
226
+ );
227
+ } catch {
228
+ // An intermediate object the code wrote into is gone from the
229
+ // store: the code's view of the whole root wins.
230
+ draft[ns][root] = own(scope.work[ns][root]);
231
+ replaced.add(root);
232
+ }
233
+ }
234
+ // Hand the changes to the mutations this one runs inside, before
235
+ // watchers fired by this update run.
236
+ for (const change of list) mirror(draft, ns, change, enclosing);
237
+ }
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Bring a suspended mutation's copies up to the store after an action
243
+ * replaced or changed state under it (navigation clears temporaries, back
244
+ * and restart replace variables). Roots that still match keep the objects
245
+ * the code may hold.
246
+ */
247
+ function resync(scope: MutationScope): void {
248
+ const state = useStoryStore.getState();
249
+ for (const ns of NAMESPACES) {
250
+ const work = scope.work[ns];
251
+ const stored = state[ns];
252
+ for (const key of Object.keys(work)) {
253
+ if (!hasOwn(stored, key)) delete work[key];
254
+ }
255
+ for (const key of Object.keys(stored)) {
256
+ if (!hasOwn(work, key) || !deepEqual(work[key], stored[key])) {
257
+ work[key] = deepClone(stored[key]);
258
+ }
23
259
  }
24
260
  }
261
+ scope.base = cloneNamespaces(state);
262
+ }
263
+
264
+ /**
265
+ * Run an action that snapshots or replaces story state (saving, navigating,
266
+ * restarting) in program order with mutation code running now: the code's
267
+ * writes so far are committed first, the action runs against the store with
268
+ * no mutation active, and the code then continues from the resulting state.
269
+ * Outside mutation code the action just runs.
270
+ */
271
+ export function runWithCommittedMutations<T>(action: () => T): T {
272
+ if (activeScopes.length === 0) return action();
273
+ const scopes = [...activeScopes];
274
+ // Innermost first: each commit hands its changes to the enclosing ones
275
+ for (let i = scopes.length - 1; i >= 0; i--) {
276
+ commitScope(scopes[i]!, scopes.slice(0, i), true);
277
+ }
278
+ const suspended = activeScopes.splice(0);
279
+ try {
280
+ return action();
281
+ } finally {
282
+ activeScopes.push(...suspended);
283
+ for (const scope of suspended) resync(scope);
284
+ }
25
285
  }
26
286
 
27
287
  export function executeMutation(
@@ -29,10 +289,14 @@ export function executeMutation(
29
289
  mergedLocals: Record<string, unknown>,
30
290
  scopeUpdate: (key: string, value: unknown) => void,
31
291
  ): void {
32
- const state = useStoryStore.getState();
33
- const vars = deepClone(state.variables);
34
- const temps = deepClone(state.temporary);
35
- const trans = deepClone(state.transient);
292
+ // A mutation started while another executes (a watcher run action fired
293
+ // by a Story.set in its code) continues from the enclosing code's pending
294
+ // state rather than the store, as a direct call at that point would.
295
+ const start = getActiveMutationScope() ?? useStoryStore.getState();
296
+ const scope: MutationScope = {
297
+ work: cloneNamespaces(start),
298
+ base: cloneNamespaces(start),
299
+ };
36
300
  // Locals are deep-cloned like the store namespaces: a loop item or widget
37
301
  // argument taken from story state is Immer-frozen, and an unfrozen object
38
302
  // mutated in place would keep its reference, so a nested assignment
@@ -40,16 +304,20 @@ export function executeMutation(
40
304
  // Unregistered class instances (DOM nodes etc.) stay shared by reference.
41
305
  const localsClone = deepClone(mergedLocals, { keepUnregistered: true });
42
306
 
43
- execute(code, vars, temps, localsClone, trans);
307
+ activeScopes.push(scope);
308
+ try {
309
+ execute(
310
+ code,
311
+ scope.work.variables,
312
+ scope.work.temporary,
313
+ localsClone,
314
+ scope.work.transient,
315
+ );
316
+ } finally {
317
+ activeScopes.pop();
318
+ }
44
319
 
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
- });
320
+ commitScope(scope, activeScopes, false);
53
321
 
54
322
  for (const key of Object.keys(localsClone)) {
55
323
  if (!deepEqual(localsClone[key], mergedLocals[key])) {
package/src/expression.ts CHANGED
@@ -2,6 +2,7 @@ import type { StoryState } from './store';
2
2
  import { useStoryStore } from './store';
3
3
  import type { Passage } from './parser';
4
4
  import { random, randomInt } from './prng';
5
+ import { lexJs } from './js-lexer';
5
6
 
6
7
  interface ExpressionFns {
7
8
  currentPassage: () => Passage | undefined;
@@ -31,39 +32,15 @@ const fnCache = new Map<string, CompiledExpression>();
31
32
 
32
33
  /**
33
34
  * Transform expression: $var → variables["var"], _var → temporary["var"],
34
- * @var → locals["var"]
35
- * Only transforms when sigils appear as a word boundary (not inside strings naively,
36
- * but authors already have full JS access so this is acceptable).
37
- * `%var` → transient["var"] is handled by `transform` itself, because `%` is
38
- * also the modulo operator and only an operand position makes it a sigil.
35
+ * @var → locals["var"], %var → transient["var"].
36
+ * Only transforms when sigils appear as a word boundary, and only in code:
37
+ * string, template and regex literal text and comments are left untouched.
38
+ * `%var` is recognised by the lexer (`lexJs`), because `%` is also the modulo
39
+ * operator and only an operand position makes it a sigil.
39
40
  */
40
41
  const VAR_RE = /\$(\w+)/g;
41
42
  const TEMP_RE = /(?<![.\w])_(\w+)/g;
42
43
  const LOCAL_RE = /@(\w+)/g;
43
- /** Transient name after `%`: an identifier, so `%3` is never a reference. */
44
- const TRANS_NAME_RE = /[A-Za-z_]\w*/y;
45
- /** Characters of identifiers, numbers and sigil variable references. */
46
- const WORD_CHAR_RE = /[\w$@]/;
47
- const SPACE_RE = /\s/;
48
- /** Keywords followed by an operand rather than an operator. */
49
- const OPERAND_KEYWORDS = new Set([
50
- 'await',
51
- 'case',
52
- 'delete',
53
- 'do',
54
- 'else',
55
- 'in',
56
- 'instanceof',
57
- 'new',
58
- 'of',
59
- 'return',
60
- 'throw',
61
- 'typeof',
62
- 'void',
63
- 'yield',
64
- ]);
65
- /** Keywords whose parenthesised header is followed by a statement. */
66
- const HEADER_KEYWORDS = new Set(['for', 'if', 'while', 'with']);
67
44
 
68
45
  function transformSegment(segment: string): string {
69
46
  return segment
@@ -73,26 +50,13 @@ function transformSegment(segment: string): string {
73
50
  }
74
51
 
75
52
  /**
76
- * String-aware expression transformer. Walks the expression character by
77
- * character so that variable sigils ($, _, @, %) inside string literals are
78
- * left untouched while code — including expressions inside template-literal
79
- * `${…}` interpolations — is transformed.
80
- *
81
- * It also tracks whether the next token is an operand or an operator, so that
82
- * `%name` in operand position is a transient reference while `%` after an
83
- * operand — `($n)%3`, `$a[i] %2`, `$a%$b` — stays the modulo operator.
53
+ * Rewrite sigil references in `expr`. The lexer passes literal text and
54
+ * comments through untouched; each run of code between them (including the
55
+ * code of template-literal `${…}` interpolations) is transformed as a whole.
84
56
  */
85
57
  function transform(expr: string): string {
86
58
  let result = '';
87
59
  let code = ''; // accumulates code characters to be transformed
88
- let i = 0;
89
-
90
- let operandNext = true; // an operand (not an operator) comes next
91
- let word = ''; // identifier/number currently being read
92
- let afterDot = false; // `word` is a property name, never a keyword
93
- let afterHeaderKeyword = false; // last token was if/while/for/with
94
- let lastPunct = '';
95
- const parenIsHeader: boolean[] = []; // per open `(`: closes a header?
96
60
 
97
61
  function flushCode() {
98
62
  if (code) {
@@ -101,199 +65,19 @@ function transform(expr: string): string {
101
65
  }
102
66
  }
103
67
 
104
- function endWord() {
105
- if (!word) return;
106
- operandNext = !afterDot && OPERAND_KEYWORDS.has(word);
107
- afterHeaderKeyword = !afterDot && HEADER_KEYWORDS.has(word);
108
- afterDot = false;
109
- lastPunct = '';
110
- word = '';
111
- }
112
-
113
- /** A string or template literal, or a transient reference, just ended. */
114
- function endOperand() {
115
- endWord();
116
- operandNext = false;
117
- afterHeaderKeyword = false;
118
- afterDot = false;
119
- lastPunct = '';
120
- }
121
-
122
- function trackCode(c: string) {
123
- if (WORD_CHAR_RE.test(c)) {
124
- word += c;
125
- return;
126
- }
127
- endWord();
128
- // A line may start a new statement such as `%x = 1`.
129
- if (c === '\n') operandNext = true;
130
- if (SPACE_RE.test(c)) return;
131
- if (c === '(') parenIsHeader.push(afterHeaderKeyword);
132
- if (c === ')') {
133
- // `if (…) %x = 1` vs `($n)%3`
134
- operandNext = parenIsHeader.pop() ?? false;
135
- } else if (c === '.') {
136
- // Property access, unless it is the spread `...`
137
- operandNext = lastPunct === '.';
138
- } else {
139
- // `]` ends an operand; `}` closes a block, so a statement may follow.
140
- operandNext = c !== ']';
141
- }
142
- afterDot = c === '.';
143
- afterHeaderKeyword = false;
144
- lastPunct = c;
145
- }
146
-
147
- while (i < expr.length) {
148
- const ch = expr.charAt(i);
149
-
150
- // Single or double quoted string — skip entirely
151
- if (ch === '"' || ch === "'") {
68
+ lexJs(expr, {
69
+ code(ch) {
70
+ code += ch;
71
+ },
72
+ literal(text) {
152
73
  flushCode();
153
- const quote = ch;
154
- let str = quote;
155
- i++;
156
- while (i < expr.length) {
157
- const c = expr.charAt(i);
158
- if (c === '\\' && i + 1 < expr.length) {
159
- str += c + expr.charAt(i + 1);
160
- i += 2;
161
- } else if (c === quote) {
162
- str += quote;
163
- i++;
164
- break;
165
- } else {
166
- str += c;
167
- i++;
168
- }
169
- }
170
- result += str;
171
- endOperand();
172
- continue;
173
- }
174
-
175
- // Template literal — preserve literal parts, transform interpolations
176
- if (ch === '`') {
74
+ result += text;
75
+ },
76
+ transient(name) {
177
77
  flushCode();
178
- result += '`';
179
- i++;
180
- while (i < expr.length) {
181
- const c = expr.charAt(i);
182
- if (c === '\\' && i + 1 < expr.length) {
183
- result += c + expr.charAt(i + 1);
184
- i += 2;
185
- } else if (c === '$' && expr.charAt(i + 1) === '{') {
186
- // Template interpolation — collect the inner expression and
187
- // recursively transform it
188
- result += '${';
189
- i += 2;
190
- let depth = 1;
191
- let inner = '';
192
- while (i < expr.length && depth > 0) {
193
- const ic = expr.charAt(i);
194
- if (ic === '{') {
195
- depth++;
196
- inner += ic;
197
- } else if (ic === '}') {
198
- depth--;
199
- if (depth === 0) break;
200
- inner += ic;
201
- } else if (ic === '\\' && i + 1 < expr.length) {
202
- inner += ic + expr.charAt(i + 1);
203
- i++;
204
- } else if (ic === '"' || ic === "'") {
205
- // Skip quoted strings — braces inside are not depth-relevant
206
- inner += ic;
207
- i++;
208
- while (i < expr.length) {
209
- const sc = expr.charAt(i);
210
- if (sc === '\\' && i + 1 < expr.length) {
211
- inner += sc + expr.charAt(i + 1);
212
- i += 2;
213
- } else if (sc === ic) {
214
- inner += sc;
215
- i++;
216
- break;
217
- } else {
218
- inner += sc;
219
- i++;
220
- }
221
- }
222
- continue; // skip the i++ at the end
223
- } else if (ic === '`') {
224
- // Nested template literal — consume entirely
225
- inner += ic;
226
- i++;
227
- while (i < expr.length) {
228
- const tc = expr.charAt(i);
229
- if (tc === '\\' && i + 1 < expr.length) {
230
- inner += tc + expr.charAt(i + 1);
231
- i += 2;
232
- } else if (tc === '$' && expr.charAt(i + 1) === '{') {
233
- // Nested interpolation — track brace depth
234
- inner += '${';
235
- i += 2;
236
- let nestedDepth = 1;
237
- while (i < expr.length && nestedDepth > 0) {
238
- const nc = expr.charAt(i);
239
- if (nc === '{') nestedDepth++;
240
- else if (nc === '}') nestedDepth--;
241
- if (nestedDepth > 0) inner += nc;
242
- i++;
243
- }
244
- inner += '}';
245
- } else if (tc === '`') {
246
- inner += tc;
247
- i++;
248
- break;
249
- } else {
250
- inner += tc;
251
- i++;
252
- }
253
- }
254
- continue; // skip the i++ at the end
255
- } else {
256
- inner += ic;
257
- }
258
- i++;
259
- }
260
- result += transform(inner); // recursive transform
261
- if (i < expr.length && expr.charAt(i) === '}') {
262
- result += '}';
263
- i++;
264
- }
265
- } else if (c === '`') {
266
- result += '`';
267
- i++;
268
- break;
269
- } else {
270
- result += c;
271
- i++;
272
- }
273
- }
274
- endOperand();
275
- continue;
276
- }
277
-
278
- // Transient reference — only where an operand is expected
279
- if (ch === '%') {
280
- endWord();
281
- TRANS_NAME_RE.lastIndex = i + 1;
282
- const name = operandNext ? TRANS_NAME_RE.exec(expr)?.[0] : undefined;
283
- if (name) {
284
- flushCode();
285
- result += `transient["${name}"]`;
286
- i += 1 + name.length;
287
- endOperand();
288
- continue;
289
- }
290
- }
291
-
292
- // Regular code character
293
- trackCode(ch);
294
- code += ch;
295
- i++;
296
- }
78
+ result += `transient["${name}"]`;
79
+ },
80
+ });
297
81
  flushCode();
298
82
  return result;
299
83
  }
@@ -401,7 +185,8 @@ export function evaluate(
401
185
  transient: Record<string, unknown> = {},
402
186
  ): unknown {
403
187
  const transformed = transform(expr);
404
- const body = `return (${transformed});`;
188
+ // The line break keeps a trailing `// comment` from swallowing the `)`.
189
+ const body = `return (${transformed}\n);`;
405
190
  const fn = getOrCompile(body, body);
406
191
  return fn(variables, temporary, locals, buildExpressionFns(), transient);
407
192
  }