what-core 0.2.0 → 0.4.0

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,11 +1,84 @@
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
+ import { getCurrentComponent } from './dom.js';
6
+
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
+ }
5
70
 
6
- // Global cache for requests
7
- const cache = new Map();
8
71
  const inFlightRequests = new Map();
72
+ const lastFetchTimestamps = new Map(); // key -> timestamp of last completed fetch
73
+
74
+ // Create an effect scoped to the current component's lifecycle.
75
+ // When the component unmounts, the effect is automatically disposed.
76
+ function scopedEffect(fn) {
77
+ const ctx = getCurrentComponent?.();
78
+ const dispose = effect(fn);
79
+ if (ctx) ctx.effects.push(dispose);
80
+ return dispose;
81
+ }
9
82
 
10
83
  // --- useFetch Hook ---
11
84
  // Simple fetch with automatic JSON parsing and error handling
@@ -22,8 +95,14 @@ export function useFetch(url, options = {}) {
22
95
  const data = signal(initialData);
23
96
  const error = signal(null);
24
97
  const isLoading = signal(true);
98
+ let abortController = null;
25
99
 
26
100
  async function fetchData() {
101
+ // Abort previous request
102
+ if (abortController) abortController.abort();
103
+ abortController = new AbortController();
104
+ const { signal: abortSignal } = abortController;
105
+
27
106
  isLoading.set(true);
28
107
  error.set(null);
29
108
 
@@ -35,6 +114,7 @@ export function useFetch(url, options = {}) {
35
114
  ...headers,
36
115
  },
37
116
  body: body ? JSON.stringify(body) : undefined,
117
+ signal: abortSignal,
38
118
  });
39
119
 
40
120
  if (!response.ok) {
@@ -42,17 +122,26 @@ export function useFetch(url, options = {}) {
42
122
  }
43
123
 
44
124
  const json = await response.json();
45
- data.set(transform(json));
125
+ if (!abortSignal.aborted) {
126
+ data.set(transform(json));
127
+ }
46
128
  } catch (e) {
47
- error.set(e);
129
+ if (!abortSignal.aborted) {
130
+ error.set(e);
131
+ }
48
132
  } finally {
49
- isLoading.set(false);
133
+ if (!abortSignal.aborted) {
134
+ isLoading.set(false);
135
+ }
50
136
  }
51
137
  }
52
138
 
