what-core 0.3.0 → 0.4.1

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/dist/data.js CHANGED
@@ -1,12 +1,75 @@
1
1
  // What Framework - Data Fetching
2
2
  // SWR-like data fetching with caching, revalidation, and optimistic updates
3
3
 
4
- import { signal, effect, batch, computed } from './reactive.js';
4
+ import { signal, effect, batch, computed, __DEV__ } from './reactive.js';
5
5
  import { getCurrentComponent } from './dom.js';
6
6
 
7
- // Global cache for requests
8
- const cache = new Map();
7
+ // --- Reactive Cache ---
8
+ // Each cache key maps to shared signals so all components reading the same key
9
+ // see updates when ANY component mutates/revalidates that key.
10
+ // Shared per key: data signal, error signal, isValidating signal.
11
+ const cacheSignals = new Map(); // key -> signal(value)
12
+ const errorSignals = new Map(); // key -> signal(error)
13
+ const validatingSignals = new Map(); // key -> signal(boolean)
14
+ const cacheTimestamps = new Map(); // key -> last access time (for LRU)
15
+ const MAX_CACHE_SIZE = 200;
16
+
17
+ function getCacheSignal(key) {
18
+ cacheTimestamps.set(key, Date.now());
19
+ if (!cacheSignals.has(key)) {
20
+ cacheSignals.set(key, signal(null));
21
+ // Evict oldest entries if cache exceeds limit
22
+ if (cacheSignals.size > MAX_CACHE_SIZE) {
23
+ evictOldest();
24
+ }
25
+ }
26
+ return cacheSignals.get(key);
27
+ }
28
+
29
+ function getErrorSignal(key) {
30
+ if (!errorSignals.has(key)) errorSignals.set(key, signal(null));
31
+ return errorSignals.get(key);
32
+ }
33
+
34
+ function getValidatingSignal(key) {
35
+ if (!validatingSignals.has(key)) validatingSignals.set(key, signal(false));
36
+ return validatingSignals.get(key);
37
+ }
38
+
39
+ function evictOldest() {
40
+ // Remove the 20% oldest entries
41
+ const entries = [...cacheTimestamps.entries()].sort((a, b) => a[1] - b[1]);
42
+ const toRemove = Math.floor(MAX_CACHE_SIZE * 0.2);
43
+ for (let i = 0; i < toRemove && i < entries.length; i++) {
44
+ const key = entries[i][0];
45
+ // Don't evict keys with active subscribers
46
+ if (revalidationSubscribers.has(key) && revalidationSubscribers.get(key).size > 0) continue;
47
+ cacheSignals.delete(key);
48
+ errorSignals.delete(key);
49
+ validatingSignals.delete(key);
50
+ cacheTimestamps.delete(key);
51
+ lastFetchTimestamps.delete(key);
52
+ }
53
+ }
54
+
55
+ // Subscribers for invalidation: key -> Set<revalidateFn>
56
+ // When invalidateQueries is called, all subscribers re-fetch
57
+ const revalidationSubscribers = new Map();
58
+
59
+ function subscribeToKey(key, revalidateFn) {
60
+ if (!revalidationSubscribers.has(key)) revalidationSubscribers.set(key, new Set());
61
+ revalidationSubscribers.get(key).add(revalidateFn);
62
+ return () => {
63
+ const subs = revalidationSubscribers.get(key);
64
+ if (subs) {
65
+ subs.delete(revalidateFn);
66
+ if (subs.size === 0) revalidationSubscribers.delete(key);
67
+ }
68
+ };
69
+ }
70
+
9
71
  const inFlightRequests = new Map();
72
+ const lastFetchTimestamps = new Map(); // key -> timestamp of last completed fetch
10
73
 
11
74
  // Create an effect scoped to the current component's lifecycle.
12
75
  // When the component unmounts, the effect is automatically disposed.
@@ -32,8 +95,14 @@ export function useFetch(url, options = {}) {
32
95
  const data = signal(initialData);
33
96
  const error = signal(null);
34
97
  const isLoading = signal(true);
98
+ let abortController = null;
35
99
 
36
100
  async function fetchData() {
101
+ // Abort previous request
102
+ if (abortController) abortController.abort();
103
+ abortController = new AbortController();
104
+ const { signal: abortSignal } = abortController;
105
+
37
106
  isLoading.set(true);
38
107
  error.set(null);
39
108
 
@@ -45,6 +114,7 @@ export function useFetch(url, options = {}) {
45
114
  ...headers,
46
115
  },
47
116
  body: body ? JSON.stringify(body) : undefined,
117
+ signal: abortSignal,
48
118
  });
