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/async.js
CHANGED
|
@@ -8,6 +8,264 @@
|
|
|
8
8
|
|
|
9
9
|
import { pulse, effect, batch, onCleanup } from './pulse.js';
|
|
10
10
|
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Versioned Async - Centralized Race Condition Handling
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} VersionedContext
|
|
17
|
+
* @property {function(): boolean} isCurrent - Check if this operation is still valid
|
|
18
|
+
* @property {function(): boolean} isStale - Check if this operation has been superseded
|
|
19
|
+
* @property {function(function): *} ifCurrent - Run callback only if still current
|
|
20
|
+
* @property {function(function, number): number} setTimeout - Register a timeout that auto-clears on abort
|
|
21
|
+
* @property {function(function, number): number} setInterval - Register an interval that auto-clears on abort
|
|
22
|
+
* @property {function(number): void} clearTimeout - Clear a registered timeout
|
|
23
|
+
* @property {function(number): void} clearInterval - Clear a registered interval
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} VersionedAsyncController
|
|
28
|
+
* @property {function(): VersionedContext} begin - Start a new versioned operation
|
|
29
|
+
* @property {function(): void} abort - Abort the current operation
|
|
30
|
+
* @property {function(): number} getVersion - Get current version number
|
|
31
|
+
* @property {function(): void} cleanup - Clean up all timers
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create a versioned async controller for race condition handling.
|
|
36
|
+
*
|
|
37
|
+
* This utility provides a centralized way to handle async race conditions
|
|
38
|
+
* by tracking operation versions. When a new operation starts, it invalidates
|
|
39
|
+
* any previous operations, preventing stale callbacks from executing.
|
|
40
|
+
*
|
|
41
|
+
* Use cases:
|
|
42
|
+
* - Preventing stale fetch responses from updating UI after navigation
|
|
43
|
+
* - Handling rapid user input that triggers multiple async operations
|
|
44
|
+
* - Managing lazy-loaded components during route changes
|
|
45
|
+
* - Any scenario where multiple async operations might overlap
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} [options={}] - Configuration options
|
|
48
|
+
* @param {function(): void} [options.onAbort] - Callback invoked when operation is aborted
|
|
49
|
+
* @returns {VersionedAsyncController} Controller for managing versioned async operations
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // Basic usage
|
|
53
|
+
* const controller = createVersionedAsync();
|
|
54
|
+
*
|
|
55
|
+
* async function fetchData() {
|
|
56
|
+
* const ctx = controller.begin();
|
|
57
|
+
*
|
|
58
|
+
* const response = await fetch('/api/data');
|
|
59
|
+
* const data = await response.json();
|
|
60
|
+
*
|
|
61
|
+
* // Only update state if this operation is still current
|
|
62
|
+
* ctx.ifCurrent(() => {
|
|
63
|
+
* setState(data);
|
|
64
|
+
* });
|
|
65
|
+
* }
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* // With timeout handling
|
|
69
|
+
* const controller = createVersionedAsync();
|
|
70
|
+
*
|
|
71
|
+
* function lazyLoad() {
|
|
72
|
+
* const ctx = controller.begin();
|
|
73
|
+
*
|
|
74
|
+
* // Timer automatically clears if operation is aborted
|
|
75
|
+
* ctx.setTimeout(() => {
|
|
76
|
+
* ctx.ifCurrent(() => showLoading());
|
|
77
|
+
* }, 200);
|
|
78
|
+
*
|
|
79
|
+
* loadComponent().then(component => {
|
|
80
|
+
* ctx.ifCurrent(() => render(component));
|
|
81
|
+
* });
|
|
82
|
+
* }
|
|
83
|
+
*
|
|
84
|
+
* // Abort on navigation
|
|
85
|
+
* onNavigate(() => controller.abort());
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* // Manual staleness check
|
|
89
|
+
* const controller = createVersionedAsync();
|
|
90
|
+
*
|
|
91
|
+
* async function search(query) {
|
|
92
|
+
* const ctx = controller.begin();
|
|
93
|
+
*
|
|
94
|
+
* const results = await searchApi(query);
|
|
95
|
+
*
|
|
96
|
+
* if (ctx.isStale()) {
|
|
97
|
+
* return null; // Newer search was started
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* return results;
|
|
101
|
+
* }
|
|
102
|
+
*/
|
|
103
|
+
export function createVersionedAsync(options = {}) {
|
|
104
|
+
const { onAbort } = options;
|
|
105
|
+
|
|
106
|
+
let version = 0;
|
|
107
|
+
let aborted = false;
|
|
108
|
+
const timeouts = new Set();
|
|
109
|
+
const intervals = new Set();
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start a new versioned operation.
|
|
113
|
+
* Invalidates any previous operations and returns a context
|
|
114
|
+
* for checking validity and managing timers.
|
|
115
|
+
*
|
|
116
|
+
* @returns {VersionedContext} Context for the new operation
|
|
117
|
+
*/
|
|
118
|
+
function begin() {
|
|
119
|
+
aborted = false;
|
|
120
|
+
const currentVersion = ++version;
|
|
121
|
+
|
|
122
|
+
// Clear any pending timers from previous operations
|
|
123
|
+
timeouts.forEach(clearTimeout);
|
|
124
|
+
timeouts.clear();
|
|
125
|
+
intervals.forEach(clearInterval);
|
|
126
|
+
intervals.clear();
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
/**
|
|
130
|
+
* Check if this operation is still the current one.
|
|
131
|
+
* Returns false if abort() was called or a new begin() was called.
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
isCurrent() {
|
|
135
|
+
return !aborted && currentVersion === version;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if this operation has been superseded.
|
|
140
|
+
* Inverse of isCurrent() for readability.
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
isStale() {
|
|
144
|
+
return aborted || currentVersion !== version;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Execute a callback only if this operation is still current.
|
|
149
|
+
* Useful for safely updating state after async operations.
|
|
150
|
+
*
|
|
151
|
+
* @template T
|
|
152
|
+
* @param {function(): T} fn - Function to execute if current
|
|
153
|
+
* @returns {T|undefined} Result of fn or undefined if stale
|
|
154
|
+
*/
|
|
155
|
+
ifCurrent(fn) {
|
|
156
|
+
if (!aborted && currentVersion === version) {
|
|
157
|
+
return fn();
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Set a timeout that automatically clears when the operation
|
|
164
|
+
* becomes stale (either by abort or new begin).
|
|
165
|
+
*
|
|
166
|
+
* @param {function(): void} fn - Callback to execute
|
|
167
|
+
* @param {number} ms - Delay in milliseconds
|
|
168
|
+
* @returns {number} Timer ID
|
|
169
|
+
*/
|
|
170
|
+
setTimeout(fn, ms) {
|
|
171
|
+
const id = setTimeout(() => {
|
|
172
|
+
timeouts.delete(id);
|
|
173
|
+
if (!aborted && currentVersion === version) {
|
|
174
|
+
fn();
|
|
175
|
+
}
|
|
176
|
+
}, ms);
|
|
177
|
+
timeouts.add(id);
|
|
178
|
+
return id;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Set an interval that automatically clears when the operation
|
|
183
|
+
* becomes stale.
|
|
184
|
+
*
|
|
185
|
+
* @param {function(): void} fn - Callback to execute
|
|
186
|
+
* @param {number} ms - Interval in milliseconds
|
|
187
|
+
* @returns {number} Timer ID
|
|
188
|
+
*/
|
|
189
|
+
setInterval(fn, ms) {
|
|
190
|
+
const id = setInterval(() => {
|
|
191
|
+
if (!aborted && currentVersion === version) {
|
|
192
|
+
fn();
|
|
193
|
+
} else {
|
|
194
|
+
clearInterval(id);
|
|
195
|
+
intervals.delete(id);
|
|
196
|
+
}
|
|
197
|
+
}, ms);
|
|
198
|
+
intervals.add(id);
|
|
199
|
+
return id;
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clear a specific timeout registered with this context.
|
|
204
|
+
* @param {number} id - Timer ID to clear
|
|
205
|
+
*/
|
|
206
|
+
clearTimeout(id) {
|
|
207
|
+
clearTimeout(id);
|
|
208
|
+
timeouts.delete(id);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Clear a specific interval registered with this context.
|
|
213
|
+
* @param {number} id - Timer ID to clear
|
|
214
|
+
*/
|
|
215
|
+
clearInterval(id) {
|
|
216
|
+
clearInterval(id);
|
|
217
|
+
intervals.delete(id);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Abort the current operation.
|
|
224
|
+
* Marks all active operations as stale and clears pending timers.
|
|
225
|
+
*/
|
|
226
|
+
function abort() {
|
|
227
|
+
aborted = true;
|
|
228
|
+
version++;
|
|
229
|
+
|
|
230
|
+
// Clear all pending timers
|
|
231
|
+
timeouts.forEach(clearTimeout);
|
|
232
|
+
timeouts.clear();
|
|
233
|
+
intervals.forEach(clearInterval);
|
|
234
|
+
intervals.clear();
|
|
235
|
+
|
|
236
|
+
if (onAbort) {
|
|
237
|
+
onAbort();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get the current version number.
|
|
243
|
+
* Useful for advanced use cases or debugging.
|
|
244
|
+
* @returns {number}
|
|
245
|
+
*/
|
|
246
|
+
function getVersion() {
|
|
247
|
+
return version;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Clean up all timers without aborting.
|
|
252
|
+
* Call this when disposing of the controller.
|
|
253
|
+
*/
|
|
254
|
+
function cleanup() {
|
|
255
|
+
timeouts.forEach(clearTimeout);
|
|
256
|
+
timeouts.clear();
|
|
257
|
+
intervals.forEach(clearInterval);
|
|
258
|
+
intervals.clear();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
begin,
|
|
263
|
+
abort,
|
|
264
|
+
getVersion,
|
|
265
|
+
cleanup
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
11
269
|
/**
|
|
12
270
|
* @typedef {Object} AsyncState
|
|
13
271
|
* @property {T|null} data - The resolved data
|
|
@@ -30,6 +288,8 @@ import { pulse, effect, batch, onCleanup } from './pulse.js';
|
|
|
30
288
|
* Create a reactive async operation handler.
|
|
31
289
|
* Manages loading, error, and success states automatically.
|
|
32
290
|
*
|
|
291
|
+
* Uses createVersionedAsync internally for race condition handling.
|
|
292
|
+
*
|
|
33
293
|
* @template T
|
|
34
294
|
* @param {function(): Promise<T>} asyncFn - Async function to execute
|
|
35
295
|
* @param {UseAsyncOptions<T>} [options={}] - Configuration options
|
|
@@ -73,8 +333,8 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
73
333
|
const loading = pulse(false);
|
|
74
334
|
const status = pulse('idle');
|
|
75
335
|
|
|
76
|
-
//
|
|
77
|
-
|
|
336
|
+
// Use centralized versioned async for race condition handling
|
|
337
|
+
const versionController = createVersionedAsync();
|
|
78
338
|
|
|
79
339
|
/**
|
|
80
340
|
* Execute the async function
|
|
@@ -82,7 +342,7 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
82
342
|
* @returns {Promise<T|null>} The resolved data or null on error
|
|
83
343
|
*/
|
|
84
344
|
async function execute(...args) {
|
|
85
|
-
const
|
|
345
|
+
const ctx = versionController.begin();
|
|
86
346
|
let attempt = 0;
|
|
87
347
|
|
|
88
348
|
batch(() => {
|
|
@@ -96,7 +356,7 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
96
356
|
const result = await asyncFn(...args);
|
|
97
357
|
|
|
98
358
|
// Check if this execution is still current (not stale)
|
|
99
|
-
if (
|
|
359
|
+
if (ctx.isStale()) {
|
|
100
360
|
return null;
|
|
101
361
|
}
|
|
102
362
|
|
|
@@ -112,13 +372,13 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
112
372
|
attempt++;
|
|
113
373
|
|
|
114
374
|
// Only retry if we haven't exceeded retries and execution is still current
|
|
115
|
-
if (attempt <= retries &&
|
|
375
|
+
if (attempt <= retries && ctx.isCurrent()) {
|
|
116
376
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
117
377
|
continue;
|
|
118
378
|
}
|
|
119
379
|
|
|
120
380
|
// Check if this execution is still current
|
|
121
|
-
if (
|
|
381
|
+
if (ctx.isStale()) {
|
|
122
382
|
return null;
|
|
123
383
|
}
|
|
124
384
|
|
|
@@ -140,7 +400,7 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
140
400
|
* Reset state to initial values
|
|
141
401
|
*/
|
|
142
402
|
function reset() {
|
|
143
|
-
|
|
403
|
+
versionController.abort();
|
|
144
404
|
batch(() => {
|
|
145
405
|
data.set(initialData);
|
|
146
406
|
error.set(null);
|
|
@@ -153,7 +413,7 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
153
413
|
* Abort current execution (marks it as stale)
|
|
154
414
|
*/
|
|
155
415
|
function abort() {
|
|
156
|
-
|
|
416
|
+
versionController.abort();
|
|
157
417
|
if (loading.get()) {
|
|
158
418
|
batch(() => {
|
|
159
419
|
loading.set(false);
|
|
@@ -239,7 +499,9 @@ export function useResource(key, fetcher, options = {}) {
|
|
|
239
499
|
const lastFetchTime = pulse(0);
|
|
240
500
|
|
|
241
501
|
let intervalId = null;
|
|
242
|
-
|
|
502
|
+
|
|
503
|
+
// Use centralized versioned async for race condition handling
|
|
504
|
+
const versionController = createVersionedAsync();
|
|
243
505
|
|
|
244
506
|
/**
|
|
245
507
|
* Get the current cache key
|
|
@@ -285,7 +547,7 @@ export function useResource(key, fetcher, options = {}) {
|
|
|
285
547
|
*/
|
|
286
548
|
async function fetch() {
|
|
287
549
|
const cacheKey = getCacheKey();
|
|
288
|
-
|
|
550
|
+
const ctx = versionController.begin();
|
|
289
551
|
|
|
290
552
|
// Check cache first
|
|
291
553
|
const cached = getCachedData();
|
|
@@ -312,8 +574,8 @@ export function useResource(key, fetcher, options = {}) {
|
|
|
312
574
|
try {
|
|
313
575
|
const result = await fetcher();
|
|
314
576
|
|
|
315
|
-
// Check if key changed
|
|
316
|
-
if (
|
|
577
|
+
// Check if fetch was superseded (key changed or aborted)
|
|
578
|
+
if (ctx.isStale()) {
|
|
317
579
|
return null;
|
|
318
580
|
}
|
|
319
581
|
|
|
@@ -330,7 +592,7 @@ export function useResource(key, fetcher, options = {}) {
|
|
|
330
592
|
|
|
331
593
|
return result;
|
|
332
594
|
} catch (err) {
|
|
333
|
-
if (
|
|
595
|
+
if (ctx.isStale()) {
|
|
334
596
|
return null;
|
|
335
597
|
}
|
|
336
598
|
|
|
@@ -415,16 +677,21 @@ export function useResource(key, fetcher, options = {}) {
|
|
|
415
677
|
onCleanup(() => window.removeEventListener('online', handleOnline));
|
|
416
678
|
}
|
|
417
679
|
|
|
680
|
+
// Track current key for change detection
|
|
681
|
+
let lastKey = null;
|
|
682
|
+
|
|
418
683
|
// Watch for key changes if key is a function
|
|
419
684
|
if (typeof key === 'function') {
|
|
420
685
|
effect(() => {
|
|
421
686
|
const newKey = key();
|
|
422
|
-
if (newKey !==
|
|
687
|
+
if (newKey !== lastKey) {
|
|
688
|
+
lastKey = newKey;
|
|
423
689
|
fetch();
|
|
424
690
|
}
|
|
425
691
|
});
|
|
426
692
|
} else {
|
|
427
693
|
// Initial fetch
|
|
694
|
+
lastKey = key;
|
|
428
695
|
fetch();
|
|
429
696
|
}
|
|
430
697
|
|
|
@@ -611,6 +878,7 @@ export function getResourceCacheStats() {
|
|
|
611
878
|
}
|
|
612
879
|
|
|
613
880
|
export default {
|
|
881
|
+
createVersionedAsync,
|
|
614
882
|
useAsync,
|
|
615
883
|
useResource,
|
|
616
884
|
usePolling,
|