@hyperfixi/reactivity 2.4.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.
@@ -0,0 +1,585 @@
1
+ /**
2
+ * End-to-end integration — parse + installPlugin + execute round-trip.
3
+ *
4
+ * Uses the `registry.snapshot()` / `registry.restore(baseline)` pattern to
5
+ * isolate plugin installations from other tests in the process.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import { Runtime, hyperscript } from '@hyperfixi/core';
10
+ import { parse } from '@hyperfixi/core';
11
+ import { getParserExtensionRegistry, installPlugin } from '@hyperfixi/core';
12
+ import type { ExecutionContext } from '@hyperfixi/core/src/types/core';
13
+ import { reactivityPlugin, reactive } from './index';
14
+
15
+ function createContext(me: HTMLElement): ExecutionContext {
16
+ return {
17
+ me,
18
+ it: null,
19
+ you: null,
20
+ result: null,
21
+ locals: new Map(),
22
+ globals: new Map(),
23
+ variables: new Map(),
24
+ events: new Map(),
25
+ } as unknown as ExecutionContext;
26
+ }
27
+
28
+ /** Drain microtasks + the setTimeout queue so reactive effects settle. */
29
+ async function settle(): Promise<void> {
30
+ for (let i = 0; i < 20; i++) await Promise.resolve();
31
+ await new Promise<void>(resolve => setTimeout(resolve, 0));
32
+ for (let i = 0; i < 20; i++) await Promise.resolve();
33
+ }
34
+
35
+ describe('@hyperfixi/reactivity — integration', () => {
36
+ const registry = getParserExtensionRegistry();
37
+ let baseline: ReturnType<typeof registry.snapshot>;
38
+ let runtime: Runtime;
39
+
40
+ beforeEach(() => {
41
+ baseline = registry.snapshot();
42
+ runtime = new Runtime();
43
+ installPlugin(runtime, reactivityPlugin);
44
+ });
45
+
46
+ afterEach(() => {
47
+ registry.restore(baseline);
48
+ });
49
+
50
+ describe('install idempotency', () => {
51
+ it('installing the plugin twice does not duplicate global-write hooks', async () => {
52
+ // Install a second time — should be a no-op thanks to the hasFeature guard.
53
+ installPlugin(runtime, reactivityPlugin);
54
+
55
+ const el = document.createElement('div');
56
+ document.body.appendChild(el);
57
+ const ctx = createContext(el);
58
+ ctx.globals.set('count', 0);
59
+
60
+ let runs = 0;
61
+ const r = parse('live\n set $mirror to $count\nend');
62
+ expect(r.success).toBe(true);
63
+ // Wrap the live body in our own counter via a wrapping effect.
64
+ const stop = reactive.createEffect(
65
+ () => {
66
+ reactive.trackGlobal('count');
67
+ return ctx.globals.get('count');
68
+ },
69
+ () => {
70
+ runs++;
71
+ },
72
+ el
73
+ );
74
+ await runtime.execute(r.node!, ctx);
75
+ await settle();
76
+
77
+ const baselineRuns = runs;
78
+ ctx.globals.set('count', 1);
79
+ reactive.notifyGlobal('count');
80
+ await settle();
81
+ // Exactly one additional run despite the duplicate install attempt.
82
+ expect(runs).toBe(baselineRuns + 1);
83
+
84
+ stop();
85
+ document.body.removeChild(el);
86
+ });
87
+ });
88
+
89
+ describe('live', () => {
90
+ it('re-runs the body when a tracked global changes', async () => {
91
+ // The live body sets $total from $price; changing $price should re-run
92
+ // the body and update $total.
93
+ const el = document.createElement('div');
94
+ document.body.appendChild(el);
95
+ const ctx = createContext(el);
96
+ ctx.globals.set('price', 10);
97
+ ctx.globals.set('total', 0);
98
+
99
+ const r = parse('live\n set $total to $price\nend');
100
+ expect(r.success).toBe(true);
101
+ await runtime.execute(r.node!, ctx);
102
+ await settle();
103
+
104
+ // Initial run set total = 10.
105
+ expect(ctx.globals.get('total')).toBe(10);
106
+
107
+ // Change price — live should re-run and update total.
108
+ ctx.globals.set('price', 42);
109
+ reactive.notifyGlobal('price'); // emulate the set path's notify hook
110
+ await settle();
111
+ expect(ctx.globals.get('total')).toBe(42);
112
+
113
+ document.body.removeChild(el);
114
+ });
115
+ });
116
+
117
+ describe('when ... changes', () => {
118
+ it('runs the body when the watched expression changes', async () => {
119
+ const el = document.createElement('div');
120
+ el.id = 'target';
121
+ document.body.appendChild(el);
122
+ const ctx = createContext(el);
123
+ ctx.globals.set('message', 'hello');
124
+
125
+ // when $message changes, put it into me → updates textContent.
126
+ const r = parse('when $message changes\n put it into me\nend');
127
+ expect(r.success).toBe(true);
128
+ await runtime.execute(r.node!, ctx);
129
+ await settle();
130
+
131
+ // Initial: textContent should equal 'hello' because handler fires on init.
132
+ expect(el.textContent).toBe('hello');
133
+
134
+ ctx.globals.set('message', 'world');
135
+ reactive.notifyGlobal('message');
136
+ await settle();
137
+ expect(el.textContent).toBe('world');
138
+
139
+ document.body.removeChild(el);
140
+ });
141
+ });
142
+
143
+ describe('bind', () => {
144
+ it('two-way binds a global var to an input value', async () => {
145
+ const input = document.createElement('input');
146
+ input.type = 'text';
147
+ input.value = 'initial';
148
+ document.body.appendChild(input);
149
+ const ctx = createContext(input);
150
+ ctx.globals.set('greeting', 'pending');
151
+
152
+ const r = parse('bind $greeting to me');
153
+ expect(r.success).toBe(true);
154
+ await runtime.execute(r.node!, ctx);
155
+ await settle();
156
+
157
+ // After init, one side wins; expect the var to sync with the DOM value
158
+ // (DOM → var direction runs first).
159
+ expect(ctx.globals.get('greeting')).toBe('initial');
160
+
161
+ // Programmatic var write → DOM updated.
162
+ ctx.globals.set('greeting', 'updated');
163
+ reactive.notifyGlobal('greeting');
164
+ await settle();
165
+ expect(input.value).toBe('updated');
166
+
167
+ // User input → var updated.
168
+ input.value = 'typed';
169
+ input.dispatchEvent(new Event('input'));
170
+ await settle();
171
+ expect(ctx.globals.get('greeting')).toBe('typed');
172
+
173
+ document.body.removeChild(input);
174
+ });
175
+
176
+ it('two-way binds a local var (`:name`) to an input value', async () => {
177
+ const input = document.createElement('input');
178
+ input.type = 'text';
179
+ input.value = 'initial';
180
+ document.body.appendChild(input);
181
+ const ctx = createContext(input);
182
+ ctx.locals.set('greeting', 'pending');
183
+
184
+ const r = parse('bind :greeting to me');
185
+ expect(r.success).toBe(true);
186
+ await runtime.execute(r.node!, ctx);
187
+ await settle();
188
+
189
+ // DOM → var on init (DOM wins).
190
+ expect(ctx.locals.get('greeting')).toBe('initial');
191
+
192
+ // User input updates the local via DOM→var effect.
193
+ input.value = 'typed';
194
+ input.dispatchEvent(new Event('input'));
195
+ await settle();
196
+ expect(ctx.locals.get('greeting')).toBe('typed');
197
+
198
+ document.body.removeChild(input);
199
+ });
200
+
201
+ it('auto-detects checkbox → checked', async () => {
202
+ const cb = document.createElement('input');
203
+ cb.type = 'checkbox';
204
+ cb.checked = false;
205
+ document.body.appendChild(cb);
206
+ const ctx = createContext(cb);
207
+ ctx.globals.set('isOn', true);
208
+
209
+ const r = parse('bind $isOn to me');
210
+ expect(r.success).toBe(true);
211
+ await runtime.execute(r.node!, ctx);
212
+ await settle();
213
+
214
+ // DOM → var on init (DOM wins).
215
+ expect(ctx.globals.get('isOn')).toBe(false);
216
+
217
+ // var → DOM
218
+ ctx.globals.set('isOn', true);
219
+ reactive.notifyGlobal('isOn');
220
+ await settle();
221
+ expect(cb.checked).toBe(true);
222
+
223
+ document.body.removeChild(cb);
224
+ });
225
+
226
+ it("explicit property via possessive (`me's value`) on a text input", async () => {
227
+ const input = document.createElement('input');
228
+ input.type = 'text';
229
+ input.value = 'initial';
230
+ document.body.appendChild(input);
231
+ const ctx = createContext(input);
232
+ ctx.globals.set('greeting', 'pending');
233
+
234
+ const r = parse("bind $greeting to me's value");
235
+ expect(r.success).toBe(true);
236
+ await runtime.execute(r.node!, ctx);
237
+ await settle();
238
+
239
+ // DOM → var on init.
240
+ expect(ctx.globals.get('greeting')).toBe('initial');
241
+
242
+ // var → DOM.
243
+ ctx.globals.set('greeting', 'updated');
244
+ reactive.notifyGlobal('greeting');
245
+ await settle();
246
+ expect(input.value).toBe('updated');
247
+
248
+ // DOM → var on user input (form-like element, listener still active).
249
+ input.value = 'typed';
250
+ input.dispatchEvent(new Event('input'));
251
+ await settle();
252
+ expect(ctx.globals.get('greeting')).toBe('typed');
253
+
254
+ document.body.removeChild(input);
255
+ });
256
+
257
+ it('explicit property via member access (`me.value`) on a text input', async () => {
258
+ const input = document.createElement('input');
259
+ input.type = 'text';
260
+ input.value = 'hello';
261
+ document.body.appendChild(input);
262
+ const ctx = createContext(input);
263
+ ctx.globals.set('greeting', 'pending');
264
+
265
+ const r = parse('bind $greeting to me.value');
266
+ expect(r.success).toBe(true);
267
+ await runtime.execute(r.node!, ctx);
268
+ await settle();
269
+
270
+ expect(ctx.globals.get('greeting')).toBe('hello');
271
+
272
+ ctx.globals.set('greeting', 'changed');
273
+ reactive.notifyGlobal('greeting');
274
+ await settle();
275
+ expect(input.value).toBe('changed');
276
+
277
+ document.body.removeChild(input);
278
+ });
279
+
280
+ it('explicit property targets a non-default property (color input value)', async () => {
281
+ // A color input's default `value` is the same as auto-detect, but this
282
+ // test pins the explicit-property path against a specific property name
283
+ // rather than relying on type-based auto-detection.
284
+ const picker = document.createElement('input');
285
+ picker.type = 'color';
286
+ picker.value = '#ff0000';
287
+ document.body.appendChild(picker);
288
+ const ctx = createContext(picker);
289
+ ctx.globals.set('color', '#000000');
290
+
291
+ const r = parse("bind $color to me's value");
292
+ expect(r.success).toBe(true);
293
+ await runtime.execute(r.node!, ctx);
294
+ await settle();
295
+
296
+ // DOM → var on init.
297
+ expect(ctx.globals.get('color')).toBe('#ff0000');
298
+
299
+ // var → DOM (explicit property name; auto-detect would have picked the
300
+ // same string — but the code path here goes through the unwrap branch).
301
+ ctx.globals.set('color', '#00ff00');
302
+ reactive.notifyGlobal('color');
303
+ await settle();
304
+ expect(picker.value).toBe('#00ff00');
305
+
306
+ document.body.removeChild(picker);
307
+ });
308
+
309
+ it('var → DOM works for non-form properties; DOM → var is skipped', async () => {
310
+ // Binding a div's textContent: var→DOM should propagate; the listener
311
+ // for DOM→var isn't installed because input/change don't fire on divs.
312
+ const div = document.createElement('div');
313
+ div.textContent = 'initial';
314
+ document.body.appendChild(div);
315
+ const ctx = createContext(div);
316
+ ctx.globals.set('text', 'override');
317
+
318
+ const r = parse("bind $text to me's textContent");
319
+ expect(r.success).toBe(true);
320
+ await runtime.execute(r.node!, ctx);
321
+ await settle();
322
+
323
+ // No DOM→var sync, so the var keeps its pre-bind value.
324
+ expect(ctx.globals.get('text')).toBe('override');
325
+
326
+ // var → DOM fires on the initial run because var !== DOM.
327
+ expect(div.textContent).toBe('override');
328
+
329
+ // var → DOM continues to fire on subsequent writes.
330
+ ctx.globals.set('text', 'updated');
331
+ reactive.notifyGlobal('text');
332
+ await settle();
333
+ expect(div.textContent).toBe('updated');
334
+
335
+ document.body.removeChild(div);
336
+ });
337
+
338
+ it('throws with a multi-level hint for chained property access on RHS', async () => {
339
+ const div = document.createElement('div');
340
+ document.body.appendChild(div);
341
+ const ctx = createContext(div);
342
+ // `me.style.backgroundColor` — chained .style.backgroundColor.
343
+ const r = parse('bind $color to me.style.backgroundColor');
344
+ expect(r.success).toBe(true);
345
+ await expect(runtime.execute(r.node!, ctx)).rejects.toThrow(/multi-level property access/);
346
+ document.body.removeChild(div);
347
+ });
348
+
349
+ it('throws with type+snippet hint when RHS does not resolve to an element', async () => {
350
+ const div = document.createElement('div');
351
+ document.body.appendChild(div);
352
+ const ctx = createContext(div);
353
+ // String literal on RHS isn't a var-ref, so we reach the element check
354
+ // with a non-element value and surface the new diagnostic.
355
+ const r = parse('bind $value to "just-a-string"');
356
+ expect(r.success).toBe(true);
357
+ await expect(runtime.execute(r.node!, ctx)).rejects.toThrow(
358
+ /did not resolve to an element \(got string "just-a-string"\)\. If you meant to write to a property/
359
+ );
360
+ document.body.removeChild(div);
361
+ });
362
+ });
363
+
364
+ describe('^name DOM-scoped vars', () => {
365
+ it('writes a caret var via `set ^X to Y`', async () => {
366
+ const host = document.createElement('div');
367
+ document.body.appendChild(host);
368
+ const ctx = createContext(host);
369
+
370
+ const r = parse('set ^count to 5');
371
+ expect(r.success).toBe(true);
372
+ await runtime.execute(r.node!, ctx);
373
+ await settle();
374
+ expect(reactive.readCaret(host, 'count')).toBe(5);
375
+
376
+ document.body.removeChild(host);
377
+ });
378
+
379
+ it('mutates a caret var via `increment ^X`', async () => {
380
+ const host = document.createElement('div');
381
+ document.body.appendChild(host);
382
+ const ctx = createContext(host);
383
+
384
+ reactive.writeCaret(host, 'count', 10, host);
385
+
386
+ const r = parse('increment ^count');
387
+ expect(r.success).toBe(true);
388
+ await runtime.execute(r.node!, ctx);
389
+ await settle();
390
+ expect(reactive.readCaret(host, 'count')).toBe(11);
391
+
392
+ await runtime.execute(r.node!, ctx);
393
+ await settle();
394
+ expect(reactive.readCaret(host, 'count')).toBe(12);
395
+
396
+ document.body.removeChild(host);
397
+ });
398
+
399
+ it('hyperscript.process binds click handler that increments ^count', async () => {
400
+ // Tests the components-style flow: build DOM, hyperscript.process binds
401
+ // _= attributes, simulate click, verify ^count updated.
402
+ const host = document.createElement('div');
403
+ host.innerHTML = '<button _="on click increment ^count">+</button>';
404
+ document.body.appendChild(host);
405
+
406
+ reactive.writeCaret(host, 'count', 0, host);
407
+
408
+ hyperscript.process(host);
409
+ // Settle whatever async parsing/binding happens.
410
+ await settle();
411
+
412
+ const button = host.querySelector('button')!;
413
+ button.click();
414
+ await settle();
415
+ expect(reactive.readCaret(host, 'count')).toBe(1);
416
+
417
+ button.click();
418
+ await settle();
419
+ expect(reactive.readCaret(host, 'count')).toBe(2);
420
+
421
+ button.click();
422
+ await settle();
423
+ expect(reactive.readCaret(host, 'count')).toBe(3);
424
+
425
+ document.body.removeChild(host);
426
+ });
427
+
428
+ it("simulates click-counter: button increments host's ^count", async () => {
429
+ // Mirrors the @hyperfixi/components click-counter scenario: a button
430
+ // inside a host element runs `increment ^count`. The host owns ^count.
431
+ const host = document.createElement('div');
432
+ const button = document.createElement('button');
433
+ host.appendChild(button);
434
+ document.body.appendChild(host);
435
+
436
+ // Init: host owns ^count
437
+ const initCtx = createContext(host);
438
+ const initRes = parse('set ^count to 0');
439
+ expect(initRes.success).toBe(true);
440
+ await runtime.execute(initRes.node!, initCtx);
441
+ await settle();
442
+ expect(reactive.readCaret(host, 'count')).toBe(0);
443
+
444
+ // Click handler: button executes `increment ^count` with me=button
445
+ const clickCtx = createContext(button);
446
+ const clickRes = parse('increment ^count');
447
+ expect(clickRes.success).toBe(true);
448
+
449
+ await runtime.execute(clickRes.node!, clickCtx);
450
+ await settle();
451
+ expect(reactive.readCaret(host, 'count')).toBe(1);
452
+
453
+ await runtime.execute(clickRes.node!, clickCtx);
454
+ await runtime.execute(clickRes.node!, clickCtx);
455
+ await settle();
456
+ expect(reactive.readCaret(host, 'count')).toBe(3);
457
+
458
+ document.body.removeChild(host);
459
+ });
460
+
461
+ it('reads an inherited caret var', async () => {
462
+ const parent = document.createElement('div');
463
+ const child = document.createElement('span');
464
+ parent.appendChild(child);
465
+ document.body.appendChild(parent);
466
+
467
+ reactive.writeCaret(parent, 'theme', 'dark');
468
+ const ctx = createContext(child);
469
+
470
+ // Expression `^theme` reads from the parent.
471
+ const r = parse('set :t to ^theme');
472
+ expect(r.success).toBe(true);
473
+ await runtime.execute(r.node!, ctx);
474
+ await settle();
475
+ expect(ctx.locals.get('t')).toBe('dark');
476
+
477
+ document.body.removeChild(parent);
478
+ });
479
+
480
+ it('reads `^name on <target>` end-to-end via parser + runtime', async () => {
481
+ const a = document.createElement('div');
482
+ const b = document.createElement('div');
483
+ b.id = 'caret-target';
484
+ document.body.appendChild(a);
485
+ document.body.appendChild(b);
486
+
487
+ reactive.writeCaret(b, 'count', 99, b);
488
+
489
+ const ctx = createContext(a);
490
+ // me=a, but we want to read ^count from b via `on`.
491
+ const r = parse('set :v to ^count on #caret-target');
492
+ expect(r.success).toBe(true);
493
+ await runtime.execute(r.node!, ctx);
494
+ await settle();
495
+
496
+ expect(ctx.locals.get('v')).toBe(99);
497
+
498
+ document.body.removeChild(a);
499
+ document.body.removeChild(b);
500
+ });
501
+ });
502
+
503
+ describe('local hooks (cross-handler propagation)', () => {
504
+ it('a `set :foo` from elsewhere triggers an effect that read `:foo`', async () => {
505
+ const el = document.createElement('div');
506
+ document.body.appendChild(el);
507
+ const ctx = createContext(el);
508
+ ctx.locals.set('foo', 'initial');
509
+
510
+ // Effect tracking the local read via the runtime path (so localReadHook fires).
511
+ let lastSeen: unknown = undefined;
512
+ const stop = reactive.createEffect(
513
+ async () => await runtime.execute(parse('set :v to :foo').node!, ctx),
514
+ () => {
515
+ lastSeen = ctx.locals.get('v');
516
+ },
517
+ el
518
+ );
519
+ await settle();
520
+ expect(lastSeen).toBe('initial');
521
+
522
+ // Programmatic `set :foo to ...` from a SEPARATE parse+execute (same context.me).
523
+ // Without local hooks this would be silent; with them, the effect re-runs.
524
+ await runtime.execute(parse('set :foo to "updated"').node!, ctx);
525
+ await settle();
526
+ expect(lastSeen).toBe('updated');
527
+
528
+ stop();
529
+ document.body.removeChild(el);
530
+ });
531
+ });
532
+
533
+ describe('coverage gaps', () => {
534
+ it('bind effects tear down when the owning element is cleaned up', async () => {
535
+ const input = document.createElement('input');
536
+ input.type = 'text';
537
+ input.value = 'a';
538
+ document.body.appendChild(input);
539
+ const ctx = createContext(input);
540
+ ctx.globals.set('x', 'a');
541
+
542
+ const r = parse('bind $x to me');
543
+ expect(r.success).toBe(true);
544
+ await runtime.execute(r.node!, ctx);
545
+ await settle();
546
+
547
+ // Tear down the element via the cleanup registry.
548
+ runtime.cleanup(input);
549
+ document.body.removeChild(input);
550
+
551
+ // Programmatic var write must NOT touch the (cleaned-up) DOM. If the
552
+ // var→DOM effect were still alive, input.value would become 'b'.
553
+ ctx.globals.set('x', 'b');
554
+ reactive.notifyGlobal('x');
555
+ await settle();
556
+ expect(input.value).toBe('a');
557
+ });
558
+
559
+ it('long-lived bind survives 200 user-input events', async () => {
560
+ const input = document.createElement('input');
561
+ input.type = 'text';
562
+ input.value = '';
563
+ document.body.appendChild(input);
564
+ const ctx = createContext(input);
565
+ ctx.globals.set('x', '');
566
+
567
+ const r = parse('bind $x to me');
568
+ expect(r.success).toBe(true);
569
+ await runtime.execute(r.node!, ctx);
570
+ await settle();
571
+
572
+ // 200 separate user-input events. Without the cycle-counter reset (from
573
+ // the previous PR), the DOM→var effect would halt at lifetime trigger #101
574
+ // and the global would freeze.
575
+ for (let i = 0; i < 200; i++) {
576
+ input.value = `v${i}`;
577
+ input.dispatchEvent(new Event('input'));
578
+ await settle();
579
+ }
580
+ expect(ctx.globals.get('x')).toBe('v199');
581
+
582
+ document.body.removeChild(input);
583
+ });
584
+ });
585
+ });
package/src/live.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `live ... end` — reactive block. Body re-runs whenever any dependency read
3
+ * during its execution changes.
4
+ *
5
+ * Upstream syntax:
6
+ * live [commandList] end
7
+ *
8
+ * Each command in the body is re-executed as a single effect. The entire
9
+ * body shares one effect instance; its dependency set is the union of every
10
+ * read performed during body execution.
11
+ */
12
+
13
+ import type { ASTNode, ExecutionContext, FeatureParserCtx } from './types';
14
+ import { reactive } from './signals';
15
+
16
+ export interface LiveFeatureNode extends ASTNode {
17
+ type: 'liveFeature';
18
+ body: ASTNode[];
19
+ }
20
+
21
+ /**
22
+ * Parse `live ... end`. The `live` keyword has already been consumed by the
23
+ * parser dispatcher; we parse the body and expect a trailing `end`.
24
+ */
25
+ export function parseLiveFeature(ctx: unknown, token: unknown): ASTNode {
26
+ const pctx = ctx as FeatureParserCtx;
27
+ const body = pctx.parseCommandListUntilEnd();
28
+ // parseCommandListUntilEnd stops when it sees `end` (but doesn't consume it).
29
+ if (!pctx.isAtEnd() && pctx.check('end')) pctx.match('end');
30
+ const tok = token as { start?: number; end?: number; line?: number; column?: number };
31
+ return {
32
+ type: 'liveFeature',
33
+ body,
34
+ start: tok?.start ?? 0,
35
+ end: pctx.getPosition().end,
36
+ line: tok?.line,
37
+ column: tok?.column,
38
+ } as LiveFeatureNode;
39
+ }
40
+
41
+ /**
42
+ * Create an evaluator bound to a runtime reference. The plugin captures
43
+ * `runtime` at install time and passes it in so effect re-runs can dispatch
44
+ * the body commands without going through module-scope state.
45
+ */
46
+ export function makeEvaluateLiveFeature(runtime: {
47
+ execute(node: ASTNode, ctx: ExecutionContext): Promise<unknown>;
48
+ }): (node: ASTNode, ctx: unknown) => unknown | Promise<unknown> {
49
+ return async function evaluateLiveFeature(node, ctx) {
50
+ const context = ctx as ExecutionContext;
51
+ const owner = (context.me as Element) ?? document.body;
52
+ const n = node as LiveFeatureNode;
53
+
54
+ const stop = reactive.createEffect(
55
+ async () => {
56
+ for (const cmd of n.body) {
57
+ await runtime.execute(cmd, context);
58
+ }
59
+ },
60
+ () => {
61
+ /* no-op — side effects happened inside the expression */
62
+ },
63
+ owner
64
+ );
65
+ context.registerCleanup?.(owner, stop, 'live-effect');
66
+ return undefined;
67
+ };
68
+ }