pulse-js-framework 1.7.4 → 1.7.5

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
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { loggers } from './logger.js';
22
+ import { Errors } from '../core/errors.js';
22
23
 
23
24
  const log = loggers.pulse;
24
25
 
@@ -77,23 +78,163 @@ const log = loggers.pulse;
77
78
  */
78
79
  const MAX_EFFECT_ITERATIONS = 100;
79
80
 
81
+ // =============================================================================
82
+ // REACTIVE CONTEXT CLASS
83
+ // =============================================================================
84
+
85
+ /**
86
+ * ReactiveContext - Encapsulates all state for an isolated reactive system.
87
+ *
88
+ * This allows multiple independent reactive systems to coexist:
89
+ * - Isolated testing (each test gets its own context)
90
+ * - Server-side rendering (one context per request)
91
+ * - Micro-frontends (each app gets its own context)
92
+ *
93
+ * @example
94
+ * // Create isolated context for testing
95
+ * const testContext = new ReactiveContext();
96
+ * testContext.run(() => {
97
+ * const count = pulse(0);
98
+ * effect(() => console.log(count.get()));
99
+ * count.set(1);
100
+ * });
101
+ *
102
+ * // Global context is unaffected
103
+ */
104
+ export class ReactiveContext {
105
+ /**
106
+ * Create a new reactive context
107
+ * @param {Object} [options] - Configuration options
108
+ * @param {string} [options.name] - Name for debugging
109
+ */
110
+ constructor(options = {}) {
111
+ this.name = options.name || `context_${++ReactiveContext._idCounter}`;
112
+ this.currentEffect = null;
113
+ this.batchDepth = 0;
114
+ this.pendingEffects = new Set();
115
+ this.isRunningEffects = false;
116
+ // HMR support
117
+ this.currentModuleId = null;
118
+ this.effectRegistry = new Map();
119
+ }
120
+
121
+ /**
122
+ * Reset this context to initial state
123
+ */
124
+ reset() {
125
+ this.currentEffect = null;
126
+ this.batchDepth = 0;
127
+ this.pendingEffects.clear();
128
+ this.isRunningEffects = false;
129
+ this.currentModuleId = null;
130
+ this.effectRegistry.clear();
131
+ }
132
+
133
+ /**
134
+ * Run a function within this context
135
+ * @template T
136
+ * @param {function(): T} fn - Function to run
137
+ * @returns {T} Return value of fn
138
+ */
139
+ run(fn) {
140
+ const prevContext = activeContext;
141
+ activeContext = this;
142
+ try {
143
+ return fn();
144
+ } finally {
145
+ activeContext = prevContext;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Check if this context is currently active
151
+ * @returns {boolean}
152
+ */
153
+ isActive() {
154
+ return activeContext === this;
155
+ }
156
+
157
+ /** @private */
158
+ static _idCounter = 0;
159
+ }
160
+
80
161
  /**
81
- * Global reactive context - holds all tracking state.
82
- * Exported for testing purposes (use resetContext() to reset).
162
+ * The currently active reactive context.
83
163
  * @type {ReactiveContext}
164
+ * @private
84
165
  */
85
- export const context = {
86
- currentEffect: null,
87
- batchDepth: 0,
88
- pendingEffects: new Set(),
89
- isRunningEffects: false,
90
- // HMR support
91
- currentModuleId: null,
92
- effectRegistry: new Map()
93
- };
166
+ let activeContext;
167
+
168
+ /**
169
+ * Global default reactive context - used when no specific context is active.
170
+ * @type {ReactiveContext}
171
+ */
172
+ export const globalContext = new ReactiveContext({ name: 'global' });
173
+
174
+ // Initialize active context to global
175
+ activeContext = globalContext;
176
+
177
+ /**
178
+ * Get the currently active reactive context.
179
+ * @returns {ReactiveContext} The active context
180
+ */
181
+ export function getActiveContext() {
182
+ return activeContext;
183
+ }
184
+
185
+ /**
186
+ * Run a function within a specific reactive context.
187
+ * Useful for isolating reactive operations in tests or SSR.
188
+ *
189
+ * @template T
190
+ * @param {ReactiveContext} ctx - The context to use
191
+ * @param {function(): T} fn - Function to run
192
+ * @returns {T} Return value of fn
193
+ *
194
+ * @example
195
+ * const isolated = new ReactiveContext();
196
+ * withContext(isolated, () => {
197
+ * const x = pulse(0);
198
+ * effect(() => console.log(x.get()));
199
+ * });
200
+ */
201
+ export function withContext(ctx, fn) {
202
+ return ctx.run(fn);
203
+ }
204
+
205
+ /**
206
+ * Create a new isolated reactive context.
207
+ * @param {Object} [options] - Configuration options
208
+ * @param {string} [options.name] - Name for debugging
209
+ * @returns {ReactiveContext} A new isolated context
210
+ *
211
+ * @example
212
+ * // In tests
213
+ * let ctx;
214
+ * beforeEach(() => { ctx = createContext({ name: 'test' }); });
215
+ * afterEach(() => ctx.reset());
216
+ *
217
+ * test('isolated test', () => {
218
+ * ctx.run(() => {
219
+ * const count = pulse(0);
220
+ * // This effect only exists in ctx
221
+ * });
222
+ * });
223
+ */
224
+ export function createContext(options) {
225
+ return new ReactiveContext(options);
226
+ }
227
+
228
+ /**
229
+ * Legacy: Global reactive context object for backward compatibility.
230
+ * Prefer using getActiveContext() for new code.
231
+ * @type {ReactiveContext}
232
+ * @deprecated Use getActiveContext() instead
233
+ */
234
+ export const context = globalContext;
94
235
 
95
236
  /**
96
- * Reset the reactive context to initial state.
237
+ * Reset the active reactive context to initial state.
97
238
  * Use this in tests to ensure isolation between test cases.
98
239
  * @returns {void}
99
240
  * @example
@@ -102,12 +243,7 @@ export const context = {
102
243
  * beforeEach(() => resetContext());
103
244
  */
104
245
  export function resetContext() {
105
- context.currentEffect = null;
106
- context.batchDepth = 0;
107
- context.pendingEffects.clear();
108
- context.isRunningEffects = false;
109
- context.currentModuleId = null;
110
- context.effectRegistry.clear();
246
+ activeContext.reset();
111
247
  }
112
248
 
113
249
  /**
@@ -174,7 +310,7 @@ export function onEffectError(handler) {
174
310
  * @returns {void}
175
311
  */
176
312
  export function setCurrentModule(moduleId) {
177
- context.currentModuleId = moduleId;
313
+ activeContext.currentModuleId = moduleId;
178
314
  }
179
315
 
180
316
  /**
@@ -182,7 +318,7 @@ export function setCurrentModule(moduleId) {
182
318
  * @returns {void}
183
319
  */
184
320
  export function clearCurrentModule() {
185
- context.currentModuleId = null;
321
+ activeContext.currentModuleId = null;
186
322
  }
187
323
 
188
324
  /**
@@ -192,7 +328,7 @@ export function clearCurrentModule() {
192
328
  * @returns {void}
193
329
  */
194
330
  export function disposeModule(moduleId) {
195
- const effects = context.effectRegistry.get(moduleId);
331
+ const effects = activeContext.effectRegistry.get(moduleId);
196
332
  if (effects) {
197
333
  for (const effectFn of effects) {
198
334
  // Run cleanup functions
@@ -211,7 +347,7 @@ export function disposeModule(moduleId) {
211
347
  }
212
348
  effectFn.dependencies.clear();
213
349
  }
214
- context.effectRegistry.delete(moduleId);
350
+ activeContext.effectRegistry.delete(moduleId);
215
351
  }
216
352
  }
217
353
 
@@ -269,8 +405,8 @@ export function disposeModule(moduleId) {
269
405
  * });
270
406
  */
271
407
  export function onCleanup(fn) {
272
- if (context.currentEffect) {
273
- context.currentEffect.cleanups.push(fn);
408
+ if (activeContext.currentEffect) {
409
+ activeContext.currentEffect.cleanups.push(fn);
274
410
  }
275
411
  }
276
412
 
@@ -307,9 +443,9 @@ export class Pulse {
307
443
  * });
308
444
  */
309
445
  get() {
310
- if (context.currentEffect) {
311
- this.#subscribers.add(context.currentEffect);
312
- context.currentEffect.dependencies.add(this);
446
+ if (activeContext.currentEffect) {
447
+ this.#subscribers.add(activeContext.currentEffect);
448
+ activeContext.currentEffect.dependencies.add(this);
313
449
  }
314
450
  return this.#value;
315
451
  }
@@ -380,8 +516,8 @@ export class Pulse {
380
516
  const subs = [...this.#subscribers];
381
517
 
382
518
  for (const subscriber of subs) {
383
- if (context.batchDepth > 0 || context.isRunningEffects) {
384
- context.pendingEffects.add(subscriber);
519
+ if (activeContext.batchDepth > 0 || activeContext.isRunningEffects) {
520
+ activeContext.pendingEffects.add(subscriber);
385
521
  } else {
386
522
  runEffect(subscriber);
387
523
  }
@@ -519,19 +655,19 @@ function runEffect(effectFn) {
519
655
  * @returns {void}
520
656
  */
521
657
  function flushEffects() {
522
- if (context.isRunningEffects) return;
658
+ if (activeContext.isRunningEffects) return;
523
659
 
524
- context.isRunningEffects = true;
660
+ activeContext.isRunningEffects = true;
525
661
  let iterations = 0;
526
662
 
527
663
  // Track effect run counts to identify infinite loop culprits
528
664
  const effectRunCounts = new Map();
529
665
 
530
666
  try {
531
- while (context.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
667
+ while (activeContext.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
532
668
  iterations++;
533
- const effects = [...context.pendingEffects];
534
- context.pendingEffects.clear();
669
+ const effects = [...activeContext.pendingEffects];
670
+ activeContext.pendingEffects.clear();
535
671
 
536
672
  for (const eff of effects) {
537
673
  // Track how many times each effect runs
@@ -547,33 +683,26 @@ function flushEffects() {
547
683
  .sort((a, b) => b[1] - a[1])
548
684
  .slice(0, 10);
549
685
 
550
- const culpritDetails = sortedByRuns
551
- .map(([id, count]) => `${id} (${count} runs)`)
552
- .join(', ');
686
+ const culprits = sortedByRuns
687
+ .map(([id, count]) => `${id} (${count} runs)`);
553
688
 
554
689
  // Still pending effects
555
- const stillPending = [...context.pendingEffects]
690
+ const stillPending = [...activeContext.pendingEffects]
556
691
  .map(e => e.id || 'unknown')
557
- .slice(0, 5)
558
- .join(', ');
692
+ .slice(0, 5);
559
693
 
560
- const errorMsg =
561
- `[Pulse] INFINITE LOOP DETECTED\n` +
562
- `Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached.\n` +
563
- `Most active effects: [${culpritDetails}]\n` +
564
- `Still pending: [${stillPending || 'none'}]\n` +
565
- `Tip: Check for circular dependencies where effects trigger each other.`;
694
+ const error = Errors.circularDependency(culprits, stillPending);
566
695
 
567
696
  // Always use console.error directly to ensure visibility
568
- console.error(errorMsg);
697
+ console.error(error.message);
569
698
 
570
699
  // Also log through the logger for consistency
571
- log.error(errorMsg);
700
+ log.error(error.message);
572
701
 
573
- context.pendingEffects.clear();
702
+ activeContext.pendingEffects.clear();
574
703
  }
575
704
  } finally {
576
- context.isRunningEffects = false;
705
+ activeContext.isRunningEffects = false;
577
706
  }
578
707
  }
579
708
 
@@ -632,13 +761,13 @@ export function computed(fn, options = {}) {
632
761
  p.get = function() {
633
762
  if (dirty) {
634
763
  // Run computation
635
- const prevEffect = context.currentEffect;
764
+ const prevEffect = activeContext.currentEffect;
636
765
  const tempEffect = {
637
766
  run: () => {},
638
767
  dependencies: new Set(),
639
768
  cleanups: []
640
769
  };
641
- context.currentEffect = tempEffect;
770
+ activeContext.currentEffect = tempEffect;
642
771
 
643
772
  try {
644
773
  cachedValue = fn();
@@ -664,14 +793,14 @@ export function computed(fn, options = {}) {
664
793
 
665
794
  p._init(cachedValue);
666
795
  } finally {
667
- context.currentEffect = prevEffect;
796
+ activeContext.currentEffect = prevEffect;
668
797
  }
669
798
  }
670
799
 
671
800
  // Track dependency on this computed
672
- if (context.currentEffect) {
673
- p._addSubscriber(context.currentEffect);
674
- context.currentEffect.dependencies.add(p);
801
+ if (activeContext.currentEffect) {
802
+ p._addSubscriber(activeContext.currentEffect);
803
+ activeContext.currentEffect.dependencies.add(p);
675
804
  }
676
805
 
677
806
  return cachedValue;
@@ -701,18 +830,11 @@ export function computed(fn, options = {}) {
701
830
 
702
831
  // Override set to make it read-only
703
832
  p.set = () => {
704
- throw new Error(
705
- '[Pulse] Cannot set a computed value directly. ' +
706
- 'Computed values are derived from other pulses and update automatically. ' +
707
- 'Modify the source pulse(s) instead.'
708
- );
833
+ throw Errors.computedSet(p._name || null);
709
834
  };
710
835
 
711
836
  p.update = () => {
712
- throw new Error(
713
- '[Pulse] Cannot update a computed value directly. ' +
714
- 'Computed values are read-only. Modify the source pulse(s) instead.'
715
- );
837
+ throw Errors.computedSet(p._name || null);
716
838
  };
717
839
 
718
840
  // Add dispose method
@@ -764,7 +886,7 @@ export function effect(fn, options = {}) {
764
886
  const effectId = customId || `effect_${++effectIdCounter}`;
765
887
 
766
888
  // Capture module ID at creation time for HMR tracking
767
- const moduleId = context.currentModuleId;
889
+ const moduleId = activeContext.currentModuleId;
768
890
 
769
891
  const effectFn = {
770
892
  id: effectId,
@@ -787,15 +909,15 @@ export function effect(fn, options = {}) {
787
909
  effectFn.dependencies.clear();
788
910
 
789
911
  // Set as current effect for dependency tracking
790
- const prevEffect = context.currentEffect;
791
- context.currentEffect = effectFn;
912
+ const prevEffect = activeContext.currentEffect;
913
+ activeContext.currentEffect = effectFn;
792
914
 
793
915
  try {
794
916
  fn();
795
917
  } catch (error) {
796
918
  handleEffectError(error, effectFn, 'execution');
797
919
  } finally {
798
- context.currentEffect = prevEffect;
920
+ activeContext.currentEffect = prevEffect;
799
921
  }
800
922
  },
801
923
  dependencies: new Set(),
@@ -804,10 +926,10 @@ export function effect(fn, options = {}) {
804
926
 
805
927
  // HMR: Register effect with current module
806
928
  if (moduleId) {
807
- if (!context.effectRegistry.has(moduleId)) {
808
- context.effectRegistry.set(moduleId, new Set());
929
+ if (!activeContext.effectRegistry.has(moduleId)) {
930
+ activeContext.effectRegistry.set(moduleId, new Set());
809
931
  }
810
- context.effectRegistry.get(moduleId).add(effectFn);
932
+ activeContext.effectRegistry.get(moduleId).add(effectFn);
811
933
  }
812
934
 
813
935
  // Run immediately to collect dependencies
@@ -831,8 +953,8 @@ export function effect(fn, options = {}) {
831
953
  effectFn.dependencies.clear();
832
954
 
833
955
  // HMR: Remove from registry
834
- if (moduleId && context.effectRegistry.has(moduleId)) {
835
- context.effectRegistry.get(moduleId).delete(effectFn);
956
+ if (moduleId && activeContext.effectRegistry.has(moduleId)) {
957
+ activeContext.effectRegistry.get(moduleId).delete(effectFn);
836
958
  }
837
959
  };
838
960
  }
@@ -859,12 +981,12 @@ export function effect(fn, options = {}) {
859
981
  * });
860
982
  */
861
983
  export function batch(fn) {
862
- context.batchDepth++;
984
+ activeContext.batchDepth++;
863
985
  try {
864
986
  return fn();
865
987
  } finally {
866
- context.batchDepth--;
867
- if (context.batchDepth === 0) {
988
+ activeContext.batchDepth--;
989
+ if (activeContext.batchDepth === 0) {
868
990
  flushEffects();
869
991
  }
870
992
  }
@@ -1145,12 +1267,12 @@ export function fromPromise(promise, initialValue = undefined) {
1145
1267
  * // Effect only re-runs when aSignal changes, not bSignal
1146
1268
  */
1147
1269
  export function untrack(fn) {
1148
- const prevEffect = context.currentEffect;
1149
- context.currentEffect = null;
1270
+ const prevEffect = activeContext.currentEffect;
1271
+ activeContext.currentEffect = null;
1150
1272
  try {
1151
1273
  return fn();
1152
1274
  } finally {
1153
- context.currentEffect = prevEffect;
1275
+ activeContext.currentEffect = prevEffect;
1154
1276
  }
1155
1277
  }
1156
1278
 
package/runtime/router.js CHANGED
@@ -16,6 +16,8 @@
16
16
  import { pulse, effect, batch } from './pulse.js';
17
17
  import { el } from './dom.js';
18
18
  import { loggers } from './logger.js';
19
+ import { createVersionedAsync } from './async.js';
20
+ import { Errors } from '../core/errors.js';
19
21
 
20
22
  const log = loggers.router;
21
23
 
@@ -55,8 +57,9 @@ export function lazy(importFn, options = {}) {
55
57
  // Cache for loaded component
56
58
  let cachedComponent = null;
57
59
  let loadPromise = null;
58
- // Version counter to invalidate stale load callbacks
59
- let currentLoadVersion = 0;
60
+
61
+ // Use centralized versioned async for race condition handling
62
+ const versionController = createVersionedAsync();
60
63
 
61
64
  return function lazyHandler(ctx) {
62
65
  // Return cached component if already loaded
@@ -72,35 +75,22 @@ export function lazy(importFn, options = {}) {
72
75
 
73
76
  // Create container for async loading
74
77
  const container = el('div.lazy-route');
75
- let loadingTimer = null;
76
- let timeoutTimer = null;
77
- let isAborted = false;
78
-
79
- // Increment version and capture for this load attempt
80
- const loadVersion = ++currentLoadVersion;
81
78
 
82
- // Helper to check if this load is still valid
83
- const isStale = () => isAborted || loadVersion !== currentLoadVersion;
84
-
85
- // Cleanup function to abort this load attempt
86
- const abort = () => {
87
- isAborted = true;
88
- clearTimeout(loadingTimer);
89
- clearTimeout(timeoutTimer);
90
- };
79
+ // Start a new versioned load operation
80
+ const loadCtx = versionController.begin();
91
81
 
92
82
  // Attach abort method to container for cleanup on navigation
93
- container._pulseAbortLazyLoad = abort;
83
+ container._pulseAbortLazyLoad = () => versionController.abort();
94
84
 
95
85
  // Start loading if not already
96
86
  if (!loadPromise) {
97
87
  loadPromise = importFn();
98
88
  }
99
89
 
100
- // Delay showing loading state to avoid flash
90
+ // Delay showing loading state to avoid flash (uses versioned timer)
101
91
  if (LoadingComponent && delay > 0) {
102
- loadingTimer = setTimeout(() => {
103
- if (!cachedComponent && !isStale()) {
92
+ loadCtx.setTimeout(() => {
93
+ if (!cachedComponent && loadCtx.isCurrent()) {
104
94
  container.replaceChildren(LoadingComponent());
105
95
  }
106
96
  }, delay);
@@ -108,14 +98,15 @@ export function lazy(importFn, options = {}) {
108
98
  container.replaceChildren(LoadingComponent());
109
99
  }
110
100
 
111
- // Set timeout for loading
112
- const timeoutPromise = timeout > 0
113
- ? new Promise((_, reject) => {
114
- timeoutTimer = setTimeout(() => {
115
- reject(new Error(`Lazy load timeout after ${timeout}ms`));
116
- }, timeout);
117
- })
118
- : null;
101
+ // Set timeout for loading (uses versioned timer)
102
+ let timeoutPromise = null;
103
+ if (timeout > 0) {
104
+ timeoutPromise = new Promise((_, reject) => {
105
+ loadCtx.setTimeout(() => {
106
+ reject(Errors.lazyTimeout(timeout));
107
+ }, timeout);
108
+ });
109
+ }
119
110
 
120
111
  // Race between load and timeout
121
112
  const loadWithTimeout = timeoutPromise
@@ -124,11 +115,8 @@ export function lazy(importFn, options = {}) {
124
115
 
125
116
  loadWithTimeout
126
117
  .then(module => {
127
- clearTimeout(loadingTimer);
128
- clearTimeout(timeoutTimer);
129
-
130
118
  // Ignore if this load attempt is stale (navigation occurred)
131
- if (isStale()) {
119
+ if (loadCtx.isStale()) {
132
120
  return;
133
121
  }
134
122
 
@@ -144,17 +132,17 @@ export function lazy(importFn, options = {}) {
144
132
  : Component;
145
133
 
146
134
  // Replace loading with actual component
147
- if (result instanceof Node && !isStale()) {
148
- container.replaceChildren(result);
149
- }
135
+ loadCtx.ifCurrent(() => {
136
+ if (result instanceof Node) {
137
+ container.replaceChildren(result);
138
+ }
139
+ });
150
140
  })
151
141
  .catch(err => {
152
- clearTimeout(loadingTimer);
153
- clearTimeout(timeoutTimer);
154
142
  loadPromise = null; // Allow retry
155
143
 
156
144
  // Ignore if this load attempt is stale
157
- if (isStale()) {
145
+ if (loadCtx.isStale()) {
158
146
  return;
159
147
  }
160
148
 
package/runtime/store.js CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { pulse, computed, effect, batch } from './pulse.js';
19
19
  import { loggers, createLogger } from './logger.js';
20
+ import { Errors, createErrorMessage } from '../core/errors.js';
20
21
 
21
22
  const log = loggers.store;
22
23
 
@@ -77,20 +78,22 @@ function validateStateValue(value, path = 'state', seen = new WeakSet()) {
77
78
 
78
79
  // Check for invalid types
79
80
  if (INVALID_TYPES.has(valueType)) {
80
- throw new TypeError(
81
- `Invalid state value at "${path}": ${valueType}s cannot be stored in state. ` +
82
- `State values must be primitives, arrays, or plain objects.`
83
- );
81
+ throw Errors.invalidStoreValue(valueType);
84
82
  }
85
83
 
86
84
  // Check objects for circular references and nested invalid types
87
85
  if (value !== null && valueType === 'object') {
88
86
  // Check for circular reference
89
87
  if (seen.has(value)) {
90
- throw new TypeError(
91
- `Circular reference detected at "${path}". ` +
92
- `State must not contain circular references.`
88
+ const error = new TypeError(
89
+ createErrorMessage({
90
+ code: 'STORE_TYPE_ERROR',
91
+ message: `Circular reference detected at "${path}".`,
92
+ context: 'State must not contain circular references for persistence.',
93
+ suggestion: 'Remove the circular reference or exclude this value from the store.'
94
+ })
93
95
  );
96
+ throw error;
94
97
  }
95
98
  seen.add(value);
96
99