pulse-js-framework 1.4.4 → 1.4.6

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/runtime/pulse.js CHANGED
@@ -1,36 +1,195 @@
1
1
  /**
2
2
  * Pulse - Reactive Primitives
3
+ * @module pulse-js-framework/runtime/pulse
3
4
  *
4
5
  * The core reactivity system based on "pulsations" -
5
6
  * reactive values that propagate changes through the system.
7
+ *
8
+ * @example
9
+ * import { pulse, effect, computed } from './pulse.js';
10
+ *
11
+ * const count = pulse(0);
12
+ * const doubled = computed(() => count.get() * 2);
13
+ *
14
+ * effect(() => {
15
+ * console.log('Count:', count.get(), 'Doubled:', doubled.get());
16
+ * });
17
+ *
18
+ * count.set(5); // Logs: Count: 5 Doubled: 10
19
+ */
20
+
21
+ import { loggers } from './logger.js';
22
+
23
+ const log = loggers.pulse;
24
+
25
+ /**
26
+ * @typedef {Object} ReactiveContext
27
+ * @property {EffectFn|null} currentEffect - Currently executing effect for dependency tracking
28
+ * @property {number} batchDepth - Nesting depth of batch() calls
29
+ * @property {Set<EffectFn>} pendingEffects - Effects queued during batch
30
+ * @property {boolean} isRunningEffects - Flag to prevent recursive effect flushing
31
+ * @property {string|null} currentModuleId - Current module ID for HMR effect tracking
32
+ * @property {Map<string, Set<EffectFn>>} effectRegistry - Module ID to effects mapping for HMR
33
+ */
34
+
35
+ /**
36
+ * Global reactive context - holds all tracking state.
37
+ * Exported for testing purposes (use resetContext() to reset).
38
+ * @type {ReactiveContext}
39
+ */
40
+ export const context = {
41
+ currentEffect: null,
42
+ batchDepth: 0,
43
+ pendingEffects: new Set(),
44
+ isRunningEffects: false,
45
+ // HMR support
46
+ currentModuleId: null,
47
+ effectRegistry: new Map()
48
+ };
49
+
50
+ /**
51
+ * Reset the reactive context to initial state.
52
+ * Use this in tests to ensure isolation between test cases.
53
+ * @returns {void}
54
+ * @example
55
+ * // In test setup/teardown
56
+ * import { resetContext } from 'pulse-js-framework/runtime/pulse';
57
+ * beforeEach(() => resetContext());
58
+ */
59
+ export function resetContext() {
60
+ context.currentEffect = null;
61
+ context.batchDepth = 0;
62
+ context.pendingEffects.clear();
63
+ context.isRunningEffects = false;
64
+ context.currentModuleId = null;
65
+ context.effectRegistry.clear();
66
+ }
67
+
68
+ /**
69
+ * Set the current module ID for HMR effect tracking.
70
+ * Effects created while a module ID is set will be registered for cleanup.
71
+ * @param {string} moduleId - The module identifier (typically import.meta.url)
72
+ * @returns {void}
73
+ */
74
+ export function setCurrentModule(moduleId) {
75
+ context.currentModuleId = moduleId;
76
+ }
77
+
78
+ /**
79
+ * Clear the current module ID after module initialization.
80
+ * @returns {void}
81
+ */
82
+ export function clearCurrentModule() {
83
+ context.currentModuleId = null;
84
+ }
85
+
86
+ /**
87
+ * Dispose all effects associated with a module.
88
+ * Called during HMR to clean up before re-executing the module.
89
+ * @param {string} moduleId - The module identifier to dispose
90
+ * @returns {void}
91
+ */
92
+ export function disposeModule(moduleId) {
93
+ const effects = context.effectRegistry.get(moduleId);
94
+ if (effects) {
95
+ for (const effectFn of effects) {
96
+ // Run cleanup functions
97
+ for (const cleanup of effectFn.cleanups) {
98
+ try {
99
+ cleanup();
100
+ } catch (e) {
101
+ log.error('HMR cleanup error:', e);
102
+ }
103
+ }
104
+ effectFn.cleanups = [];
105
+
106
+ // Unsubscribe from all dependencies
107
+ for (const dep of effectFn.dependencies) {
108
+ dep._unsubscribe(effectFn);
109
+ }
110
+ effectFn.dependencies.clear();
111
+ }
112
+ context.effectRegistry.delete(moduleId);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @typedef {Object} EffectFn
118
+ * @property {Function} run - The effect function to execute
119
+ * @property {Set<Pulse>} dependencies - Set of pulse dependencies
120
+ * @property {Array<Function>} cleanups - Cleanup functions to run
121
+ */
122
+
123
+ /**
124
+ * @typedef {Object} PulseOptions
125
+ * @property {function(*, *): boolean} [equals] - Custom equality function (default: Object.is)
126
+ */
127
+
128
+ /**
129
+ * @typedef {Object} ComputedOptions
130
+ * @property {boolean} [lazy=false] - If true, only compute when value is read
131
+ */
132
+
133
+ /**
134
+ * @typedef {Object} MemoOptions
135
+ * @property {function(*, *): boolean} [equals] - Custom equality function for args comparison
6
136
  */
7
137
 
8
- // Current tracking context for automatic dependency collection
9
- let currentEffect = null;
10
- let batchDepth = 0;
11
- let pendingEffects = new Set();
12
- let isRunningEffects = false;
13
- let cleanupQueue = [];
138
+ /**
139
+ * @typedef {Object} MemoComputedOptions
140
+ * @property {Array<Pulse|Function>} [deps] - Dependencies to watch for changes
141
+ * @property {function(*, *): boolean} [equals] - Custom equality function
142
+ */
14
143
 
15
144
  /**
16
- * Register a cleanup function for the current effect
17
- * Called when the effect re-runs or is disposed
145
+ * @typedef {Object} ReactiveState
146
+ * @property {Object.<string, Pulse>} $pulses - Access to underlying pulse objects
147
+ * @property {function(string): Pulse} $pulse - Get pulse by key
148
+ */
149
+
150
+ /**
151
+ * @typedef {Object} PromiseState
152
+ * @property {Pulse<T>} value - The resolved value
153
+ * @property {Pulse<boolean>} loading - Loading state
154
+ * @property {Pulse<Error|null>} error - Error state
155
+ * @template T
156
+ */
157
+
158
+ /**
159
+ * Register a cleanup function for the current effect.
160
+ * Called when the effect re-runs or is disposed.
161
+ * @param {Function} fn - Cleanup function to register
162
+ * @returns {void}
163
+ * @example
164
+ * effect(() => {
165
+ * const timer = setInterval(() => console.log('tick'), 1000);
166
+ * onCleanup(() => clearInterval(timer));
167
+ * });
18
168
  */
19
169
  export function onCleanup(fn) {
20
- if (currentEffect) {
21
- currentEffect.cleanups.push(fn);
170
+ if (context.currentEffect) {
171
+ context.currentEffect.cleanups.push(fn);
22
172
  }
23
173
  }
24
174
 
25
175
  /**
26
- * Pulse - A reactive value container
27
- * When the value changes, it "pulses" to all its dependents
176
+ * Pulse - A reactive value container.
177
+ * When the value changes, it "pulses" to all its dependents.
178
+ * @template T
28
179
  */
29
180
  export class Pulse {
181
+ /** @type {T} */
30
182
  #value;
183
+ /** @type {Set<EffectFn>} */
31
184
  #subscribers = new Set();
185
+ /** @type {function(T, T): boolean} */
32
186
  #equals;
33
187
 
188
+ /**
189
+ * Create a new Pulse with an initial value
190
+ * @param {T} value - The initial value
191
+ * @param {PulseOptions} [options={}] - Configuration options
192
+ */
34
193
  constructor(value, options = {}) {
35
194
  this.#value = value;
36
195
  this.#equals = options.equals ?? Object.is;
@@ -38,17 +197,28 @@ export class Pulse {
38
197
 
39
198
  /**
40
199
  * Get the current value and track dependency if in an effect context
200
+ * @returns {T} The current value
201
+ * @example
202
+ * const name = pulse('Alice');
203
+ * effect(() => {
204
+ * console.log(name.get()); // Tracks dependency automatically
205
+ * });
41
206
  */
42
207
  get() {
43
- if (currentEffect) {
44
- this.#subscribers.add(currentEffect);
45
- currentEffect.dependencies.add(this);
208
+ if (context.currentEffect) {
209
+ this.#subscribers.add(context.currentEffect);
210
+ context.currentEffect.dependencies.add(this);
46
211
  }
47
212
  return this.#value;
48
213
  }
49
214
 
50
215
  /**
51
216
  * Set a new value and notify all subscribers
217
+ * @param {T} newValue - The new value to set
218
+ * @returns {void}
219
+ * @example
220
+ * const count = pulse(0);
221
+ * count.set(5);
52
222
  */
53
223
  set(newValue) {
54
224
  if (this.#equals(this.#value, newValue)) return;
@@ -58,13 +228,25 @@ export class Pulse {
58
228
 
59
229
  /**
60
230
  * Update value using a function
231
+ * @param {function(T): T} fn - Update function receiving current value
232
+ * @returns {void}
233
+ * @example
234
+ * const count = pulse(0);
235
+ * count.update(n => n + 1); // Increment
61
236
  */
62
237
  update(fn) {
63
238
  this.set(fn(this.#value));
64
239
  }
65
240
 
66
241
  /**
67
- * Subscribe to changes
242
+ * Subscribe to value changes
243
+ * @param {function(T): void} fn - Callback invoked on each change
244
+ * @returns {function(): void} Unsubscribe function
245
+ * @example
246
+ * const count = pulse(0);
247
+ * const unsub = count.subscribe(value => console.log(value));
248
+ * count.set(1); // Logs: 1
249
+ * unsub(); // Stop listening
68
250
  */
69
251
  subscribe(fn) {
70
252
  const subscriber = { run: fn, dependencies: new Set() };
@@ -74,6 +256,13 @@ export class Pulse {
74
256
 
75
257
  /**
76
258
  * Create a derived pulse that recomputes when this changes
259
+ * @template U
260
+ * @param {function(T): U} fn - Derivation function
261
+ * @returns {Pulse<U>} A new computed pulse
262
+ * @example
263
+ * const count = pulse(5);
264
+ * const doubled = count.derive(n => n * 2);
265
+ * doubled.get(); // 10
77
266
  */
78
267
  derive(fn) {
79
268
  return computed(() => fn(this.get()));
@@ -81,14 +270,16 @@ export class Pulse {
81
270
 
82
271
  /**
83
272
  * Notify all subscribers of a change
273
+ * @private
274
+ * @returns {void}
84
275
  */
85
276
  #notify() {
86
277
  // Copy subscribers to avoid mutation during iteration
87
278
  const subs = [...this.#subscribers];
88
279
 
89
280
  for (const subscriber of subs) {
90
- if (batchDepth > 0 || isRunningEffects) {
91
- pendingEffects.add(subscriber);
281
+ if (context.batchDepth > 0 || context.isRunningEffects) {
282
+ context.pendingEffects.add(subscriber);
92
283
  } else {
93
284
  runEffect(subscriber);
94
285
  }
@@ -96,7 +287,10 @@ export class Pulse {
96
287
  }
97
288
 
98
289
  /**
99
- * Unsubscribe a specific subscriber
290
+ * Unsubscribe a specific subscriber (internal use)
291
+ * @param {EffectFn} subscriber - The subscriber to remove
292
+ * @returns {void}
293
+ * @internal
100
294
  */
101
295
  _unsubscribe(subscriber) {
102
296
  this.#subscribers.delete(subscriber);
@@ -104,6 +298,10 @@ export class Pulse {
104
298
 
105
299
  /**
106
300
  * Get value without tracking (for debugging/inspection)
301
+ * @returns {T} The current value without tracking dependency
302
+ * @example
303
+ * const count = pulse(5);
304
+ * count.peek(); // 5, no dependency tracked
107
305
  */
108
306
  peek() {
109
307
  return this.#value;
@@ -111,6 +309,9 @@ export class Pulse {
111
309
 
112
310
  /**
113
311
  * Initialize value without triggering notifications (internal use)
312
+ * @param {T} value - The value to set
313
+ * @returns {void}
314
+ * @internal
114
315
  */
115
316
  _init(value) {
116
317
  this.#value = value;
@@ -118,6 +319,9 @@ export class Pulse {
118
319
 
119
320
  /**
120
321
  * Set from computed - propagates to subscribers (internal use)
322
+ * @param {T} newValue - The new value
323
+ * @returns {void}
324
+ * @internal
121
325
  */
122
326
  _setFromComputed(newValue) {
123
327
  if (this.#equals(this.#value, newValue)) return;
@@ -127,6 +331,9 @@ export class Pulse {
127
331
 
128
332
  /**
129
333
  * Add a subscriber directly (internal use)
334
+ * @param {EffectFn} subscriber - The subscriber to add
335
+ * @returns {void}
336
+ * @internal
130
337
  */
131
338
  _addSubscriber(subscriber) {
132
339
  this.#subscribers.add(subscriber);
@@ -134,6 +341,8 @@ export class Pulse {
134
341
 
135
342
  /**
136
343
  * Trigger notification to all subscribers (internal use)
344
+ * @returns {void}
345
+ * @internal
137
346
  */
138
347
  _triggerNotify() {
139
348
  this.#notify();
@@ -142,6 +351,9 @@ export class Pulse {
142
351
 
143
352
  /**
144
353
  * Run a single effect safely
354
+ * @private
355
+ * @param {EffectFn} effectFn - The effect to run
356
+ * @returns {void}
145
357
  */
146
358
  function runEffect(effectFn) {
147
359
  if (!effectFn || !effectFn.run) return;
@@ -149,25 +361,27 @@ function runEffect(effectFn) {
149
361
  try {
150
362
  effectFn.run();
151
363
  } catch (error) {
152
- console.error('Effect error:', error);
364
+ log.error('Effect error:', error);
153
365
  }
154
366
  }
155
367
 
156
368
  /**
157
369
  * Flush all pending effects
370
+ * @private
371
+ * @returns {void}
158
372
  */
159
373
  function flushEffects() {
160
- if (isRunningEffects) return;
374
+ if (context.isRunningEffects) return;
161
375
 
162
- isRunningEffects = true;
376
+ context.isRunningEffects = true;
163
377
  let iterations = 0;
164
378
  const maxIterations = 100; // Prevent infinite loops
165
379
 
166
380
  try {
167
- while (pendingEffects.size > 0 && iterations < maxIterations) {
381
+ while (context.pendingEffects.size > 0 && iterations < maxIterations) {
168
382
  iterations++;
169
- const effects = [...pendingEffects];
170
- pendingEffects.clear();
383
+ const effects = [...context.pendingEffects];
384
+ context.pendingEffects.clear();
171
385
 
172
386
  for (const effect of effects) {
173
387
  runEffect(effect);
@@ -175,24 +389,48 @@ function flushEffects() {
175
389
  }
176
390
 
177
391
  if (iterations >= maxIterations) {
178
- console.warn('Pulse: Maximum effect iterations reached. Possible infinite loop.');
179
- pendingEffects.clear();
392
+ log.warn('Maximum effect iterations reached. Possible infinite loop.');
393
+ context.pendingEffects.clear();
180
394
  }
181
395
  } finally {
182
- isRunningEffects = false;
396
+ context.isRunningEffects = false;
183
397
  }
184
398
  }
185
399
 
186
400
  /**
187
- * Create a simple pulse with an initial value
401
+ * Create a reactive pulse with an initial value
402
+ * @template T
403
+ * @param {T} value - The initial value
404
+ * @param {PulseOptions} [options] - Configuration options
405
+ * @returns {Pulse<T>} A new Pulse instance
406
+ * @example
407
+ * const count = pulse(0);
408
+ * const user = pulse({ name: 'Alice' });
409
+ *
410
+ * // With custom equality
411
+ * const data = pulse(obj, { equals: (a, b) => a.id === b.id });
188
412
  */
189
413
  export function pulse(value, options) {
190
414
  return new Pulse(value, options);
191
415
  }
192
416
 
193
417
  /**
194
- * Create a computed pulse that automatically updates
195
- * when its dependencies change
418
+ * Create a computed pulse that automatically updates when its dependencies change
419
+ * @template T
420
+ * @param {function(): T} fn - Computation function
421
+ * @param {ComputedOptions} [options={}] - Configuration options
422
+ * @returns {Pulse<T>} A read-only Pulse that updates automatically
423
+ * @example
424
+ * const firstName = pulse('John');
425
+ * const lastName = pulse('Doe');
426
+ * const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
427
+ *
428
+ * fullName.get(); // 'John Doe'
429
+ * firstName.set('Jane');
430
+ * fullName.get(); // 'Jane Doe'
431
+ *
432
+ * // Lazy computation (only runs when read)
433
+ * const expensive = computed(() => heavyCalculation(), { lazy: true });
196
434
  */
197
435
  export function computed(fn, options = {}) {
198
436
  const { lazy = false } = options;
@@ -212,13 +450,13 @@ export function computed(fn, options = {}) {
212
450
  p.get = function() {
213
451
  if (dirty) {
214
452
  // Run computation
215
- const prevEffect = currentEffect;
453
+ const prevEffect = context.currentEffect;
216
454
  const tempEffect = {
217
455
  run: () => {},
218
456
  dependencies: new Set(),
219
457
  cleanups: []
220
458
  };
221
- currentEffect = tempEffect;
459
+ context.currentEffect = tempEffect;
222
460
 
223
461
  try {
224
462
  cachedValue = fn();
@@ -241,14 +479,14 @@ export function computed(fn, options = {}) {
241
479
 
242
480
  p._init(cachedValue);
243
481
  } finally {
244
- currentEffect = prevEffect;
482
+ context.currentEffect = prevEffect;
245
483
  }
246
484
  }
247
485
 
248
486
  // Track dependency on this computed
249
- if (currentEffect) {
250
- p._addSubscriber(currentEffect);
251
- currentEffect.dependencies.add(p);
487
+ if (context.currentEffect) {
488
+ p._addSubscriber(context.currentEffect);
489
+ context.currentEffect.dependencies.add(p);
252
490
  }
253
491
 
254
492
  return cachedValue;
@@ -288,8 +526,29 @@ export function computed(fn, options = {}) {
288
526
 
289
527
  /**
290
528
  * Create an effect that runs when its dependencies change
529
+ * @param {function(): void|function(): void} fn - Effect function, may return a cleanup function
530
+ * @returns {function(): void} Dispose function to stop the effect
531
+ * @example
532
+ * const count = pulse(0);
533
+ *
534
+ * // Basic effect
535
+ * const dispose = effect(() => {
536
+ * console.log('Count is:', count.get());
537
+ * });
538
+ *
539
+ * count.set(1); // Logs: Count is: 1
540
+ * dispose(); // Stop listening
541
+ *
542
+ * // With cleanup
543
+ * effect(() => {
544
+ * const timer = setInterval(() => tick(), 1000);
545
+ * return () => clearInterval(timer); // Cleanup on re-run or dispose
546
+ * });
291
547
  */
292
548
  export function effect(fn) {
549
+ // Capture module ID at creation time for HMR tracking
550
+ const moduleId = context.currentModuleId;
551
+
293
552
  const effectFn = {
294
553
  run: () => {
295
554
  // Run cleanup functions from previous run
@@ -297,7 +556,7 @@ export function effect(fn) {
297
556
  try {
298
557
  cleanup();
299
558
  } catch (e) {
300
- console.error('Cleanup error:', e);
559
+ log.error('Cleanup error:', e);
301
560
  }
302
561
  }
303
562
  effectFn.cleanups = [];
@@ -309,21 +568,29 @@ export function effect(fn) {
309
568
  effectFn.dependencies.clear();
310
569
 
311
570
  // Set as current effect for dependency tracking
312
- const prevEffect = currentEffect;
313
- currentEffect = effectFn;
571
+ const prevEffect = context.currentEffect;
572
+ context.currentEffect = effectFn;
314
573
 
315
574
  try {
316
575
  fn();
317
576
  } catch (error) {
318
- console.error('Effect execution error:', error);
577
+ log.error('Effect execution error:', error);
319
578
  } finally {
320
- currentEffect = prevEffect;
579
+ context.currentEffect = prevEffect;
321
580
  }
322
581
  },
323
582
  dependencies: new Set(),
324
583
  cleanups: []
325
584
  };
326
585
 
586
+ // HMR: Register effect with current module
587
+ if (moduleId) {
588
+ if (!context.effectRegistry.has(moduleId)) {
589
+ context.effectRegistry.set(moduleId, new Set());
590
+ }
591
+ context.effectRegistry.get(moduleId).add(effectFn);
592
+ }
593
+
327
594
  // Run immediately to collect dependencies
328
595
  effectFn.run();
329
596
 
@@ -334,7 +601,7 @@ export function effect(fn) {
334
601
  try {
335
602
  cleanup();
336
603
  } catch (e) {
337
- console.error('Cleanup error:', e);
604
+ log.error('Cleanup error:', e);
338
605
  }
339
606
  }
340
607
  effectFn.cleanups = [];
@@ -343,28 +610,69 @@ export function effect(fn) {
343
610
  dep._unsubscribe(effectFn);
344
611
  }
345
612
  effectFn.dependencies.clear();
613
+
614
+ // HMR: Remove from registry
615
+ if (moduleId && context.effectRegistry.has(moduleId)) {
616
+ context.effectRegistry.get(moduleId).delete(effectFn);
617
+ }
346
618
  };
347
619
  }
348
620
 
349
621
  /**
350
- * Batch multiple updates into one
351
- * Effects only run after all updates complete
622
+ * Batch multiple updates into one. Effects only run after all updates complete.
623
+ * @template T
624
+ * @param {function(): T} fn - Function containing multiple updates
625
+ * @returns {T} The return value of fn
626
+ * @example
627
+ * const x = pulse(0);
628
+ * const y = pulse(0);
629
+ *
630
+ * effect(() => console.log(x.get(), y.get()));
631
+ *
632
+ * // Without batch: logs twice
633
+ * x.set(1);
634
+ * y.set(1);
635
+ *
636
+ * // With batch: logs once
637
+ * batch(() => {
638
+ * x.set(2);
639
+ * y.set(2);
640
+ * });
352
641
  */
353
642
  export function batch(fn) {
354
- batchDepth++;
643
+ context.batchDepth++;
355
644
  try {
356
- fn();
645
+ return fn();
357
646
  } finally {
358
- batchDepth--;
359
- if (batchDepth === 0) {
647
+ context.batchDepth--;
648
+ if (context.batchDepth === 0) {
360
649
  flushEffects();
361
650
  }
362
651
  }
363
652
  }
364
653
 
365
654
  /**
366
- * Create a reactive state object from a plain object
367
- * Each property becomes a pulse
655
+ * Create a reactive state object from a plain object.
656
+ * Each property becomes a pulse with getters/setters.
657
+ * @template T
658
+ * @param {T} obj - Plain object with initial values
659
+ * @returns {T & ReactiveState} Reactive state object
660
+ * @example
661
+ * const state = createState({
662
+ * count: 0,
663
+ * items: ['a', 'b']
664
+ * });
665
+ *
666
+ * // Use as regular properties
667
+ * state.count = 5;
668
+ * console.log(state.count); // 5
669
+ *
670
+ * // Array helpers
671
+ * state.items$push('c');
672
+ * state.items$filter(item => item !== 'a');
673
+ *
674
+ * // Access underlying pulses
675
+ * state.$pulse('count').subscribe(v => console.log(v));
368
676
  */
369
677
  export function createState(obj) {
370
678
  const state = {};
@@ -454,8 +762,21 @@ export function createState(obj) {
454
762
  }
455
763
 
456
764
  /**
457
- * Memoize a function based on reactive dependencies
458
- * Only recomputes when dependencies change
765
+ * Memoize a function based on its arguments.
766
+ * Only recomputes when arguments change.
767
+ * @template {function} T
768
+ * @param {T} fn - Function to memoize
769
+ * @param {MemoOptions} [options={}] - Configuration options
770
+ * @returns {T} Memoized function
771
+ * @example
772
+ * const expensiveCalc = memo((x, y) => {
773
+ * console.log('Computing...');
774
+ * return x * y;
775
+ * });
776
+ *
777
+ * expensiveCalc(2, 3); // Logs: Computing... Returns: 6
778
+ * expensiveCalc(2, 3); // Returns: 6 (cached, no log)
779
+ * expensiveCalc(3, 4); // Logs: Computing... Returns: 12
459
780
  */
460
781
  export function memo(fn, options = {}) {
461
782
  const { equals = Object.is } = options;
@@ -480,8 +801,20 @@ export function memo(fn, options = {}) {
480
801
  }
481
802
 
482
803
  /**
483
- * Create a memoized computed value
484
- * Combines memo with computed for expensive derivations
804
+ * Create a memoized computed value.
805
+ * Combines memo with computed for expensive derivations.
806
+ * @template T
807
+ * @param {function(): T} fn - Computation function
808
+ * @param {MemoComputedOptions} [options={}] - Configuration options
809
+ * @returns {Pulse<T>} A computed pulse that only recalculates when deps change
810
+ * @example
811
+ * const items = pulse([1, 2, 3, 4, 5]);
812
+ * const filter = pulse('');
813
+ *
814
+ * const filtered = memoComputed(
815
+ * () => items.get().filter(i => String(i).includes(filter.get())),
816
+ * { deps: [items, filter] }
817
+ * );
485
818
  */
486
819
  export function memoComputed(fn, options = {}) {
487
820
  const { deps = [], equals = Object.is } = options;
@@ -505,7 +838,26 @@ export function memoComputed(fn, options = {}) {
505
838
  }
506
839
 
507
840
  /**
508
- * Watch specific pulses and run a callback when they change
841
+ * Watch specific pulses and run a callback when they change.
842
+ * Unlike effect, provides both new and old values.
843
+ * @template T
844
+ * @param {Pulse<T>|Array<Pulse<T>>} sources - Pulse(s) to watch
845
+ * @param {function(Array<T>, Array<T>): void} callback - Called with (newValues, oldValues)
846
+ * @returns {function(): void} Dispose function to stop watching
847
+ * @example
848
+ * const count = pulse(0);
849
+ *
850
+ * const stop = watch(count, ([newVal], [oldVal]) => {
851
+ * console.log(`Changed from ${oldVal} to ${newVal}`);
852
+ * });
853
+ *
854
+ * count.set(1); // Logs: Changed from 0 to 1
855
+ * stop();
856
+ *
857
+ * // Watch multiple
858
+ * watch([a, b], ([newA, newB], [oldA, oldB]) => {
859
+ * console.log('Values changed');
860
+ * });
509
861
  */
510
862
  export function watch(sources, callback) {
511
863
  const sourcesArray = Array.isArray(sources) ? sources : [sources];
@@ -520,7 +872,22 @@ export function watch(sources, callback) {
520
872
  }
521
873
 
522
874
  /**
523
- * Create a pulse from a promise
875
+ * Create pulses from a promise, tracking loading and error states.
876
+ * @template T
877
+ * @param {Promise<T>} promise - The promise to track
878
+ * @param {T} [initialValue=undefined] - Initial value while loading
879
+ * @returns {PromiseState<T>} Object with value, loading, and error pulses
880
+ * @example
881
+ * const { value, loading, error } = fromPromise(
882
+ * fetch('/api/data').then(r => r.json()),
883
+ * [] // Initial value
884
+ * );
885
+ *
886
+ * effect(() => {
887
+ * if (loading.get()) return console.log('Loading...');
888
+ * if (error.get()) return console.log('Error:', error.get());
889
+ * console.log('Data:', value.get());
890
+ * });
524
891
  */
525
892
  export function fromPromise(promise, initialValue = undefined) {
526
893
  const p = pulse(initialValue);
@@ -545,15 +912,26 @@ export function fromPromise(promise, initialValue = undefined) {
545
912
  }
546
913
 
547
914
  /**
548
- * Untrack - read pulses without creating dependencies
915
+ * Execute a function without tracking any pulse dependencies.
916
+ * Useful for reading values without creating subscriptions.
917
+ * @template T
918
+ * @param {function(): T} fn - Function to execute untracked
919
+ * @returns {T} The return value of fn
920
+ * @example
921
+ * effect(() => {
922
+ * const a = aSignal.get(); // Tracked
923
+ * const b = untrack(() => bSignal.get()); // Not tracked
924
+ * console.log(a, b);
925
+ * });
926
+ * // Effect only re-runs when aSignal changes, not bSignal
549
927
  */
550
928
  export function untrack(fn) {
551
- const prevEffect = currentEffect;
552
- currentEffect = null;
929
+ const prevEffect = context.currentEffect;
930
+ context.currentEffect = null;
553
931
  try {
554
932
  return fn();
555
933
  } finally {
556
- currentEffect = prevEffect;
934
+ context.currentEffect = prevEffect;
557
935
  }
558
936
  }
559
937
 
@@ -569,5 +947,11 @@ export default {
569
947
  untrack,
570
948
  onCleanup,
571
949
  memo,
572
- memoComputed
950
+ memoComputed,
951
+ context,
952
+ resetContext,
953
+ // HMR support
954
+ setCurrentModule,
955
+ clearCurrentModule,
956
+ disposeModule
573
957
  };