49
119
 
50
120
  if (!response.ok) {
@@ -52,17 +122,26 @@ export function useFetch(url, options = {}) {
52
122
  }
53
123
 
54
124
  const json = await response.json();
55
- data.set(transform(json));
125
+ if (!abortSignal.aborted) {
126
+ data.set(transform(json));
127
+ }
56
128
  } catch (e) {
57
- error.set(e);
129
+ if (!abortSignal.aborted) {
130
+ error.set(e);
131
+ }
58
132
  } finally {
59
- isLoading.set(false);
133
+ if (!abortSignal.aborted) {
134
+ isLoading.set(false);
135
+ }
60
136
  }
61
137
  }
62
138
 
63
- // Fetch on mount
139
+ // Fetch on mount, abort on unmount
64
140
  scopedEffect(() => {
65
141
  fetchData();
142
+ return () => {
143
+ if (abortController) abortController.abort();
144
+ };
66
145
  });
67
146
 
68
147
  return {
@@ -89,49 +168,91 @@ export function useSWR(key, fetcher, options = {}) {
89
168
  suspense = false,
90
169
  } = options;
91
170
 
92
- // Reactive state
93
- const data = signal(cache.get(key) || fallbackData || null);
94
- const error = signal(null);
95
- const isValidating = signal(false);
96
- const isLoading = computed(() => !data() && isValidating());
171
+ // Support null/undefined/false key for conditional/dependent fetching
172
+ // When key is falsy, don't fetch — return idle state
173
+ if (key == null || key === false) {
174
+ const data = signal(fallbackData || null);
175
+ const error = signal(null);
176
+ return {
177
+ data: () => data(),
178
+ error: () => error(),
179
+ isLoading: () => false,
180
+ isValidating: () => false,
181
+ mutate: (newData) => data.set(typeof newData === 'function' ? newData(data()) : newData),
182
+ revalidate: () => Promise.resolve(),
183
+ };
184
+ }
185
+
186
+ // Shared reactive cache signals — all useSWR instances with the same key
187
+ // read from these signals, so mutating from one component updates all others.
188
+ const cacheS = getCacheSignal(key);
189
+ const error = getErrorSignal(key);
190
+ const isValidating = getValidatingSignal(key);
191
+ const data = computed(() => cacheS() ?? fallbackData ?? null);
192
+ const isLoading = computed(() => cacheS() == null && isValidating());
193
+
194
+ let abortController = null;
97
195
 
98
196
  async function revalidate() {
99
- // Deduplication: if there's already a request in flight, wait for it
197
+ const now = Date.now();
198
+
199
+ // Deduplication: if there's already a request in flight, reuse it
100
200
  if (inFlightRequests.has(key)) {
101
- const existingPromise = inFlightRequests.get(key);
102
- const now = Date.now();
103
- if (now - existingPromise.timestamp < dedupingInterval) {
104
- return existingPromise.promise;
201
+ const existing = inFlightRequests.get(key);
202
+ if (now - existing.timestamp < dedupingInterval) {
203
+ return existing.promise;
105
204
  }
106
205
  }
107
206
 
207
+ // Also deduplicate against recently completed fetches
208
+ const lastFetch = lastFetchTimestamps.get(key);
209
+ if (lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
210
+ return cacheS.peek();
211
+ }
212
+
213
+ // Abort previous request
214
+ if (abortController) abortController.abort();
215
+ abortController = new AbortController();
216
+ const { signal: abortSignal } = abortController;
217
+
108
218
  isValidating.set(true);
109
219
 
110
- const promise = fetcher(key);
111
- inFlightRequests.set(key, { promise, timestamp: Date.now() });
220
+ const promise = fetcher(key, { signal: abortSignal });
221
+ inFlightRequests.set(key, { promise, timestamp: now });
112
222
 
113
223
  try {
114
224
  const result = await promise;
225
+ if (abortSignal.aborted) return;
115
226
  batch(() => {
116
- data.set(result);
227
+ cacheS.set(result); // Updates ALL components reading this key
117
228
  error.set(null);
118
- cache.set(key, result);
119
229
  });
230
+ cacheTimestamps.set(key, Date.now());
231
+ lastFetchTimestamps.set(key, Date.now());
120
232
  if (onSuccess) onSuccess(result, key);
121
233
  return result;
122
234
  } catch (e) {
235
+ if (abortSignal.aborted) return;
123
236
  error.set(e);
124
237
  if (onError) onError(e, key);
125
238
  throw e;
126
239
  } finally {
127
- isValidating.set(false);
240
+ if (!abortSignal.aborted) isValidating.set(false);
128
241
  inFlightRequests.delete(key);
129
242
  }
130
243
  }
131
244
 
245
+ // Subscribe to invalidation events for this key
246
+ const unsubscribe = subscribeToKey(key, () => revalidate().catch(() => {}));
247
+
132
248
  // Initial fetch
133
249
  scopedEffect(() => {
134
250
  revalidate().catch(() => {});
251
+ // Cleanup: abort and unsubscribe on unmount
252
+ return () => {
253
+ if (abortController) abortController.abort();
254
+ unsubscribe();
255
+ };
135
256
  });
136
257
 
137
258
  // Revalidate on focus
@@ -169,11 +290,12 @@ export function useSWR(key, fetcher, options = {}) {
169
290
  return {
170
291
  data: () => data(),
171
292
  error: () => error(),
172
- isLoading,
293
+ isLoading: () => isLoading(),
173
294
  isValidating: () => isValidating(),
174
295
  mutate: (newData, shouldRevalidate = true) => {
175
- data.set(typeof newData === 'function' ? newData(data()) : newData);
176
- cache.set(key, data());
296
+ const resolved = typeof newData === 'function' ? newData(cacheS.peek()) : newData;
297
+ cacheS.set(resolved); // Updates ALL components reading this key
298
+ cacheTimestamps.set(key, Date.now());
177
299
  if (shouldRevalidate) {
178
300
  revalidate().catch(() => {});
179
301
  }
@@ -205,28 +327,34 @@ export function useQuery(options) {
205
327
 
206
328
  const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
207
329
 
208
- const rawData = signal(cache.get(key) || null);
330
+ const cacheS = getCacheSignal(key);
209
331
  const data = computed(() => {
210
- const d = rawData();
332
+ const d = cacheS();
211
333
  return select && d !== null ? select(d) : d;
212
334
  });
213
- const error = signal(null);
214
- const status = signal(cache.has(key) ? 'success' : 'loading');
335
+ const error = getErrorSignal(key);
336
+ const status = signal(cacheS.peek() != null ? 'success' : 'loading');
215
337
  const fetchStatus = signal('idle');
216
338
 
217
339
  let lastFetchTime = 0;
340
+ let abortController = null;
218
341
 
219
- async function fetch() {
342
+ async function fetchQuery() {
220
343
  if (!enabled) return;
221
344
 
222
345
  // Check if data is still fresh
223
346
  const now = Date.now();
224
- if (cache.has(key) && now - lastFetchTime < staleTime) {
225
- return cache.get(key);
347
+ if (cacheS.peek() != null && now - lastFetchTime < staleTime) {
348
+ return cacheS.peek();
226
349
  }
227
350
 
351
+ // Abort previous request
352
+ if (abortController) abortController.abort();
353
+ abortController = new AbortController();
354
+ const { signal: abortSignal } = abortController;
355
+
228
356
  fetchStatus.set('fetching');
229
- if (!cache.has(key)) {
357
+ if (cacheS.peek() == null) {
230
358
  status.set('loading');
231
359
  }
232
360
 
@@ -234,31 +362,48 @@ export function useQuery(options) {
234
362
 
235
363
  async function attemptFetch() {
236
364
  try {
237
- const result = await queryFn({ queryKey: Array.isArray(queryKey) ? queryKey : [queryKey] });
365
+ const result = await queryFn({ queryKey: Array.isArray(queryKey) ? queryKey : [queryKey], signal: abortSignal });
366
+ if (abortSignal.aborted) return;
238
367
  batch(() => {
239
- rawData.set(result);
368
+ cacheS.set(result); // Updates all components reading this key
240
369
  error.set(null);
241
370
  status.set('success');
242
371
  fetchStatus.set('idle');
243
372
  });
244
- cache.set(key, result);
245
373
  lastFetchTime = Date.now();
374
+ cacheTimestamps.set(key, Date.now());
246
375
 
247
376
  if (onSuccess) onSuccess(result);
248
377
  if (onSettled) onSettled(result, null);
249
378
 
250
- // Schedule cache cleanup
379
+ // Schedule cache cleanup (only if no active subscribers)
251
380
  setTimeout(() => {
252
381
  if (Date.now() - lastFetchTime >= cacheTime) {
253
- cache.delete(key);
382
+ const subs = revalidationSubscribers.get(key);
383
+ if (!subs || subs.size === 0) {
384
+ cacheSignals.delete(key);
385
+ errorSignals.delete(key);
386
+ validatingSignals.delete(key);
387
+ cacheTimestamps.delete(key);
388
+ lastFetchTimestamps.delete(key);
389
+ }
254
390
  }
255
391
  }, cacheTime);
256
392
 
257
393
  return result;
258
394
  } catch (e) {
395
+ if (abortSignal.aborted) return;
259
396
  attempts++;
260
397
  if (attempts < retry) {
261
- await new Promise(r => setTimeout(r, retryDelay(attempts)));
398
+ // Abort-aware retry delay: cancel the wait if the component unmounts
399
+ await new Promise((resolve, reject) => {
400
+ const id = setTimeout(resolve, retryDelay(attempts));
401
+ abortSignal.addEventListener('abort', () => {
402
+ clearTimeout(id);
403
+ reject(new DOMException('Aborted', 'AbortError'));
404
+ }, { once: true });
405
+ }).catch(e => { if (e.name === 'AbortError') return; throw e; });
406
+ if (abortSignal.aborted) return;
262
407
  return attemptFetch();
263
408
  }
264
409
 
@@ -278,11 +423,18 @@ export function useQuery(options) {
278
423
  return attemptFetch();
279
424
  }
280
425
 
426
+ // Subscribe to invalidation events for this key
427
+ const unsubscribe = subscribeToKey(key, () => fetchQuery().catch(() => {}));
428
+
281
429
  // Initial fetch
282
430
  scopedEffect(() => {
283
431
  if (enabled) {
284
- fetch().catch(() => {});
432
+ fetchQuery().catch(() => {});
285
433
  }
434
+ return () => {
435
+ if (abortController) abortController.abort();
436
+ unsubscribe();
437
+ };
286
438
  });
287
439
 
288
440
  // Refetch on focus
@@ -290,7 +442,7 @@ export function useQuery(options) {
290
442
  scopedEffect(() => {
291
443
  const handler = () => {
292
444
  if (document.visibilityState === 'visible') {
293
- fetch().catch(() => {});
445
+ fetchQuery().catch(() => {});
294
446
  }
295
447
  };
296
448
  document.addEventListener('visibilitychange', handler);
@@ -302,7 +454,7 @@ export function useQuery(options) {
302
454
  if (refetchInterval) {
303
455
  scopedEffect(() => {
304
456
  const interval = setInterval(() => {
305
- fetch().catch(() => {});
457
+ fetchQuery().catch(() => {});
306
458
  }, refetchInterval);
307
459
  return () => clearInterval(interval);
308
460
  });
@@ -317,7 +469,7 @@ export function useQuery(options) {
317
469
  isError: () => status() === 'error',
318
470
  isSuccess: () => status() === 'success',
319
471
  isFetching: () => fetchStatus() === 'fetching',
320
- refetch: fetch,
472
+ refetch: fetchQuery,
321
473
  };
322
474
  }
323
475
 
@@ -342,8 +494,16 @@ export function useInfiniteQuery(options) {
342
494
  const isFetchingPreviousPage = signal(false);
343
495
 
344
496
  const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
497
+ let abortController = null;
498
+
499
+ let isRefetching = false;
345
500
 
346
501
  async function fetchPage(pageParam, direction = 'next') {
502
+ // Abort previous page fetch
503
+ if (abortController) abortController.abort();
504
+ abortController = new AbortController();
505
+ const { signal: abortSignal } = abortController;
506
+
347
507
  const loading = direction === 'next' ? isFetchingNextPage : isFetchingPreviousPage;
348
508
  loading.set(true);
349
509
 
@@ -351,10 +511,19 @@ export function useInfiniteQuery(options) {
351
511
  const result = await queryFn({
352
512
  queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
353
513
  pageParam,
514
+ signal: abortSignal,
354
515
  });
355
516
 
517
+ if (abortSignal.aborted) return;
518
+
356
519
  batch(() => {
357
- if (direction === 'next') {
520
+ if (isRefetching) {
521
+ // Refetch: replace all pages with fresh first page (SWR pattern —
522
+ // old pages stayed visible during fetch, now swap atomically)
523
+ pages.set([result]);
524
+ pageParams.set([pageParam]);
525
+ isRefetching = false;
526
+ } else if (direction === 'next') {
358
527
  pages.set([...pages.peek(), result]);
359
528
  pageParams.set([...pageParams.peek(), pageParam]);
360
529
  } else {
@@ -373,13 +542,16 @@ export function useInfiniteQuery(options) {
373
542
 
374
543
  return result;
375
544
  } finally {
376
- loading.set(false);
545
+ if (!abortSignal.aborted) loading.set(false);
377
546
  }
378
547
  }
379
548
 
380
- // Initial fetch
549
+ // Initial fetch, abort on unmount
381
550
  scopedEffect(() => {
382
551
  fetchPage(initialPageParam).catch(() => {});
552
+ return () => {
553
+ if (abortController) abortController.abort();
554
+ };
383
555
  });
384
556
 
385
557
  return {
@@ -403,8 +575,9 @@ export function useInfiniteQuery(options) {
403
575
  }
404
576
  },
405
577
  refetch: async () => {
406
- pages.set([]);
407
- pageParams.set([initialPageParam]);
578
+ // Keep old pages visible during refetch (SWR pattern).
579
+ // The fetchPage callback swaps them atomically when data arrives.
580
+ isRefetching = true;
408
581
  return fetchPage(initialPageParam);
409
582
  },
410
583
  };
@@ -412,33 +585,54 @@ export function useInfiniteQuery(options) {
412
585
 
413
586
  // --- Cache Management ---
414
587
 
415
- export function invalidateQueries(keyOrPredicate) {
588
+ export function invalidateQueries(keyOrPredicate, options = {}) {
589
+ const { hard = false } = options;
590
+ const keysToInvalidate = [];
416
591
  if (typeof keyOrPredicate === 'function') {
417
- for (const [key] of cache) {
418
- if (keyOrPredicate(key)) {
419
- cache.delete(key);
420
- }
592
+ for (const [key] of cacheSignals) {
593
+ if (keyOrPredicate(key)) keysToInvalidate.push(key);
421
594
  }
422
595
  } else {
423
- cache.delete(keyOrPredicate);
596
+ keysToInvalidate.push(keyOrPredicate);
597
+ }
598
+
599
+ for (const key of keysToInvalidate) {
600
+ // Hard invalidation clears data immediately (shows loading state)
601
+ // Soft invalidation (default) keeps stale data visible during re-fetch (SWR pattern)
602
+ if (hard && cacheSignals.has(key)) cacheSignals.get(key).set(null);
603
+ // Trigger all subscribers to re-fetch
604
+ const subs = revalidationSubscribers.get(key);
605
+ if (subs) {
606
+ for (const revalidate of subs) revalidate();
607
+ }
424
608
  }
425
609
  }
426
610
 
427
611
  export function prefetchQuery(key, fetcher) {
612
+ const cacheS = getCacheSignal(key);
428
613
  return fetcher(key).then(result => {
429
- cache.set(key, result);
614
+ cacheS.set(result);
615
+ cacheTimestamps.set(key, Date.now());
430
616
  return result;
431
617
  });
432
618
  }
433
619
 
434
- export function setQueryData(key, data) {
435
- cache.set(key, typeof data === 'function' ? data(cache.get(key)) : data);
620
+ export function setQueryData(key, updater) {
621
+ const cacheS = getCacheSignal(key);
622
+ const current = cacheS.peek();
623
+ cacheS.set(typeof updater === 'function' ? updater(current) : updater);
624
+ cacheTimestamps.set(key, Date.now());
436
625
  }
437
626
 
438
627
  export function getQueryData(key) {
439
- return cache.get(key);
628
+ return cacheSignals.has(key) ? cacheSignals.get(key).peek() : undefined;
440
629
  }
441
630
 
442
631
  export function clearCache() {
443
- cache.clear();
632
+ cacheSignals.clear();
633
+ errorSignals.clear();
634
+ validatingSignals.clear();
635
+ cacheTimestamps.clear();
636
+ lastFetchTimestamps.clear();
637
+ inFlightRequests.clear();
444
638
  }