@webjsdev/core 0.7.1

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/src/signal.js ADDED
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Signals, reactive scalar values that re-render dependents when they
3
+ * change.
4
+ *
5
+ * The shape mirrors the TC39 Signals proposal (Stage 1). When the
6
+ * proposal lands in browsers this module becomes a one-line re-export.
7
+ *
8
+ * import { signal, computed, effect } from '@webjsdev/core';
9
+ *
10
+ * const count = signal(0);
11
+ * const doubled = computed(() => count.get() * 2);
12
+ * count.set(count.get() + 1);
13
+ *
14
+ * Inside a WebComponent render(), any signal read is tracked
15
+ * automatically. The component re-renders when any read signal changes.
16
+ * For fine-grained reactivity (only one template hole updates), use the
17
+ * `watch()` directive from `@webjsdev/core/directives`.
18
+ *
19
+ * Cross-request safety note (SSR): a module-scope signal lives for the
20
+ * lifetime of the Node process, so `let count = signal(0)` at the top
21
+ * of a server module would leak state between requests. Keep
22
+ * module-scope signals in browser-only modules. For request-scoped
23
+ * server state, use per-instance signals or the request context
24
+ * primitives in `@webjsdev/server`.
25
+ *
26
+ * Algorithm: push-pull hybrid (matches signal-polyfill).
27
+ *
28
+ * - Each producer (State or Computed) carries a `version`. A State
29
+ * bumps its version on a real `set()` (where the new value is NOT
30
+ * `.equal` to the old). A Computed bumps its version after recompute
31
+ * only when the recomputed output is not equal to the prior.
32
+ *
33
+ * - A consumer (Computed or Watcher) records, per producer it reads,
34
+ * the version it saw at read time. On the next read, the consumer
35
+ * polls each producer: if the stored version still matches, that
36
+ * producer hasn't actually changed and recomputation can be skipped.
37
+ *
38
+ * - On `set()`, a producer pushes a dirty mark down to live consumers
39
+ * (consumers that need notification because they're watched). Push
40
+ * marks the consumer dirty cheaply; the version check on read is
41
+ * what determines whether a recompute happens. This is what makes
42
+ * diamond dependencies glitch-free and memoizes the common case
43
+ * where a computed's output doesn't actually change.
44
+ */
45
+
46
+ /* ============================================================
47
+ * Graph primitives
48
+ * ============================================================ */
49
+
50
+ /**
51
+ * The currently-active consumer (Computed or Watcher) whose dependency
52
+ * set is being recorded. `null` at top-level / outside any reactive
53
+ * context. Reads of producers while this is non-null register an edge.
54
+ * @type {ReactiveNode | null}
55
+ */
56
+ let activeConsumer = null;
57
+
58
+ /** Are we currently inside a Watcher notify callback? Reads + writes throw. */
59
+ let inNotificationPhase = false;
60
+
61
+ /**
62
+ * Global epoch counter. Bumped on every `State.set` that produces a
63
+ * value change. Lets clean consumers skip producer polling entirely
64
+ * when no source has changed since their last read.
65
+ */
66
+ let epoch = 0;
67
+
68
+ /**
69
+ * Batched set: pending watcher notifications, drained at the outermost
70
+ * batch close. Inside a batch, computed reads see the new values
71
+ * immediately; only Watcher notify firing is deferred.
72
+ * @type {Set<ReactiveNode> | null}
73
+ */
74
+ let batchPending = null;
75
+ let batchDepth = 0;
76
+
77
+ /**
78
+ * @typedef {Object} ReactiveNode
79
+ * @property {number} version
80
+ * @property {number} lastCleanEpoch
81
+ * @property {boolean} dirty
82
+ * @property {ReactiveNode[]} producers
83
+ * @property {number[]} producerLastReadVersions
84
+ * @property {Set<ReactiveNode>=} liveConsumers
85
+ * @property {boolean} consumerAllowSignalWrites
86
+ * @property {boolean} consumerIsAlwaysLive
87
+ * @property {(this: ReactiveNode) => boolean} producerMustRecompute
88
+ * @property {(this: ReactiveNode) => void} producerRecomputeValue
89
+ * @property {(this: ReactiveNode) => void} consumerMarkedDirty
90
+ * @property {any} wrapper
91
+ * @property {(this: any) => void=} watched
92
+ * @property {(this: any) => void=} unwatched
93
+ */
94
+
95
+ /** Default equality check, identical to TC39 spec default. */
96
+ const defaultEquals = (a, b) => Object.is(a, b);
97
+
98
+ const NOOP = () => {};
99
+ const FALSE_FN = () => false;
100
+
101
+ /**
102
+ * Add `consumer` as a live consumer of `node`. If `node` transitions
103
+ * from 0 to 1+ live consumers AND is itself a consumer (a Computed),
104
+ * recursively register `node` as a live consumer of its own producers.
105
+ * This is how source `set()` calls reach watchers across computed
106
+ * chains: the push side of the graph propagates upward only when
107
+ * something downstream is actually live.
108
+ */
109
+ function producerAddLiveConsumer(node, consumer) {
110
+ if (!node.liveConsumers) node.liveConsumers = new Set();
111
+ const wasEmpty = node.liveConsumers.size === 0;
112
+ node.liveConsumers.add(consumer);
113
+ if (wasEmpty) {
114
+ if (node.producers && node.producers.length > 0) {
115
+ for (const p of node.producers) producerAddLiveConsumer(p, node);
116
+ }
117
+ if (node.watched) {
118
+ try { node.watched.call(node.wrapper); } catch (e) { console.error(e); }
119
+ }
120
+ }
121
+ }
122
+
123
+ /** Inverse of producerAddLiveConsumer. Cascades teardown upward. */
124
+ function producerRemoveLiveConsumer(node, consumer) {
125
+ if (!node.liveConsumers) return;
126
+ node.liveConsumers.delete(consumer);
127
+ if (node.liveConsumers.size === 0) {
128
+ if (node.producers && node.producers.length > 0) {
129
+ for (const p of node.producers) producerRemoveLiveConsumer(p, node);
130
+ }
131
+ if (node.unwatched) {
132
+ try { node.unwatched.call(node.wrapper); } catch (e) { console.error(e); }
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Called whenever a producer is read (during a Computed body or a
139
+ * Watcher's tracked region). Records the edge.
140
+ * @param {ReactiveNode} node
141
+ */
142
+ function producerAccessed(node) {
143
+ if (inNotificationPhase) {
144
+ throw new Error('Signal read during notification phase');
145
+ }
146
+ if (activeConsumer === null) return;
147
+
148
+ const consumer = activeConsumer;
149
+ const idx = consumer.nextProducerIndex++;
150
+
151
+ if (idx < consumer.producers.length && consumer.producers[idx] !== node) {
152
+ // The producer at this slot from the previous evaluation isn't the
153
+ // same as the one being read now. Drop the stale live edge.
154
+ if (consumerIsLive(consumer)) {
155
+ producerRemoveLiveConsumer(consumer.producers[idx], consumer);
156
+ }
157
+ }
158
+
159
+ if (consumer.producers[idx] !== node) {
160
+ consumer.producers[idx] = node;
161
+ if (consumerIsLive(consumer)) {
162
+ producerAddLiveConsumer(node, consumer);
163
+ }
164
+ }
165
+ consumer.producerLastReadVersions[idx] = node.version;
166
+ }
167
+
168
+ /** True if `node` has any live consumer (watched or under a live computed). */
169
+ function consumerIsLive(node) {
170
+ return node.consumerIsAlwaysLive || (node.liveConsumers && node.liveConsumers.size > 0);
171
+ }
172
+
173
+ /**
174
+ * Walk live consumers, mark them dirty, cascade. This is the push side
175
+ * of push-pull: it does NOT pre-emptively recompute computeds, only
176
+ * marks them so the next read knows to check.
177
+ * @param {ReactiveNode} node
178
+ */
179
+ function producerNotifyConsumers(node) {
180
+ if (!node.liveConsumers || node.liveConsumers.size === 0) return;
181
+ const prev = inNotificationPhase;
182
+ inNotificationPhase = true;
183
+ try {
184
+ for (const consumer of node.liveConsumers) {
185
+ if (!consumer.dirty) {
186
+ consumerMarkDirty(consumer);
187
+ }
188
+ }
189
+ } finally {
190
+ inNotificationPhase = prev;
191
+ }
192
+ }
193
+
194
+ /** Mark a consumer dirty, cascade dirty to its own live consumers, fire its callback. */
195
+ function consumerMarkDirty(node) {
196
+ node.dirty = true;
197
+ producerNotifyConsumers(node);
198
+ node.consumerMarkedDirty.call(node.wrapper || node);
199
+ }
200
+
201
+ /**
202
+ * Refresh `node.version` to reflect the current state of its inputs.
203
+ * For a Computed, this re-runs the computation if any input version
204
+ * has changed since the last evaluation. Memoizes via the `equal` check:
205
+ * if recomputed output equals prior, `version` is not bumped.
206
+ * @param {ReactiveNode} node
207
+ */
208
+ function producerUpdateValueVersion(node) {
209
+ if (!node.dirty && node.lastCleanEpoch === epoch) return;
210
+ if (!node.producerMustRecompute() && !consumerPollProducersForChange(node)) {
211
+ node.dirty = false;
212
+ node.lastCleanEpoch = epoch;
213
+ return;
214
+ }
215
+ node.producerRecomputeValue();
216
+ node.dirty = false;
217
+ node.lastCleanEpoch = epoch;
218
+ }
219
+
220
+ /** Walk producers, recursively refresh each, check if any version moved. */
221
+ function consumerPollProducersForChange(node) {
222
+ for (let i = 0; i < node.producers.length; i++) {
223
+ const producer = node.producers[i];
224
+ const seenVersion = node.producerLastReadVersions[i];
225
+ if (seenVersion !== producer.version) return true;
226
+ producerUpdateValueVersion(producer);
227
+ if (seenVersion !== producer.version) return true;
228
+ }
229
+ return false;
230
+ }
231
+
232
+ /** Begin a tracked region: this node's producers will be recorded. */
233
+ function consumerBeforeComputation(node) {
234
+ if (node) node.nextProducerIndex = 0;
235
+ const prev = activeConsumer;
236
+ activeConsumer = node;
237
+ return prev;
238
+ }
239
+
240
+ /** End a tracked region: prune stale edges, restore previous consumer. */
241
+ function consumerAfterComputation(node, prev) {
242
+ activeConsumer = prev;
243
+ if (!node) return;
244
+ if (consumerIsLive(node)) {
245
+ for (let i = node.nextProducerIndex; i < node.producers.length; i++) {
246
+ producerRemoveLiveConsumer(node.producers[i], node);
247
+ }
248
+ }
249
+ while (node.producers.length > node.nextProducerIndex) {
250
+ node.producers.pop();
251
+ node.producerLastReadVersions.pop();
252
+ }
253
+ }
254
+
255
+ /* ============================================================
256
+ * State signal
257
+ * ============================================================ */
258
+
259
+ class SignalState {
260
+ /**
261
+ * @param {unknown} initial
262
+ * @param {{equals?: (a:unknown,b:unknown)=>boolean, [k:symbol]: any}=} options
263
+ */
264
+ constructor(initial, options) {
265
+ this.__value = initial;
266
+ this.version = 0;
267
+ this.lastCleanEpoch = epoch;
268
+ this.dirty = false;
269
+ this.producers = [];
270
+ this.producerLastReadVersions = [];
271
+ this.liveConsumers = undefined;
272
+ this.consumerAllowSignalWrites = true;
273
+ this.consumerIsAlwaysLive = false;
274
+ this.producerMustRecompute = FALSE_FN;
275
+ this.producerRecomputeValue = NOOP;
276
+ this.consumerMarkedDirty = NOOP;
277
+ this.wrapper = this;
278
+ this.__isSignal = true;
279
+ this.__equal = options?.equals || defaultEquals;
280
+ this.watched = options?.[WATCHED];
281
+ this.unwatched = options?.[UNWATCHED];
282
+ }
283
+
284
+ get() {
285
+ producerAccessed(this);
286
+ return this.__value;
287
+ }
288
+
289
+ peek() { return this.__value; }
290
+
291
+ set(v) {
292
+ if (inNotificationPhase) {
293
+ throw new Error('Signal write during notification phase');
294
+ }
295
+ if (this.__equal.call(this, this.__value, v)) return;
296
+ this.__value = v;
297
+ this.version++;
298
+ epoch++;
299
+ if (batchPending !== null) {
300
+ // Collect watchers whose notify is deferred; computeds still see
301
+ // the new value via versioning.
302
+ collectBatched(this);
303
+ } else {
304
+ producerNotifyConsumers(this);
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * During a batch, walk the live-consumer tree once and stash watchers
311
+ * for deferred firing. Computeds get their dirty flag set the same way;
312
+ * only the Watcher notify fires later.
313
+ */
314
+ function collectBatched(node) {
315
+ const prev = inNotificationPhase;
316
+ inNotificationPhase = true;
317
+ try {
318
+ if (!node.liveConsumers) return;
319
+ for (const consumer of node.liveConsumers) {
320
+ if (consumer.dirty) continue;
321
+ consumer.dirty = true;
322
+ if (consumer.__isWatcher) {
323
+ batchPending.add(consumer);
324
+ } else {
325
+ collectBatched(consumer);
326
+ }
327
+ }
328
+ } finally {
329
+ inNotificationPhase = prev;
330
+ }
331
+ }
332
+
333
+ /* ============================================================
334
+ * Computed signal
335
+ * ============================================================ */
336
+
337
+ const UNSET = Symbol('unset');
338
+ const COMPUTING = Symbol('computing');
339
+ const ERRORED = Symbol('errored');
340
+
341
+ class SignalComputed {
342
+ /**
343
+ * @param {() => unknown} fn
344
+ * @param {{equals?: (a:unknown,b:unknown)=>boolean, [k:symbol]: any}=} options
345
+ */
346
+ constructor(fn, options) {
347
+ this.__fn = fn;
348
+ this.__value = UNSET;
349
+ this.__error = null;
350
+ this.version = 0;
351
+ this.lastCleanEpoch = -1;
352
+ this.dirty = true;
353
+ this.producers = [];
354
+ this.producerLastReadVersions = [];
355
+ this.nextProducerIndex = 0;
356
+ this.liveConsumers = undefined;
357
+ this.consumerAllowSignalWrites = false;
358
+ this.consumerIsAlwaysLive = false;
359
+ this.wrapper = this;
360
+ this.__isSignal = true;
361
+ this.__isComputed = true;
362
+ this.__equal = options?.equals || defaultEquals;
363
+ this.watched = options?.[WATCHED];
364
+ this.unwatched = options?.[UNWATCHED];
365
+
366
+ const self = this;
367
+ this.producerMustRecompute = function() {
368
+ return self.__value === UNSET || self.__value === COMPUTING;
369
+ };
370
+ this.producerRecomputeValue = function() {
371
+ if (self.__value === COMPUTING) {
372
+ throw new Error('Detected cycle in computations.');
373
+ }
374
+ const oldValue = self.__value;
375
+ self.__value = COMPUTING;
376
+ const prev = consumerBeforeComputation(self);
377
+ let newValue;
378
+ let isError = false;
379
+ try {
380
+ newValue = self.__fn.call(self.wrapper);
381
+ } catch (err) {
382
+ newValue = ERRORED;
383
+ self.__error = err;
384
+ isError = true;
385
+ } finally {
386
+ consumerAfterComputation(self, prev);
387
+ }
388
+ const oldOk = oldValue !== UNSET && oldValue !== ERRORED;
389
+ if (!isError && oldOk && self.__equal.call(self.wrapper, oldValue, newValue)) {
390
+ // Output unchanged. Restore prior value, don't bump version,
391
+ // so downstream consumers can short-circuit on the version check.
392
+ self.__value = oldValue;
393
+ return;
394
+ }
395
+ self.__value = newValue;
396
+ self.version++;
397
+ };
398
+ this.consumerMarkedDirty = NOOP;
399
+ }
400
+
401
+ get() {
402
+ producerUpdateValueVersion(this);
403
+ producerAccessed(this);
404
+ if (this.__value === ERRORED) throw this.__error;
405
+ return this.__value;
406
+ }
407
+
408
+ peek() {
409
+ producerUpdateValueVersion(this);
410
+ if (this.__value === ERRORED) throw this.__error;
411
+ return this.__value;
412
+ }
413
+ }
414
+
415
+ /* ============================================================
416
+ * Watcher
417
+ * ============================================================ */
418
+
419
+ class SignalWatcher {
420
+ /** @param {() => void} notifyCb */
421
+ constructor(notifyCb) {
422
+ this.__notify = notifyCb;
423
+ this.version = 0;
424
+ this.lastCleanEpoch = epoch;
425
+ this.dirty = false;
426
+ this.producers = [];
427
+ this.producerLastReadVersions = [];
428
+ this.nextProducerIndex = 0;
429
+ this.liveConsumers = undefined;
430
+ this.consumerAllowSignalWrites = false;
431
+ this.consumerIsAlwaysLive = true;
432
+ this.producerMustRecompute = FALSE_FN;
433
+ this.producerRecomputeValue = NOOP;
434
+ this.wrapper = this;
435
+ this.__isWatcher = true;
436
+ const self = this;
437
+ this.consumerMarkedDirty = function() { self.__fire(); };
438
+ }
439
+
440
+ /** Fire the user's notify cb once; dirty stays until `watch()` re-arms. */
441
+ __fire() {
442
+ if (this.__notify) this.__notify.call(this);
443
+ }
444
+
445
+ /**
446
+ * Spec API: register signals to watch. Without args, re-arms (clears
447
+ * the dirty mark so the next change will fire notify again).
448
+ * @param {...(SignalState|SignalComputed)} signals
449
+ */
450
+ watch(...signals) {
451
+ if (signals.length === 0) {
452
+ // Re-arm.
453
+ this.dirty = false;
454
+ return;
455
+ }
456
+ const prev = consumerBeforeComputation(this);
457
+ try {
458
+ for (const sig of signals) {
459
+ producerAccessed(sig);
460
+ if (sig.__isComputed) {
461
+ producerUpdateValueVersion(sig);
462
+ }
463
+ }
464
+ } finally {
465
+ consumerAfterComputation(this, prev);
466
+ }
467
+ this.dirty = false;
468
+ }
469
+
470
+ /**
471
+ * Spec API: stop watching the given signals.
472
+ * @param {...(SignalState|SignalComputed)} signals
473
+ */
474
+ unwatch(...signals) {
475
+ for (let i = this.producers.length - 1; i >= 0; i--) {
476
+ if (signals.includes(this.producers[i])) {
477
+ producerRemoveLiveConsumer(this.producers[i], this);
478
+ this.producers.splice(i, 1);
479
+ this.producerLastReadVersions.splice(i, 1);
480
+ this.nextProducerIndex = Math.min(this.nextProducerIndex, this.producers.length);
481
+ }
482
+ }
483
+ }
484
+
485
+ /** Spec API: list watched Computeds that are currently dirty. */
486
+ getPending() {
487
+ return this.producers.filter((p) => p.__isComputed && p.dirty);
488
+ }
489
+
490
+ /**
491
+ * Convenience extension (not in TC39 spec). Runs `fn` with this
492
+ * watcher as the active consumer, so any signal `fn` reads is added
493
+ * to the watcher's producer set automatically. Re-arms on every call.
494
+ *
495
+ * Used by the WebComponent integration and the `watch()` directive
496
+ * because the natural webjs pattern is "watch everything this render
497
+ * touches" rather than enumerating signals up front.
498
+ *
499
+ * @param {() => unknown} fn
500
+ * @returns {unknown}
501
+ */
502
+ observe(fn) {
503
+ const prev = consumerBeforeComputation(this);
504
+ let result;
505
+ try {
506
+ result = fn();
507
+ } finally {
508
+ consumerAfterComputation(this, prev);
509
+ }
510
+ this.dirty = false;
511
+ return result;
512
+ }
513
+
514
+ /** Convenience extension: tear down all subscriptions. */
515
+ dispose() {
516
+ for (const producer of this.producers) {
517
+ producerRemoveLiveConsumer(producer, this);
518
+ }
519
+ this.producers = [];
520
+ this.producerLastReadVersions = [];
521
+ this.__notify = null;
522
+ }
523
+ }
524
+
525
+ /* ============================================================
526
+ * Subtle helpers
527
+ * ============================================================ */
528
+
529
+ const WATCHED = Symbol('watched');
530
+ const UNWATCHED = Symbol('unwatched');
531
+
532
+ /**
533
+ * Run `fn` with dependency tracking suppressed. Reads inside see
534
+ * current values but are not recorded as edges of the surrounding
535
+ * consumer.
536
+ * @template T
537
+ * @param {() => T} fn
538
+ * @returns {T}
539
+ */
540
+ function untrack(fn) {
541
+ const prev = activeConsumer;
542
+ activeConsumer = null;
543
+ try { return fn(); } finally { activeConsumer = prev; }
544
+ }
545
+
546
+ /** @param {SignalComputed | SignalWatcher} sink */
547
+ function introspectSources(sink) {
548
+ return sink.producers.slice();
549
+ }
550
+
551
+ /** @param {SignalState | SignalComputed} producer */
552
+ function introspectSinks(producer) {
553
+ return producer.liveConsumers ? [...producer.liveConsumers] : [];
554
+ }
555
+
556
+ /** @param {SignalState | SignalComputed} producer */
557
+ function hasSinks(producer) {
558
+ return !!producer.liveConsumers && producer.liveConsumers.size > 0;
559
+ }
560
+
561
+ /** @param {SignalComputed | SignalWatcher} sink */
562
+ function hasSources(sink) {
563
+ return sink.producers.length > 0;
564
+ }
565
+
566
+ /** The innermost active Computed, or null. */
567
+ function currentComputed() {
568
+ return activeConsumer && activeConsumer.__isComputed ? activeConsumer : null;
569
+ }
570
+
571
+ /* ============================================================
572
+ * Batch
573
+ * ============================================================ */
574
+
575
+ /**
576
+ * Open a batch. All `set()` calls inside `fn` defer Watcher notify
577
+ * firing until the outermost batch closes. Inside the batch,
578
+ * subsequent `get()` reads see the new values immediately; only the
579
+ * Watcher notify callback is deferred.
580
+ *
581
+ * @param {() => void} fn
582
+ */
583
+ export function batch(fn) {
584
+ batchDepth++;
585
+ if (batchPending === null) batchPending = new Set();
586
+ try {
587
+ fn();
588
+ } finally {
589
+ batchDepth--;
590
+ if (batchDepth === 0) {
591
+ const pending = batchPending;
592
+ batchPending = null;
593
+ for (const w of pending) w.__fire();
594
+ }
595
+ }
596
+ }
597
+
598
+ /* ============================================================
599
+ * Public surface
600
+ * ============================================================ */
601
+
602
+ /**
603
+ * TC39-shaped namespace. `Signal.State`, `Signal.Computed`, plus
604
+ * `Signal.subtle.*` for advanced use (Watcher, untrack, introspection,
605
+ * the watched/unwatched symbols).
606
+ */
607
+ export const Signal = {
608
+ State: SignalState,
609
+ Computed: SignalComputed,
610
+ subtle: {
611
+ Watcher: SignalWatcher,
612
+ untrack,
613
+ introspectSources,
614
+ introspectSinks,
615
+ hasSinks,
616
+ hasSources,
617
+ currentComputed,
618
+ watched: WATCHED,
619
+ unwatched: UNWATCHED,
620
+ },
621
+ };
622
+
623
+ /**
624
+ * Convenience constructor for a writable signal.
625
+ * @template T
626
+ * @param {T} initial
627
+ * @param {{equals?: (a:T,b:T)=>boolean}=} options
628
+ * @returns {SignalState}
629
+ */
630
+ export function signal(initial, options) { return new SignalState(initial, options); }
631
+
632
+ /**
633
+ * Convenience constructor for a computed signal.
634
+ * @template T
635
+ * @param {() => T} fn
636
+ * @param {{equals?: (a:T,b:T)=>boolean}=} options
637
+ * @returns {SignalComputed}
638
+ */
639
+ export function computed(fn, options) { return new SignalComputed(fn, options); }
640
+
641
+ /**
642
+ * Predicate: discriminate a signal from a plain value.
643
+ * @param {unknown} v
644
+ */
645
+ export function isSignal(v) {
646
+ return !!(v && typeof v === 'object' && /** @type any */ (v).__isSignal === true);
647
+ }
648
+
649
+ /**
650
+ * Run `fn` once, then re-run it whenever any signal it read changes.
651
+ * Returns a dispose function.
652
+ *
653
+ * Effects are useful for browser-only side effects such as DOM
654
+ * mutations, timers, or fetch calls. Avoid in render paths; the
655
+ * component's built-in SignalWatcher already covers re-render.
656
+ *
657
+ * The spec forbids reads/writes inside a Watcher notify, so effects
658
+ * defer the re-run to a microtask.
659
+ *
660
+ * @param {() => void} fn
661
+ * @returns {() => void}
662
+ */
663
+ export function effect(fn) {
664
+ let scheduled = false;
665
+ let disposed = false;
666
+ const computed_ = new SignalComputed(fn);
667
+ const w = new SignalWatcher(() => {
668
+ if (scheduled || disposed) return;
669
+ scheduled = true;
670
+ queueMicrotask(() => {
671
+ scheduled = false;
672
+ if (disposed) return;
673
+ try { computed_.get(); } catch (e) { console.error(e); }
674
+ w.watch();
675
+ });
676
+ });
677
+ w.watch(computed_);
678
+ try { computed_.get(); } catch (e) { console.error(e); }
679
+ return () => {
680
+ if (disposed) return;
681
+ disposed = true;
682
+ w.dispose();
683
+ };
684
+ }