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/cli/analyze.js +127 -46
- package/cli/build.js +51 -13
- package/cli/format.js +64 -8
- package/cli/lint.js +112 -27
- package/cli/utils/cli-ui.js +452 -0
- package/compiler/parser.js +19 -2
- package/core/errors.js +281 -6
- package/package.json +7 -2
- package/runtime/async.js +282 -14
- package/runtime/dom-adapter.js +920 -0
- package/runtime/dom.js +331 -162
- package/runtime/logger.js +144 -69
- package/runtime/logger.prod.js +43 -18
- package/runtime/pulse.js +202 -80
- package/runtime/router.js +27 -39
- package/runtime/store.js +10 -7
- package/runtime/utils.js +279 -18
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
|
-
*
|
|
82
|
-
* Exported for testing purposes (use resetContext() to reset).
|
|
162
|
+
* The currently active reactive context.
|
|
83
163
|
* @type {ReactiveContext}
|
|
164
|
+
* @private
|
|
84
165
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
273
|
-
|
|
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 (
|
|
311
|
-
this.#subscribers.add(
|
|
312
|
-
|
|
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 (
|
|
384
|
-
|
|
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 (
|
|
658
|
+
if (activeContext.isRunningEffects) return;
|
|
523
659
|
|
|
524
|
-
|
|
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 (
|
|
667
|
+
while (activeContext.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
|
|
532
668
|
iterations++;
|
|
533
|
-
const effects = [...
|
|
534
|
-
|
|
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
|
|
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 = [...
|
|
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
|
|
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(
|
|
697
|
+
console.error(error.message);
|
|
569
698
|
|
|
570
699
|
// Also log through the logger for consistency
|
|
571
|
-
log.error(
|
|
700
|
+
log.error(error.message);
|
|
572
701
|
|
|
573
|
-
|
|
702
|
+
activeContext.pendingEffects.clear();
|
|
574
703
|
}
|
|
575
704
|
} finally {
|
|
576
|
-
|
|
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 =
|
|
764
|
+
const prevEffect = activeContext.currentEffect;
|
|
636
765
|
const tempEffect = {
|
|
637
766
|
run: () => {},
|
|
638
767
|
dependencies: new Set(),
|
|
639
768
|
cleanups: []
|
|
640
769
|
};
|
|
641
|
-
|
|
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
|
-
|
|
796
|
+
activeContext.currentEffect = prevEffect;
|
|
668
797
|
}
|
|
669
798
|
}
|
|
670
799
|
|
|
671
800
|
// Track dependency on this computed
|
|
672
|
-
if (
|
|
673
|
-
p._addSubscriber(
|
|
674
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
791
|
-
|
|
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
|
-
|
|
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 (!
|
|
808
|
-
|
|
929
|
+
if (!activeContext.effectRegistry.has(moduleId)) {
|
|
930
|
+
activeContext.effectRegistry.set(moduleId, new Set());
|
|
809
931
|
}
|
|
810
|
-
|
|
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 &&
|
|
835
|
-
|
|
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
|
-
|
|
984
|
+
activeContext.batchDepth++;
|
|
863
985
|
try {
|
|
864
986
|
return fn();
|
|
865
987
|
} finally {
|
|
866
|
-
|
|
867
|
-
if (
|
|
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 =
|
|
1149
|
-
|
|
1270
|
+
const prevEffect = activeContext.currentEffect;
|
|
1271
|
+
activeContext.currentEffect = null;
|
|
1150
1272
|
try {
|
|
1151
1273
|
return fn();
|
|
1152
1274
|
} finally {
|
|
1153
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
83
|
-
const
|
|
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
|
-
|
|
103
|
-
if (!cachedComponent &&
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
})
|
|
118
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|