pulse-js-framework 1.7.1 → 1.7.3

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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * Pulse Async Primitives
3
+ * @module pulse-js-framework/runtime/async
4
+ *
5
+ * Reactive primitives for handling asynchronous operations like
6
+ * data fetching, polling, and async state management.
7
+ */
8
+
9
+ import { pulse, effect, batch, onCleanup } from './pulse.js';
10
+
11
+ /**
12
+ * @typedef {Object} AsyncState
13
+ * @property {T|null} data - The resolved data
14
+ * @property {Error|null} error - The error if rejected
15
+ * @property {boolean} loading - Whether the async operation is in progress
16
+ * @property {'idle'|'loading'|'success'|'error'} status - Current status
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} UseAsyncOptions
21
+ * @property {boolean} [immediate=true] - Execute immediately on creation
22
+ * @property {T} [initialData=null] - Initial data value
23
+ * @property {function(Error): void} [onError] - Error callback
24
+ * @property {function(T): void} [onSuccess] - Success callback
25
+ * @property {number} [retries=0] - Number of retry attempts on failure
26
+ * @property {number} [retryDelay=1000] - Delay between retries in ms
27
+ */
28
+
29
+ /**
30
+ * Create a reactive async operation handler.
31
+ * Manages loading, error, and success states automatically.
32
+ *
33
+ * @template T
34
+ * @param {function(): Promise<T>} asyncFn - Async function to execute
35
+ * @param {UseAsyncOptions<T>} [options={}] - Configuration options
36
+ * @returns {Object} Reactive async state and controls
37
+ *
38
+ * @example
39
+ * // Basic usage
40
+ * const { data, loading, error, execute } = useAsync(
41
+ * () => fetch('/api/users').then(r => r.json())
42
+ * );
43
+ *
44
+ * effect(() => {
45
+ * if (loading.get()) console.log('Loading...');
46
+ * if (data.get()) console.log('Users:', data.get());
47
+ * if (error.get()) console.log('Error:', error.get().message);
48
+ * });
49
+ *
50
+ * // With options
51
+ * const { data } = useAsync(
52
+ * () => fetchData(),
53
+ * {
54
+ * immediate: false,
55
+ * retries: 3,
56
+ * onSuccess: (data) => console.log('Got data:', data),
57
+ * onError: (err) => console.error('Failed:', err)
58
+ * }
59
+ * );
60
+ */
61
+ export function useAsync(asyncFn, options = {}) {
62
+ const {
63
+ immediate = true,
64
+ initialData = null,
65
+ onError,
66
+ onSuccess,
67
+ retries = 0,
68
+ retryDelay = 1000
69
+ } = options;
70
+
71
+ const data = pulse(initialData);
72
+ const error = pulse(null);
73
+ const loading = pulse(false);
74
+ const status = pulse('idle');
75
+
76
+ // Track current execution version to handle race conditions
77
+ let executionVersion = 0;
78
+
79
+ /**
80
+ * Execute the async function
81
+ * @param {...any} args - Arguments to pass to asyncFn
82
+ * @returns {Promise<T|null>} The resolved data or null on error
83
+ */
84
+ async function execute(...args) {
85
+ const currentVersion = ++executionVersion;
86
+ let attempt = 0;
87
+
88
+ batch(() => {
89
+ loading.set(true);
90
+ error.set(null);
91
+ status.set('loading');
92
+ });
93
+
94
+ while (attempt <= retries) {
95
+ try {
96
+ const result = await asyncFn(...args);
97
+
98
+ // Check if this execution is still current (not stale)
99
+ if (currentVersion !== executionVersion) {
100
+ return null;
101
+ }
102
+
103
+ batch(() => {
104
+ data.set(result);
105
+ loading.set(false);
106
+ status.set('success');
107
+ });
108
+
109
+ if (onSuccess) onSuccess(result);
110
+ return result;
111
+ } catch (err) {
112
+ attempt++;
113
+
114
+ // Only retry if we haven't exceeded retries and execution is still current
115
+ if (attempt <= retries && currentVersion === executionVersion) {
116
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
117
+ continue;
118
+ }
119
+
120
+ // Check if this execution is still current
121
+ if (currentVersion !== executionVersion) {
122
+ return null;
123
+ }
124
+
125
+ batch(() => {
126
+ error.set(err);
127
+ loading.set(false);
128
+ status.set('error');
129
+ });
130
+
131
+ if (onError) onError(err);
132
+ return null;
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Reset state to initial values
141
+ */
142
+ function reset() {
143
+ executionVersion++;
144
+ batch(() => {
145
+ data.set(initialData);
146
+ error.set(null);
147
+ loading.set(false);
148
+ status.set('idle');
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Abort current execution (marks it as stale)
154
+ */
155
+ function abort() {
156
+ executionVersion++;
157
+ if (loading.get()) {
158
+ batch(() => {
159
+ loading.set(false);
160
+ status.set('idle');
161
+ });
162
+ }
163
+ }
164
+
165
+ // Execute immediately if requested
166
+ if (immediate) {
167
+ execute();
168
+ }
169
+
170
+ return {
171
+ data,
172
+ error,
173
+ loading,
174
+ status,
175
+ execute,
176
+ reset,
177
+ abort
178
+ };
179
+ }
180
+
181
+ /**
182
+ * @typedef {Object} ResourceOptions
183
+ * @property {number} [refreshInterval] - Auto-refresh interval in ms
184
+ * @property {boolean} [refreshOnFocus=false] - Refresh when window regains focus
185
+ * @property {boolean} [refreshOnReconnect=false] - Refresh when network reconnects
186
+ * @property {T} [initialData=null] - Initial data value
187
+ * @property {function(Error): void} [onError] - Error callback
188
+ * @property {number} [staleTime=0] - Time in ms before data is considered stale
189
+ * @property {number} [cacheTime=300000] - Time in ms to keep data in cache (5 min default)
190
+ */
191
+
192
+ // Global resource cache
193
+ const resourceCache = new Map();
194
+
195
+ /**
196
+ * Create a reactive resource with caching, auto-refresh, and stale-while-revalidate.
197
+ * Similar to React Query or SWR patterns.
198
+ *
199
+ * @template T
200
+ * @param {string|function(): string} key - Cache key or function returning key
201
+ * @param {function(): Promise<T>} fetcher - Async function to fetch data
202
+ * @param {ResourceOptions<T>} [options={}] - Configuration options
203
+ * @returns {Object} Reactive resource state and controls
204
+ *
205
+ * @example
206
+ * // Basic usage with caching
207
+ * const users = useResource('users', () => fetch('/api/users').then(r => r.json()));
208
+ *
209
+ * // With auto-refresh
210
+ * const liveData = useResource(
211
+ * 'live-data',
212
+ * () => fetchLiveData(),
213
+ * { refreshInterval: 5000 }
214
+ * );
215
+ *
216
+ * // Dynamic key based on reactive value
217
+ * const userId = pulse(1);
218
+ * const user = useResource(
219
+ * () => `user-${userId.get()}`,
220
+ * () => fetch(`/api/users/${userId.get()}`).then(r => r.json())
221
+ * );
222
+ */
223
+ export function useResource(key, fetcher, options = {}) {
224
+ const {
225
+ refreshInterval,
226
+ refreshOnFocus = false,
227
+ refreshOnReconnect = false,
228
+ initialData = null,
229
+ onError,
230
+ staleTime = 0,
231
+ cacheTime = 300000
232
+ } = options;
233
+
234
+ const data = pulse(initialData);
235
+ const error = pulse(null);
236
+ const loading = pulse(false);
237
+ const isStale = pulse(false);
238
+ const isValidating = pulse(false);
239
+ const lastFetchTime = pulse(0);
240
+
241
+ let intervalId = null;
242
+ let currentKey = null;
243
+
244
+ /**
245
+ * Get the current cache key
246
+ */
247
+ function getCacheKey() {
248
+ return typeof key === 'function' ? key() : key;
249
+ }
250
+
251
+ /**
252
+ * Get cached data if available and not expired
253
+ */
254
+ function getCachedData() {
255
+ const cacheKey = getCacheKey();
256
+ const cached = resourceCache.get(cacheKey);
257
+
258
+ if (cached) {
259
+ const age = Date.now() - cached.timestamp;
260
+ if (age < cacheTime) {
261
+ return {
262
+ data: cached.data,
263
+ isStale: age > staleTime
264
+ };
265
+ }
266
+ // Expired, remove from cache
267
+ resourceCache.delete(cacheKey);
268
+ }
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * Update cache with new data
274
+ */
275
+ function updateCache(newData) {
276
+ const cacheKey = getCacheKey();
277
+ resourceCache.set(cacheKey, {
278
+ data: newData,
279
+ timestamp: Date.now()
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Fetch fresh data
285
+ */
286
+ async function fetch() {
287
+ const cacheKey = getCacheKey();
288
+ currentKey = cacheKey;
289
+
290
+ // Check cache first
291
+ const cached = getCachedData();
292
+ if (cached && !cached.isStale) {
293
+ batch(() => {
294
+ data.set(cached.data);
295
+ isStale.set(false);
296
+ lastFetchTime.set(resourceCache.get(cacheKey)?.timestamp || 0);
297
+ });
298
+ return cached.data;
299
+ }
300
+
301
+ // Show cached data immediately if stale (stale-while-revalidate)
302
+ if (cached && cached.isStale) {
303
+ batch(() => {
304
+ data.set(cached.data);
305
+ isStale.set(true);
306
+ isValidating.set(true);
307
+ });
308
+ } else {
309
+ loading.set(true);
310
+ }
311
+
312
+ try {
313
+ const result = await fetcher();
314
+
315
+ // Check if key changed during fetch
316
+ if (cacheKey !== currentKey) {
317
+ return null;
318
+ }
319
+
320
+ updateCache(result);
321
+
322
+ batch(() => {
323
+ data.set(result);
324
+ error.set(null);
325
+ loading.set(false);
326
+ isStale.set(false);
327
+ isValidating.set(false);
328
+ lastFetchTime.set(Date.now());
329
+ });
330
+
331
+ return result;
332
+ } catch (err) {
333
+ if (cacheKey !== currentKey) {
334
+ return null;
335
+ }
336
+
337
+ batch(() => {
338
+ error.set(err);
339
+ loading.set(false);
340
+ isValidating.set(false);
341
+ });
342
+
343
+ if (onError) onError(err);
344
+ return null;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Force refresh, ignoring cache
350
+ */
351
+ async function refresh() {
352
+ const cacheKey = getCacheKey();
353
+ resourceCache.delete(cacheKey);
354
+ return fetch();
355
+ }
356
+
357
+ /**
358
+ * Mutate data optimistically
359
+ */
360
+ function mutate(newData, shouldRevalidate = false) {
361
+ const cacheKey = getCacheKey();
362
+ const resolvedData = typeof newData === 'function' ? newData(data.get()) : newData;
363
+
364
+ updateCache(resolvedData);
365
+ data.set(resolvedData);
366
+
367
+ if (shouldRevalidate) {
368
+ isStale.set(true);
369
+ fetch();
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Clear cache for this resource
375
+ */
376
+ function invalidate() {
377
+ const cacheKey = getCacheKey();
378
+ resourceCache.delete(cacheKey);
379
+ isStale.set(true);
380
+ }
381
+
382
+ // Setup auto-refresh interval
383
+ if (refreshInterval && refreshInterval > 0) {
384
+ intervalId = setInterval(() => {
385
+ if (!loading.get() && !isValidating.get()) {
386
+ fetch();
387
+ }
388
+ }, refreshInterval);
389
+
390
+ onCleanup(() => {
391
+ if (intervalId) clearInterval(intervalId);
392
+ });
393
+ }
394
+
395
+ // Setup window focus listener
396
+ if (refreshOnFocus && typeof window !== 'undefined') {
397
+ const handleFocus = () => {
398
+ const cached = getCachedData();
399
+ if (!cached || cached.isStale) {
400
+ fetch();
401
+ }
402
+ };
403
+
404
+ window.addEventListener('focus', handleFocus);
405
+ onCleanup(() => window.removeEventListener('focus', handleFocus));
406
+ }
407
+
408
+ // Setup online listener
409
+ if (refreshOnReconnect && typeof window !== 'undefined') {
410
+ const handleOnline = () => {
411
+ fetch();
412
+ };
413
+
414
+ window.addEventListener('online', handleOnline);
415
+ onCleanup(() => window.removeEventListener('online', handleOnline));
416
+ }
417
+
418
+ // Watch for key changes if key is a function
419
+ if (typeof key === 'function') {
420
+ effect(() => {
421
+ const newKey = key();
422
+ if (newKey !== currentKey) {
423
+ fetch();
424
+ }
425
+ });
426
+ } else {
427
+ // Initial fetch
428
+ fetch();
429
+ }
430
+
431
+ return {
432
+ data,
433
+ error,
434
+ loading,
435
+ isStale,
436
+ isValidating,
437
+ lastFetchTime,
438
+ fetch,
439
+ refresh,
440
+ mutate,
441
+ invalidate
442
+ };
443
+ }
444
+
445
+ /**
446
+ * @typedef {Object} PollingOptions
447
+ * @property {number} interval - Polling interval in ms
448
+ * @property {boolean} [immediate=true] - Execute immediately on start
449
+ * @property {boolean} [pauseOnHidden=true] - Pause when page is hidden
450
+ * @property {boolean} [pauseOnOffline=true] - Pause when offline
451
+ * @property {number} [maxErrors=3] - Max consecutive errors before stopping
452
+ * @property {function(Error): void} [onError] - Error callback
453
+ */
454
+
455
+ /**
456
+ * Create a polling mechanism for repeated async operations.
457
+ *
458
+ * @template T
459
+ * @param {function(): Promise<T>} asyncFn - Async function to poll
460
+ * @param {PollingOptions} options - Polling configuration
461
+ * @returns {Object} Polling controls and state
462
+ *
463
+ * @example
464
+ * const { data, start, stop, isPolling } = usePolling(
465
+ * () => fetch('/api/status').then(r => r.json()),
466
+ * { interval: 5000, pauseOnHidden: true }
467
+ * );
468
+ *
469
+ * // Start polling
470
+ * start();
471
+ *
472
+ * // Stop when done
473
+ * stop();
474
+ */
475
+ export function usePolling(asyncFn, options) {
476
+ const {
477
+ interval,
478
+ immediate = true,
479
+ pauseOnHidden = true,
480
+ pauseOnOffline = true,
481
+ maxErrors = 3,
482
+ onError
483
+ } = options;
484
+
485
+ const data = pulse(null);
486
+ const error = pulse(null);
487
+ const isPolling = pulse(false);
488
+ const errorCount = pulse(0);
489
+
490
+ let intervalId = null;
491
+ let isPaused = false;
492
+
493
+ async function poll() {
494
+ if (isPaused || !isPolling.get()) return;
495
+
496
+ try {
497
+ const result = await asyncFn();
498
+ batch(() => {
499
+ data.set(result);
500
+ error.set(null);
501
+ errorCount.set(0);
502
+ });
503
+ } catch (err) {
504
+ const newCount = errorCount.get() + 1;
505
+ batch(() => {
506
+ error.set(err);
507
+ errorCount.set(newCount);
508
+ });
509
+
510
+ if (onError) onError(err);
511
+
512
+ // Stop polling after max consecutive errors
513
+ if (newCount >= maxErrors) {
514
+ stop();
515
+ }
516
+ }
517
+ }
518
+
519
+ function start() {
520
+ if (intervalId) return;
521
+
522
+ isPolling.set(true);
523
+ errorCount.set(0);
524
+
525
+ if (immediate) {
526
+ poll();
527
+ }
528
+
529
+ intervalId = setInterval(poll, interval);
530
+ }
531
+
532
+ function stop() {
533
+ if (intervalId) {
534
+ clearInterval(intervalId);
535
+ intervalId = null;
536
+ }
537
+ isPolling.set(false);
538
+ }
539
+
540
+ function pause() {
541
+ isPaused = true;
542
+ }
543
+
544
+ function resume() {
545
+ isPaused = false;
546
+ }
547
+
548
+ // Page visibility handling
549
+ if (pauseOnHidden && typeof document !== 'undefined') {
550
+ const handleVisibility = () => {
551
+ if (document.hidden) {
552
+ pause();
553
+ } else {
554
+ resume();
555
+ // Immediately poll when becoming visible
556
+ if (isPolling.get()) poll();
557
+ }
558
+ };
559
+
560
+ document.addEventListener('visibilitychange', handleVisibility);
561
+ onCleanup(() => document.removeEventListener('visibilitychange', handleVisibility));
562
+ }
563
+
564
+ // Online/offline handling
565
+ if (pauseOnOffline && typeof window !== 'undefined') {
566
+ const handleOffline = () => pause();
567
+ const handleOnline = () => {
568
+ resume();
569
+ if (isPolling.get()) poll();
570
+ };
571
+
572
+ window.addEventListener('offline', handleOffline);
573
+ window.addEventListener('online', handleOnline);
574
+ onCleanup(() => {
575
+ window.removeEventListener('offline', handleOffline);
576
+ window.removeEventListener('online', handleOnline);
577
+ });
578
+ }
579
+
580
+ // Cleanup on unmount
581
+ onCleanup(stop);
582
+
583
+ return {
584
+ data,
585
+ error,
586
+ isPolling,
587
+ errorCount,
588
+ start,
589
+ stop,
590
+ pause,
591
+ resume
592
+ };
593
+ }
594
+
595
+ /**
596
+ * Clear the entire resource cache
597
+ */
598
+ export function clearResourceCache() {
599
+ resourceCache.clear();
600
+ }
601
+
602
+ /**
603
+ * Get resource cache statistics
604
+ * @returns {{size: number, keys: string[]}}
605
+ */
606
+ export function getResourceCacheStats() {
607
+ return {
608
+ size: resourceCache.size,
609
+ keys: [...resourceCache.keys()]
610
+ };
611
+ }
612
+
613
+ export default {
614
+ useAsync,
615
+ useResource,
616
+ usePolling,
617
+ clearResourceCache,
618
+ getResourceCacheStats
619
+ };