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/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
- // Track current execution version to handle race conditions
77
- let executionVersion = 0;
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 currentVersion = ++executionVersion;
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 (currentVersion !== executionVersion) {
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 && currentVersion === executionVersion) {
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 (currentVersion !== executionVersion) {
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
- executionVersion++;
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
- executionVersion++;
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
- let currentKey = null;
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
- currentKey = cacheKey;
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 during fetch
316
- if (cacheKey !== currentKey) {
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 (cacheKey !== currentKey) {
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 !== currentKey) {
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,