53
- // Fetch on mount
54
- effect(() => {
139
+ // Fetch on mount, abort on unmount
140
+ scopedEffect(() => {
55
141
  fetchData();
142
+ return () => {
143
+ if (abortController) abortController.abort();
144
+ };
56
145
  });
57
146
 
58
147
  return {
@@ -79,54 +168,96 @@ export function useSWR(key, fetcher, options = {}) {
79
168
  suspense = false,
80
169
  } = options;
81
170
 
82
- // Reactive state
83
- const data = signal(cache.get(key) || fallbackData || null);
84
- const error = signal(null);
85
- const isValidating = signal(false);
86
- 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;
87
195
 
88
196
  async function revalidate() {
89
- // 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
90
200
  if (inFlightRequests.has(key)) {
91
- const existingPromise = inFlightRequests.get(key);
92
- const now = Date.now();
93
- if (now - existingPromise.timestamp < dedupingInterval) {
94
- return existingPromise.promise;
201
+ const existing = inFlightRequests.get(key);
202
+ if (now - existing.timestamp < dedupingInterval) {
203
+ return existing.promise;
95
204
  }
96
205
  }
97
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
+
98
218
  isValidating.set(true);
99
219
 
100
- const promise = fetcher(key);
101
- inFlightRequests.set(key, { promise, timestamp: Date.now() });
220
+ const promise = fetcher(key, { signal: abortSignal });
221
+ inFlightRequests.set(key, { promise, timestamp: now });
102
222
 
103
223
  try {
104
224
  const result = await promise;
225
+ if (abortSignal.aborted) return;
105
226
  batch(() => {
106
- data.set(result);
227
+ cacheS.set(result); // Updates ALL components reading this key
107
228
  error.set(null);
108
- cache.set(key, result);
109
229
  });
230
+ cacheTimestamps.set(key, Date.now());
231
+ lastFetchTimestamps.set(key, Date.now());
110
232
  if (onSuccess) onSuccess(result, key);
111
233
  return result;
112
234
  } catch (e) {
235
+ if (abortSignal.aborted) return;
113
236
  error.set(e);
114
237
  if (onError) onError(e, key);
115
238
  throw e;
116
239
  } finally {
117
- isValidating.set(false);
240
+ if (!abortSignal.aborted) isValidating.set(false);
118
241
  inFlightRequests.delete(key);
119
242
  }
120
243
  }
121
244
 
245
+ // Subscribe to invalidation events for this key
246
+ const unsubscribe = subscribeToKey(key, () => revalidate().catch(() => {}));
247
+
122
248
  // Initial fetch
123
- effect(() => {
249
+ scopedEffect(() => {
124
250
  revalidate().catch(() => {});
251
+ // Cleanup: abort and unsubscribe on unmount
252
+ return () => {
253
+ if (abortController) abortController.abort();
254
+ unsubscribe();
255
+ };
125
256
  });
126
257
 
127
258
  // Revalidate on focus
128
259
  if (revalidateOnFocus && typeof window !== 'undefined') {
129
- effect(() => {
260
+ scopedEffect(() => {
130
261
  const handler = () => {
131
262
  if (document.visibilityState === 'visible') {
132
263
  revalidate().catch(() => {});
@@ -139,7 +270,7 @@ export function useSWR(key, fetcher, options = {}) {
139
270
 
140
271
  // Revalidate on reconnect
141
272
  if (revalidateOnReconnect && typeof window !== 'undefined') {
142
- effect(() => {
273
+ scopedEffect(() => {
143
274
  const handler = () => revalidate().catch(() => {});
144
275
  window.addEventListener('online', handler);
145
276
  return () => window.removeEventListener('online', handler);
@@ -148,7 +279,7 @@ export function useSWR(key, fetcher, options = {}) {
148
279
 
149
280
  // Polling
150
281
  if (refreshInterval > 0) {
151
- effect(() => {
282
+ scopedEffect(() => {
152
283
  const interval = setInterval(() => {
153
284
  revalidate().catch(() => {});
154
285
  }, refreshInterval);
@@ -159,11 +290,12 @@ export function useSWR(key, fetcher, options = {}) {
159
290
  return {
160
291
  data: () => data(),
161
292
  error: () => error(),
162
- isLoading,
293
+ isLoading: () => isLoading(),
163
294
  isValidating: () => isValidating(),
164
295
  mutate: (newData, shouldRevalidate = true) => {
165
- data.set(typeof newData === 'function' ? newData(data()) : newData);
166
- 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());
167
299
  if (shouldRevalidate) {
168
300
  revalidate().catch(() => {});
169
301
  }
@@ -195,28 +327,34 @@ export function useQuery(options) {
195
327
 
196
328
  const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
197
329
 
198
- const rawData = signal(cache.get(key) || null);
330
+ const cacheS = getCacheSignal(key);
199
331
  const data = computed(() => {
200
- const d = rawData();
332
+ const d = cacheS();
201
333
  return select && d !== null ? select(d) : d;
202
334
  });
203
- const error = signal(null);
204
- const status = signal(cache.has(key) ? 'success' : 'loading');
335
+ const error = getErrorSignal(key);
336
+ const status = signal(cacheS.peek() != null ? 'success' : 'loading');
205
337
  const fetchStatus = signal('idle');
206
338
 
207
339
  let lastFetchTime = 0;
340
+ let abortController = null;
208
341
 
209
- async function fetch() {
342
+ async function fetchQuery() {
210
343
  if (!enabled) return;
211
344
 
212
345
  // Check if data is still fresh
213
346
  const now = Date.now();
214
- if (cache.has(key) && now - lastFetchTime < staleTime) {
215
- return cache.get(key);
347
+ if (cacheS.peek() != null && now - lastFetchTime < staleTime) {
348
+ return cacheS.peek();
216
349
  }
217
350
 
351
+ // Abort previous request
352
+ if (abortController) abortController.abort();
353
+ abortController = new AbortController();
354
+ const { signal: abortSignal } = abortController;
355
+
218
356
  fetchStatus.set('fetching');
219
- if (!cache.has(key)) {
357
+ if (cacheS.peek() == null) {
220
358
  status.set('loading');
221
359
  }
222
360
 
@@ -224,31 +362,48 @@ export function useQuery(options) {
224
362
 
225
363
  async function attemptFetch() {
226
364
  try {
227
- 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;
228
367
  batch(() => {
229
- rawData.set(result);
368
+ cacheS.set(result); // Updates all components reading this key
230
369
  error.set(null);
231
370
  status.set('success');
232
371
  fetchStatus.set('idle');
233
372
  });
234
- cache.set(key, result);
235
373
  lastFetchTime = Date.now();
374
+ cacheTimestamps.set(key, Date.now());
236
375
 
237
376
  if (onSuccess) onSuccess(result);
238
377
  if (onSettled) onSettled(result, null);
239
378
 
240
- // Schedule cache cleanup
379
+ // Schedule cache cleanup (only if no active subscribers)
241
380
  setTimeout(() => {
242
381
  if (Date.now() - lastFetchTime >= cacheTime) {
243
- 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
+ }
244
390
  }
245
391
  }, cacheTime);
246
392
 
247
393
  return result;
248
394
  } catch (e) {
395
+ if (abortSignal.aborted) return;
249
396
  attempts++;
250
397
  if (attempts < retry) {
251
- 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;
252
407
  return attemptFetch();
253
408
  }
254
409
 
@@ -268,19 +423,26 @@ export function useQuery(options) {
268
423
  return attemptFetch();
269
424
  }
270
425
 
426
+ // Subscribe to invalidation events for this key
427
+ const unsubscribe = subscribeToKey(key, () => fetchQuery().catch(() => {}));
428
+
271
429
  // Initial fetch
272
- effect(() => {
430
+ scopedEffect(() => {
273
431
  if (enabled) {
274
- fetch().catch(() => {});
432
+ fetchQuery().catch(() => {});
275
433
  }
434
+ return () => {
435
+ if (abortController) abortController.abort();
436
+ unsubscribe();
437
+ };
276
438
  });
277
439
 
278
440
  // Refetch on focus
279
441
  if (refetchOnWindowFocus && typeof window !== 'undefined') {
280
- effect(() => {
442
+ scopedEffect(() => {
281
443
  const handler = () => {
282
444
  if (document.visibilityState === 'visible') {
283
- fetch().catch(() => {});
445
+ fetchQuery().catch(() => {});
284
446
  }
285
447
  };
286
448
  document.addEventListener('visibilitychange', handler);
@@ -290,9 +452,9 @@ export function useQuery(options) {
290
452
 
291
453
  // Polling
292
454
  if (refetchInterval) {
293
- effect(() => {
455
+ scopedEffect(() => {
294
456
  const interval = setInterval(() => {
295
- fetch().catch(() => {});
457
+ fetchQuery().catch(() => {});
296
458
  }, refetchInterval);
297
459
  return () => clearInterval(interval);
298
460
  });
@@ -307,7 +469,7 @@ export function useQuery(options) {
307
469
  isError: () => status() === 'error',
308
470
  isSuccess: () => status() === 'success',
309
471
  isFetching: () => fetchStatus() === 'fetching',
310
- refetch: fetch,
472
+ refetch: fetchQuery,
311
473
  };
312
474
  }
313
475
 
@@ -332,8 +494,16 @@ export function useInfiniteQuery(options) {
332
494
  const isFetchingPreviousPage = signal(false);
333
495
 
334
496
  const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
497
+ let abortController = null;
498
+
499
+ let isRefetching = false;
335
500
 
336
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
+
337
507
  const loading = direction === 'next' ? isFetchingNextPage : isFetchingPreviousPage;
338
508
  loading.set(true);
339
509
 
@@ -341,10 +511,19 @@ export function useInfiniteQuery(options) {
341
511
  const result = await queryFn({
342
512
  queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
343
513
  pageParam,
514
+ signal: abortSignal,
344
515
  });
345
516
 
517
+ if (abortSignal.aborted) return;
518
+
346
519
  batch(() => {
347
- 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') {
348
527
  pages.set([...pages.peek(), result]);
349
528
  pageParams.set([...pageParams.peek(), pageParam]);
350
529
  } else {
@@ -363,13 +542,16 @@ export function useInfiniteQuery(options) {
363
542
 
364
543
  return result;
365
544
  } finally {
366
- loading.set(false);
545
+ if (!abortSignal.aborted) loading.set(false);
367
546
  }
368
547
  }
369
548
 
370
- // Initial fetch
371
- effect(() => {
549
+ // Initial fetch, abort on unmount
550
+ scopedEffect(() => {
372
551
  fetchPage(initialPageParam).catch(() => {});
552
+ return () => {
553
+ if (abortController) abortController.abort();
554
+ };
373
555
  });
374
556
 
375
557
  return {
@@ -393,8 +575,9 @@ export function useInfiniteQuery(options) {
393
575
  }
394
576
  },
395
577
  refetch: async () => {
396
- pages.set([]);
397
- 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;
398
581
  return fetchPage(initialPageParam);
399
582
  },
400
583
  };
@@ -402,33 +585,54 @@ export function useInfiniteQuery(options) {
402
585
 
403
586
  // --- Cache Management ---
404
587
 
405
- export function invalidateQueries(keyOrPredicate) {
588
+ export function invalidateQueries(keyOrPredicate, options = {}) {
589
+ const { hard = false } = options;
590
+ const keysToInvalidate = [];
406
591
  if (typeof keyOrPredicate === 'function') {
407
- for (const [key] of cache) {
408
- if (keyOrPredicate(key)) {
409
- cache.delete(key);
410
- }
592
+ for (const [key] of cacheSignals) {
593
+ if (keyOrPredicate(key)) keysToInvalidate.push(key);
411
594
  }
412
595
  } else {
413
- 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
+ }
414
608
  }
415
609
  }
416
610
 
417
611
  export function prefetchQuery(key, fetcher) {
612
+ const cacheS = getCacheSignal(key);
418
613
  return fetcher(key).then(result => {
419
- cache.set(key, result);
614
+ cacheS.set(result);
615
+ cacheTimestamps.set(key, Date.now());
420
616
  return result;
421
617
  });
422
618
  }
423
619
 
424
- export function setQueryData(key, data) {
425
- 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());
426
625
  }
427
626
 
428
627
  export function getQueryData(key) {
429
- return cache.get(key);
628
+ return cacheSignals.has(key) ? cacheSignals.get(key).peek() : undefined;
430
629
  }
431
630
 
432
631
  export function clearCache() {
433
- cache.clear();
632
+ cacheSignals.clear();
633
+ errorSignals.clear();
634
+ validatingSignals.clear();
635
+ cacheTimestamps.clear();
636
+ lastFetchTimestamps.clear();
637
+ inFlightRequests.clear();
434
638
  }