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/README.md +74 -0
- package/cli/index.js +23 -20
- package/cli/logger.js +122 -0
- package/index.js +8 -2
- package/loader/vite-plugin.js +22 -6
- package/package.json +37 -6
- package/runtime/dom.js +55 -11
- package/runtime/hmr.js +169 -0
- package/runtime/index.js +2 -0
- package/runtime/logger.js +304 -0
- package/runtime/native.js +7 -4
- package/runtime/pulse.js +446 -62
- package/runtime/store.js +227 -19
- package/types/index.d.ts +20 -1
- package/types/logger.d.ts +122 -0
- package/types/pulse.d.ts +33 -0
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
458
|
-
* Only recomputes when
|
|
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
|
|
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
|
-
*
|
|
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
|
};
|