what-core 0.12.3 → 0.13.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/src/data.js CHANGED
@@ -1,7 +1,7 @@
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, __DEV__ } from './reactive.js';
4
+ import { signal, effect, batch, computed, untrack, __DEV__ } from './reactive.js';
5
5
  import { getCurrentComponent } from './dom.js';
6
6
 
7
7
  // --- Reactive Cache ---
@@ -87,6 +87,28 @@ function subscribeToKey(key, revalidateFn) {
87
87
  };
88
88
  }
89
89
 
90
+ // What clearCache() cannot reach by walking the cache Maps.
91
+ //
92
+ // Two different things live outside them. useInfiniteQuery keeps its pages in
93
+ // signals local to the hook (see the note above it on why), so walking
94
+ // cacheSignals emptied nothing at all for it. And EVERY hook keeps the request
95
+ // it currently has in flight in a local AbortController: emptying the cache
96
+ // does not cancel a request that is already on the wire, so the previous user's
97
+ // response lands a moment after the clear and writes their data straight back
98
+ // onto the screen. That is the exact failure clearCache exists to prevent, at
99
+ // the exact moment (logout) it is called for, and it was guarded in
100
+ // useInfiniteQuery and nowhere else.
101
+ //
102
+ // A handler is registered for the lifetime of the hook's effect, the same
103
+ // lifetime as its invalidation subscription, so it goes away on unmount with
104
+ // everything else.
105
+ const clearCacheHandlers = new Set();
106
+
107
+ function registerClearCacheHandler(handler) {
108
+ clearCacheHandlers.add(handler);
109
+ return () => clearCacheHandlers.delete(handler);
110
+ }
111
+
90
112
  const inFlightRequests = new Map(); // key -> { promise, timestamp, refCount, epoch }
91
113
  const lastFetchTimestamps = new Map(); // key -> timestamp of last completed fetch
92
114
 
@@ -115,6 +137,28 @@ function unrefTimer(timer) {
115
137
  return timer;
116
138
  }
117
139
 
140
+ // EVERY computed() in this file reads EVERY signal it depends on
141
+ // UNCONDITIONALLY, even when a short circuit would let it skip one.
142
+ //
143
+ // This is not a style preference, it is a requirement of the reactive core. A
144
+ // computed is backed by an effect, and _runEffect auto-promotes an effect to
145
+ // "stable" when it had exactly one dependency before a re-run and the same
146
+ // single dependency after it (the assumption being that a one-dependency effect
147
+ // cannot have conditional reads). A stable effect re-runs with currentEffect
148
+ // set to null: it still produces a fresh value, but it can never SUBSCRIBE to
149
+ // anything it was not already subscribed to.
150
+ //
151
+ // So a computed of the shape `a() === 'x' && b()` is one re-run away from
152
+ // permanent deafness to `b`: any re-run in which `a` is not 'x' reads only `a`,
153
+ // promotes the computed, and from then on `b` can change without the computed
154
+ // ever being notified. It goes stale silently and forever, and only for some
155
+ // orderings of the writes, which is why both instances of this in the file
156
+ // looked fine in tests and failed in an app. See the notes on useQuery's
157
+ // `status` and useSWR's `isLoading` for what each one actually broke.
158
+ //
159
+ // Reading everything unconditionally fixes it whatever the promotion rule is:
160
+ // the dependency set never varies, so there is nothing left to lose.
161
+
118
162
  // Create an effect scoped to the current component's lifecycle.
119
163
  // When the component unmounts, the effect is automatically disposed.
120
164
  function scopedEffect(fn) {
@@ -124,6 +168,49 @@ function scopedEffect(fn) {
124
168
  return dispose;
125
169
  }
126
170
 
171
+ // Register a teardown with the component that owns this hook.
172
+ //
173
+ // scopedEffect's cleanup is not the right home for all of it: that cleanup runs
174
+ // before every RE-RUN of the effect as well as on disposal, and some teardown
175
+ // (cancelling a request the application explicitly asked for) must happen only
176
+ // when the component actually goes away. Outside a component there is nothing
177
+ // to unmount and the effect is never disposed either, so the two agree.
178
+ function onComponentDispose(fn) {
179
+ const ctx = getCurrentComponent?.();
180
+ if (ctx) ctx.effects.push(fn);
181
+ }
182
+
183
+ // `enabled` as a READABLE gate rather than a value captured once.
184
+ //
185
+ // Components run once here, so a plain boolean read at call time is frozen for
186
+ // the lifetime of the query: `enabled: userId() != null` could never become
187
+ // true, and the whole point of the option (a dependent query that starts when
188
+ // its dependency arrives) was unreachable. Accepting a signal or any thunk lets
189
+ // the query's own effect track it, so flipping the flag starts the query.
190
+ //
191
+ // The thunk is MIRRORED into a boolean signal rather than read by the query's
192
+ // own effect. Reading it there subscribes that effect to everything the thunk
193
+ // touches, and re-running it is destructive: it aborts whatever request is in
194
+ // flight. So `enabled: () => tab() === 'reports'` let an unrelated move of
195
+ // `tab` cancel the query even though the gate's value never changed, which
196
+ // silently killed an explicit refetch() (its promise resolved with `undefined`:
197
+ // no data, no error, no rejection), and `enabled: () => userId() != null`
198
+ // issued a redundant second fetch on every change of `userId`, into the
199
+ // queryKey captured at hook creation, i.e. the OLD key. A signal does not
200
+ // notify when it is written the value it already holds, so mirroring collapses
201
+ // "something the thunk reads moved" down to "the gate flipped", which is the
202
+ // only event a query has any business reacting to.
203
+ //
204
+ // A boolean is still accepted and still behaves exactly as before: it reads no
205
+ // signals, so the mirroring effect tracks no dependencies and the reactive core
206
+ // releases it on the spot.
207
+ function createGate(enabled) {
208
+ const read = typeof enabled === 'function' ? enabled : () => enabled;
209
+ const gate = signal(untrack(() => !!read()));
210
+ scopedEffect(() => { gate.set(!!read()); });
211
+ return gate;
212
+ }
213
+
127
214
  // --- useFetch Hook ---
128
215
  // Simple fetch with automatic JSON parsing and error handling
129
216
 
@@ -200,21 +287,12 @@ export function useFetch(url, options = {}) {
200
287
  // --- useSWR Hook ---
201
288
  // Stale-while-revalidate pattern with caching
202
289
 
203
- export function useSWR(key, fetcher, options = {}) {
204
- const {
205
- revalidateOnFocus = true,
206
- revalidateOnReconnect = true,
207
- refreshInterval = 0,
208
- dedupingInterval = 2000,
209
- fallbackData,
210
- onSuccess,
211
- onError,
212
- suspense = false,
213
- } = options;
290
+ export function useSWR(rawKey, fetcher, options = {}) {
291
+ const { revalidateOnFocus = true, revalidateOnReconnect = true, refreshInterval = 0, dedupingInterval = 2000, fallbackData, onSuccess, onError } = options;
214
292
 
215
293
  // Support null/undefined/false key for conditional/dependent fetching
216
294
  // When key is falsy, don't fetch — return idle state
217
- if (key == null || key === false) {
295
+ if (rawKey == null || rawKey === false) {
218
296
  const data = signal(fallbackData || null);
219
297
  const error = signal(null);
220
298
  return {
@@ -227,13 +305,42 @@ export function useSWR(key, fetcher, options = {}) {
227
305
  };
228
306
  }
229
307
 
308
+ // Normalized into the same flat string space every other cache-facing entry
309
+ // point uses (see normalizeQueryKey, which says every one of them must
310
+ // normalize identically). useSWR was the one that did not, so an ARRAY key
311
+ // was used as a Map key by object IDENTITY, and the documented array-key
312
+ // shape `useSWR(['/api/user', id], ...)` broke three ways at once: two
313
+ // components passing equal-but-distinct arrays got two cache entries and
314
+ // never saw each other's data, getQueryData(['/api/user', 1]) could not find
315
+ // what was there, and an Array reached invalidateQueries' predicate, where
316
+ // the documented `key => key.startsWith('/api/posts')` throws -- before any
317
+ // key has been bumped, so ONE array key anywhere in the app turned every
318
+ // predicate invalidation into a no-op that reported itself as a TypeError.
319
+ //
320
+ // The FETCHER still receives the original key. SWR's contract is
321
+ // `useSWR(['/api/user', id], ([url, id]) => ...)`, and how the cache spells a
322
+ // key internally is none of the fetcher's business.
323
+ const key = normalizeQueryKey(rawKey);
324
+
230
325
  // Shared reactive cache signals — all useSWR instances with the same key
231
326
  // read from these signals, so mutating from one component updates all others.
232
327
  const cacheS = getCacheSignal(key);
233
328
  const error = getErrorSignal(key);
234
329
  const isValidating = getValidatingSignal(key);
235
330
  const data = computed(() => cacheS() ?? fallbackData ?? null);
236
- const isLoading = computed(() => cacheS() == null && isValidating());
331
+ // Both reads are unconditional. See the note on conditional reads above
332
+ // scopedEffect: as `cacheS() == null && isValidating()` this computed dropped
333
+ // to a single dependency on any re-run that found the cache full (a mutate(),
334
+ // a setQueryData(), a second successful fetch), was promoted to a stable
335
+ // effect there, and never tracked isValidating again -- after which
336
+ // isLoading() answered false for the rest of the page's life, including with
337
+ // an empty cache and a request in flight, which is the one state it exists to
338
+ // report.
339
+ const isLoading = computed(() => {
340
+ const empty = cacheS() == null;
341
+ const fetching = isValidating();
342
+ return empty && fetching;
343
+ });
237
344
 
238
345
  let abortController = null;
239
346
 
@@ -287,7 +394,8 @@ export function useSWR(key, fetcher, options = {}) {
287
394
 
288
395
  isValidating.set(true);
289
396
 
290
- const promise = fetcher(key, { signal: abortSignal });
397
+ // rawKey, not the normalized one: see the note where `key` is derived.
398
+ const promise = fetcher(rawKey, { signal: abortSignal });
291
399
  inFlightRequests.set(key, { promise, timestamp: now, refCount: 1, epoch });
292
400
 
293
401
  try {
@@ -299,12 +407,12 @@ export function useSWR(key, fetcher, options = {}) {
299
407
  });
300
408
  cacheTimestamps.set(key, Date.now());
301
409
  lastFetchTimestamps.set(key, Date.now());
302
- if (onSuccess) onSuccess(result, key);
410
+ if (onSuccess) onSuccess(result, rawKey);
303
411
  return result;
304
412
  } catch (e) {
305
413
  if (abortSignal.aborted) return;
306
414
  error.set(e);
307
- if (onError) onError(e, key);
415
+ if (onError) onError(e, rawKey);
308
416
  throw e;
309
417
  } finally {
310
418
  if (!abortSignal.aborted) isValidating.set(false);
@@ -326,11 +434,24 @@ export function useSWR(key, fetcher, options = {}) {
326
434
  // Re-subscribing per run keeps the two halves in the same lifecycle.
327
435
  scopedEffect(() => {
328
436
  const unsubscribe = subscribeToKey(key, () => revalidate({ force: true }).catch(() => {}));
437
+ // clearCache() empties this key's shared signals, but the request already on
438
+ // the wire is not in any Map it can walk: it landed after the clear and put
439
+ // the previous user's data back. See registerClearCacheHandler.
440
+ const releaseHandler = registerClearCacheHandler(() => {
441
+ if (abortController) {
442
+ abortController.abort();
443
+ abortController = null;
444
+ }
445
+ // isValidating is the SHARED signal for this key and clearCache has
446
+ // already set it false; the aborted request deliberately returns without
447
+ // touching it, so there is nothing left to reset here.
448
+ });
329
449
  revalidate().catch(() => {});
330
450
  // Cleanup: abort and unsubscribe on unmount
331
451
  return () => {
332
452
  if (abortController) abortController.abort();
333
453
  unsubscribe();
454
+ releaseHandler();
334
455
  };
335
456
  });
336
457
 
@@ -405,25 +526,101 @@ export function useQuery(options) {
405
526
  } = options;
406
527
 
407
528
  const key = normalizeQueryKey(queryKey);
529
+ const gate = createGate(enabled);
408
530
 
409
531
  const cacheS = getCacheSignal(key);
410
532
  const data = computed(() => {
411
533
  const d = cacheS();
412
- return select && d !== null ? select(d) : d;
534
+ // `!= null`, not `!== null`: an emptied entry reads `undefined` (see
535
+ // clearCache), and handing a user's select() an undefined it was never
536
+ // written to expect turns a cleared cache into a crash inside their code.
537
+ return select && d != null ? select(d) : d;
413
538
  });
414
539
  const error = getErrorSignal(key);
415
- const status = signal(cacheS.peek() != null ? 'success' : 'loading');
540
+ // What this hook WRITES. Components read the derived `status` just below.
541
+ //
542
+ // A disabled query with nothing cached is NOT loading: no request is in
543
+ // flight and none will be until it is enabled or refetch() is called.
544
+ // Reporting 'loading' forever made "waiting for the user" indistinguishable
545
+ // from "waiting for the network", so a spinner rendered on isLoading() never
546
+ // came down. 'idle' is the honest third state, and isLoading() is false in it.
547
+ const rawStatus = signal(
548
+ cacheS.peek() != null ? 'success' : (gate.peek() ? 'loading' : 'idle')
549
+ );
416
550
  const fetchStatus = signal('idle');
417
551
 
552
+ // A settled status only holds while the cache still holds what it settled on.
553
+ // The data lives in a SHARED signal that something this hook never hears
554
+ // about can empty -- clearCache() on logout, a sibling's
555
+ // setQueryData(key, null) -- and a per-hook status signal has no way to
556
+ // notice. That left status 'success' with data() === undefined, which walks
557
+ // the canonical guarded render
558
+ //
559
+ // if (q.isLoading()) return 'Loading...';
560
+ // if (q.isError()) return 'Error';
561
+ // if (q.isIdle()) return 'Idle';
562
+ // return q.data().name;
563
+ //
564
+ // past every guard and into a TypeError, at the one moment (logout) when
565
+ // clearCache is most likely to be called. Deriving keeps status and data
566
+ // moving together whatever emptied the entry, instead of requiring every
567
+ // writer of the shared cache to know about every hook reading it.
568
+ //
569
+ // All four reads are UNCONDITIONAL, and that is load-bearing rather than
570
+ // tidy. Written as `s === 'success' && cacheS() == null` this computed reads
571
+ // the cache only in the success branch, so a re-run that finds any other
572
+ // status reads rawStatus alone -- one dependency -- and the reactive core
573
+ // promotes it to a stable effect that can never subscribe to anything new.
574
+ // See the note on conditional reads above scopedEffect.
575
+ //
576
+ // A query created with `enabled: false` takes exactly that path and an
577
+ // enabled one does not, which is why this looked fixed: 'idle' -> 'loading'
578
+ // (refetch starts) is a one-dependency re-run, where an enabled query goes
579
+ // straight from 'loading' to 'success' in a batch that writes the cache too,
580
+ // and so keeps both dependencies. So after refetch() a disabled query's
581
+ // status was permanently deaf to its own cache, and clearCache() left it
582
+ // reporting 'success' with data() === undefined -- walking the guarded render
583
+ // above into the TypeError this computed was written to prevent, at logout,
584
+ // with the previous user's value still painted on screen.
585
+ const status = computed(() => {
586
+ const s = rawStatus();
587
+ const hasData = cacheS() != null;
588
+ const hasError = error() != null;
589
+ // 'loading' only when something really is on its way to refill the entry.
590
+ // clearCache() starts no request, so reporting 'loading' there would render
591
+ // a spinner that never comes down -- the same defect, moved.
592
+ const refilling = fetchStatus() === 'fetching';
593
+ const lostData = s === 'success' && !hasData;
594
+ const lostError = s === 'error' && !hasError;
595
+ if (lostData || lostError) return refilling ? 'loading' : 'idle';
596
+ return s;
597
+ });
598
+
418
599
  let lastFetchTime = 0;
419
- let abortController = null;
600
+ // The request this hook has in flight, and who asked for it. An AUTOMATIC
601
+ // fetch (mount, focus, polling, invalidation) belongs to the effect below and
602
+ // dies with it; a `manual` one was asked for by application code, and the
603
+ // effect's cleanup must leave it alone. One shared controller meant an
604
+ // unrelated re-render cancelled a button click's refetch(), whose promise
605
+ // then resolved with `undefined`.
606
+ let inFlight = null;
420
607
  let cleanupTimer = null;
421
608
 
422
609
  // See the note on useSWR's revalidate: an invalidation must not be answered
423
610
  // from the freshness window, or `invalidateQueries` becomes a no-op for every
424
611
  // query with a staleTime.
425
- async function fetchQuery({ force = false } = {}) {
426
- if (!enabled) return;
612
+ //
613
+ // `manual` separates an explicit refetch() from AUTOMATIC fetching (mount,
614
+ // window focus, polling, invalidation). Only the automatic paths are gated by
615
+ // `enabled`: a call from application code is a direct request for data, and
616
+ // gating it left `enabled: false` + "fetch on a button click" with no
617
+ // supported form at all. The gate is peeked, not read reactively, because
618
+ // this function is also called from detached callbacks (a focus handler, an
619
+ // invalidation subscriber) where a tracked read would attach the gate to
620
+ // whatever effect happened to be running; the effect below does the tracked
621
+ // read.
622
+ async function fetchQuery({ force = false, manual = false } = {}) {
623
+ if (!manual && !gate.peek()) return;
427
624
 
428
625
  // Check if data is still fresh
429
626
  const now = Date.now();
@@ -431,14 +628,18 @@ export function useQuery(options) {
431
628
  return cacheS.peek();
432
629
  }
433
630
 
434
- // Abort previous request
435
- if (abortController) abortController.abort();
436
- abortController = new AbortController();
437
- const { signal: abortSignal } = abortController;
631
+ // Supersede whatever this hook had in flight: one request per hook, as
632
+ // before, whoever asked for it. Superseding REPLACES the request, which is
633
+ // why it may do this to a manual one; the effect cleanup below only
634
+ // cancels, which is why it may not.
635
+ if (inFlight) inFlight.controller.abort();
636
+ const controller = new AbortController();
637
+ inFlight = { controller, manual };
638
+ const { signal: abortSignal } = controller;
438
639
 
439
640
  fetchStatus.set('fetching');
440
641
  if (cacheS.peek() == null) {
441
- status.set('loading');
642
+ rawStatus.set('loading');
442
643
  }
443
644
 
444
645
  let attempts = 0;
@@ -450,7 +651,7 @@ export function useQuery(options) {
450
651
  batch(() => {
451
652
  cacheS.set(result); // Updates all components reading this key
452
653
  error.set(null);
453
- status.set('success');
654
+ rawStatus.set('success');
454
655
  fetchStatus.set('idle');
455
656
  });
456
657
  lastFetchTime = Date.now();
@@ -497,7 +698,7 @@ export function useQuery(options) {
497
698
 
498
699
  batch(() => {
499
700
  error.set(e);
500
- status.set('error');
701
+ rawStatus.set('error');
501
702
  fetchStatus.set('idle');
502
703
  });
503
704
 
@@ -508,7 +709,39 @@ export function useQuery(options) {
508
709
  }
509
710
  }
510
711
 
511
- return attemptFetch();
712
+ try {
713
+ return await attemptFetch();
714
+ } finally {
715
+ // Release ownership, but only if this request is still the current one:
716
+ // a newer fetch may already have superseded it while it was awaiting.
717
+ if (inFlight && inFlight.controller === controller) inFlight = null;
718
+ }
719
+ }
720
+
721
+ // clearCache() empties this key's shared signals, but the request this hook
722
+ // already has in flight is local to it and went on running: it landed a
723
+ // moment after the clear and wrote the previous user's data back into the
724
+ // entry the component is reading. See registerClearCacheHandler.
725
+ //
726
+ // A manual refetch() is cancelled too, unlike in the effect cleanup below.
727
+ // The caller's promise then resolves with undefined, which is the right trade
728
+ // against painting a logged-out user's data: clearCache() is a nuke, and
729
+ // useInfiniteQuery has always treated it as one.
730
+ function resetOnClearCache() {
731
+ if (inFlight) {
732
+ inFlight.controller.abort();
733
+ inFlight = null;
734
+ }
735
+ // An aborted fetch deliberately returns without touching either status
736
+ // signal, so nothing else would ever clear them. fetchStatus is the one
737
+ // that must not be left behind: the derived status above reads it to decide
738
+ // whether an emptied entry is being refilled, so 'fetching' would describe
739
+ // a request that no longer exists as a load in progress, and render a
740
+ // spinner that never comes down.
741
+ batch(() => {
742
+ if (rawStatus.peek() !== 'idle') rawStatus.set('idle');
743
+ if (fetchStatus.peek() !== 'idle') fetchStatus.set('idle');
744
+ });
512
745
  }
513
746
 
514
747
  // Initial fetch, plus the invalidation subscription for this key.
@@ -518,15 +751,44 @@ export function useQuery(options) {
518
751
  // re-run, leaving the query permanently deaf to invalidateQueries().
519
752
  scopedEffect(() => {
520
753
  const unsubscribe = subscribeToKey(key, () => fetchQuery({ force: true }).catch(() => {}));
521
- if (enabled) {
754
+ const releaseHandler = registerClearCacheHandler(resetOnClearCache);
755
+ // Tracked read of the MIRRORED gate, so this effect re-runs when the gate
756
+ // actually flips (and when the query function's own signals move), not
757
+ // whenever some unrelated signal a gate thunk happens to touch moves.
758
+ if (gate()) {
522
759
  fetchQuery().catch(() => {});
760
+ } else if (!inFlight?.manual) {
761
+ // Turning a query off settles it. The cleanup below aborted anything the
762
+ // effect itself started, and an aborted request deliberately returns
763
+ // without touching either status signal, so nothing else would ever clear
764
+ // them. A request refetch() owns is still running, though, so this must
765
+ // not report 'idle' over the top of one.
766
+ batch(() => {
767
+ if (rawStatus.peek() === 'loading') rawStatus.set('idle');
768
+ if (fetchStatus.peek() !== 'idle') fetchStatus.set('idle');
769
+ });
523
770
  }
524
771
  return () => {
525
- if (abortController) abortController.abort();
772
+ // Cancel only what this effect started. Cancelling a manual refetch here
773
+ // destroyed it without replacing it, and the caller saw nothing at all.
774
+ if (inFlight && !inFlight.manual) {
775
+ inFlight.controller.abort();
776
+ inFlight = null;
777
+ }
526
778
  unsubscribe();
779
+ releaseHandler();
527
780
  };
528
781
  });
529
782
 
783
+ // Unmount cancels everything, including the manual request the effect's own
784
+ // cleanup deliberately spares.
785
+ onComponentDispose(() => {
786
+ if (inFlight) {
787
+ inFlight.controller.abort();
788
+ inFlight = null;
789
+ }
790
+ });
791
+
530
792
  // Refetch on focus
531
793
  if (refetchOnWindowFocus && typeof window !== 'undefined') {
532
794
  scopedEffect(() => {
@@ -558,14 +820,38 @@ export function useQuery(options) {
558
820
  isLoading: () => status() === 'loading',
559
821
  isError: () => status() === 'error',
560
822
  isSuccess: () => status() === 'success',
823
+ // A query that is disabled and has never resolved. Its complement used to
824
+ // be reported as isLoading(), which is why a disabled query rendered a
825
+ // spinner that never came down.
826
+ isIdle: () => status() === 'idle',
561
827
  isFetching: () => fetchStatus() === 'fetching',
562
- refetch: fetchQuery,
828
+ isEnabled: () => gate(),
829
+ // Explicit: never gated by `enabled`, and never answered from the freshness
830
+ // window. A manual "get me fresh data now" that a staleTime silently
831
+ // swallows is a no-op the caller has no way to see, which is the same bug
832
+ // class that made invalidateQueries() a no-op.
833
+ refetch: () => fetchQuery({ force: true, manual: true }),
563
834
  };
564
835
  }
565
836
 
566
837
  // --- useInfiniteQuery Hook ---
567
838
  // For paginated/infinite scroll data
568
839
 
840
+ // Every base query option used to fall into an unused `...rest`, so `enabled`,
841
+ // `select`, `retry`, `onSuccess` and the rest were accepted and silently
842
+ // dropped. Those are honoured here.
843
+ //
844
+ // DEFERRED, on purpose: joining the shared cache. `key` is normalized and used
845
+ // for the invalidation subscription, but the pages live in signals local to
846
+ // this hook rather than in cacheSignals, so staleTime/cacheTime, getQueryData,
847
+ // setQueryData and cross-component sharing do not reach an infinite query yet.
848
+ // The shared cache holds ONE value per key, and an infinite query holds a
849
+ // growing list plus its page params; storing that under the same key would
850
+ // collide head-on with a useQuery on the same key (each would serve the other
851
+ // the wrong shape) and hand setQueryData a structure it has no way to describe.
852
+ // That needs a cache entry with a declared kind, which is a design change, not
853
+ // a patch. The options that depend on it (staleTime, cacheTime, placeholderData,
854
+ // refetchOnWindowFocus, refetchInterval) are still not honoured.
569
855
  export function useInfiniteQuery(options) {
570
856
  const {
571
857
  queryKey,
@@ -573,103 +859,311 @@ export function useInfiniteQuery(options) {
573
859
  getNextPageParam,
574
860
  getPreviousPageParam,
575
861
  initialPageParam,
576
- ...rest
862
+ enabled = true,
863
+ select,
864
+ retry = 3,
865
+ retryDelay = (attempt) => Math.min(1000 * 2 ** attempt, 30000),
866
+ onSuccess,
867
+ onError,
868
+ onSettled,
577
869
  } = options;
578
870
 
871
+ const gate = createGate(enabled);
872
+
579
873
  const pages = signal([]);
580
- const pageParams = signal([initialPageParam]);
874
+ // Starts EMPTY. It used to be seeded with initialPageParam AND appended to by
875
+ // the first fetch, so one page in produced [0, 0]: pageParams[i] no longer
876
+ // named pages[i], and anything walking the two together (a "load newer"
877
+ // control reading pageParams[0]) held a param for a page that did not exist.
878
+ const pageParams = signal([]);
581
879
  const hasNextPage = signal(true);
582
880
  const hasPreviousPage = signal(false);
583
881
  const isFetchingNextPage = signal(false);
584
882
  const isFetchingPreviousPage = signal(false);
883
+ // An infinite query had no failure surface at all: a rejected queryFn was
884
+ // swallowed by the effect's .catch() and the list just stayed empty forever,
885
+ // with no way for a component to tell "no results" from "the request failed".
886
+ // index.d.ts already promised error/status/isLoading here.
887
+ //
888
+ // These are local rather than the shared getErrorSignal(key), for the reason
889
+ // in the note above: an infinite query does not own the shared entry for its
890
+ // key, so writing errors into it would clobber a useQuery reading the same key.
891
+ const error = signal(null);
892
+ const status = signal(gate.peek() ? 'loading' : 'idle');
585
893
 
586
894
  const key = normalizeQueryKey(queryKey);
587
- let abortController = null;
895
+ // The page request in flight and who asked for it. See the matching note in
896
+ // useQuery: an explicit fetchNextPage()/refetch() belongs to the caller, and
897
+ // the effect's cleanup must not cancel one just because it re-ran.
898
+ let inFlight = null;
899
+
900
+ // clearCache() reaches an infinite query through here, because its pages
901
+ // never entered cacheSignals for clearCache to empty. Same nuke: drop the
902
+ // pages, drop the error, and cancel anything in flight -- a request issued
903
+ // for the PREVIOUS user would otherwise land after the clear and put their
904
+ // rows back on screen, which is the whole thing we are preventing.
905
+ function resetOnClearCache() {
906
+ if (inFlight) {
907
+ inFlight.controller.abort();
908
+ inFlight = null;
909
+ }
910
+ batch(() => {
911
+ pages.set([]);
912
+ pageParams.set([]);
913
+ hasNextPage.set(true);
914
+ hasPreviousPage.set(false);
915
+ isFetchingNextPage.set(false);
916
+ isFetchingPreviousPage.set(false);
917
+ error.set(null);
918
+ // Nothing is in flight and the clear started nothing, so 'idle' -- the
919
+ // same honest report useQuery derives for an emptied entry.
920
+ status.set('idle');
921
+ });
922
+ }
588
923
 
589
- let isRefetching = false;
590
-
591
- async function fetchPage(pageParam, direction = 'next') {
592
- // Abort previous page fetch
593
- if (abortController) abortController.abort();
594
- abortController = new AbortController();
595
- const { signal: abortSignal } = abortController;
924
+ // `replace` is a per-call argument, not a flag on the hook. As a flag it was
925
+ // set before the fetch and cleared only on success, so a refetch that aborted
926
+ // or failed left "replace the whole list" armed for whichever fetchNextPage()
927
+ // ran next, which silently deleted every loaded page.
928
+ async function fetchPage(pageParam, direction = 'next', { replace = false, manual = false } = {}) {
929
+ // Supersede the previous page fetch, whoever asked for it.
930
+ if (inFlight) inFlight.controller.abort();
931
+ const controller = new AbortController();
932
+ // `direction` is recorded so the finally below can tell whether the request
933
+ // that replaced this one owns the same loading flag. See the note there.
934
+ inFlight = { controller, manual, direction };
935
+ const { signal: abortSignal } = controller;
596
936
 
597
937
  const loading = direction === 'next' ? isFetchingNextPage : isFetchingPreviousPage;
598
938
  loading.set(true);
939
+ // Only the first page is 'loading'. Paging through an existing list keeps
940
+ // status 'success' and reports itself through isFetchingNextPage, so a list
941
+ // does not blink back to a spinner every time it grows.
942
+ if (pages.peek().length === 0) status.set('loading');
943
+
944
+ let attempts = 0;
599
945
 
600
946
  try {
601
- const result = await queryFn({
602
- queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
603
- pageParam,
604
- signal: abortSignal,
605
- });
947
+ // Retry loop, with the same accounting as useQuery: `retry` is the total
948
+ // number of attempts, and the wait between them is abort-aware so an
949
+ // unmount cancels the pending retry instead of firing it into a dead
950
+ // component.
951
+ for (;;) {
952
+ try {
953
+ const result = await queryFn({
954
+ queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
955
+ pageParam,
956
+ signal: abortSignal,
957
+ });
606
958
 
607
- if (abortSignal.aborted) return;
959
+ if (abortSignal.aborted) return;
608
960
 
609
- batch(() => {
610
- if (isRefetching) {
611
- // Refetch: replace all pages with fresh first page (SWR pattern —
612
- // old pages stayed visible during fetch, now swap atomically)
613
- pages.set([result]);
614
- pageParams.set([pageParam]);
615
- isRefetching = false;
616
- } else if (direction === 'next') {
617
- pages.set([...pages.peek(), result]);
618
- pageParams.set([...pageParams.peek(), pageParam]);
619
- } else {
620
- pages.set([result, ...pages.peek()]);
621
- pageParams.set([pageParam, ...pageParams.peek()]);
622
- }
961
+ batch(() => {
962
+ if (replace) {
963
+ // Refetch: replace all pages with fresh first page (SWR pattern —
964
+ // old pages stayed visible during fetch, now swap atomically)
965
+ pages.set([result]);
966
+ pageParams.set([pageParam]);
967
+ } else if (direction === 'next') {
968
+ pages.set([...pages.peek(), result]);
969
+ pageParams.set([...pageParams.peek(), pageParam]);
970
+ } else {
971
+ pages.set([result, ...pages.peek()]);
972
+ pageParams.set([pageParam, ...pageParams.peek()]);
973
+ }
623
974
 
624
- const nextParam = getNextPageParam?.(result, pages.peek());
625
- hasNextPage.set(nextParam !== undefined);
975
+ const nextParam = getNextPageParam?.(result, pages.peek());
976
+ hasNextPage.set(nextParam !== undefined);
626
977
 
627
- if (getPreviousPageParam) {
628
- const prevParam = getPreviousPageParam(result, pages.peek());
629
- hasPreviousPage.set(prevParam !== undefined);
630
- }
631
- });
978
+ if (getPreviousPageParam) {
979
+ const prevParam = getPreviousPageParam(result, pages.peek());
980
+ hasPreviousPage.set(prevParam !== undefined);
981
+ }
632
982
 
633
- return result;
983
+ error.set(null);
984
+ status.set('success');
985
+ });
986
+
987
+ if (onSuccess) onSuccess(result);
988
+ if (onSettled) onSettled(result, null);
989
+
990
+ return result;
991
+ } catch (e) {
992
+ if (abortSignal.aborted) return;
993
+ attempts++;
994
+ if (attempts < retry) {
995
+ // Abort-aware retry delay: cancel the wait if the component unmounts
996
+ await new Promise((resolve, reject) => {
997
+ const id = unrefTimer(setTimeout(resolve, retryDelay(attempts)));
998
+ abortSignal.addEventListener('abort', () => {
999
+ clearTimeout(id);
1000
+ reject(new DOMException('Aborted', 'AbortError'));
1001
+ }, { once: true });
1002
+ }).catch((err) => { if (err.name === 'AbortError') return; throw err; });
1003
+ if (abortSignal.aborted) return;
1004
+ continue;
1005
+ }
1006
+
1007
+ batch(() => {
1008
+ error.set(e);
1009
+ status.set('error');
1010
+ });
1011
+
1012
+ if (onError) onError(e);
1013
+ if (onSettled) onSettled(null, e);
1014
+
1015
+ throw e;
1016
+ }
1017
+ }
634
1018
  } finally {
635
- if (!abortSignal.aborted) loading.set(false);
1019
+ // Clear this direction's loading flag unless a LIVE request in the SAME
1020
+ // direction has taken it over.
1021
+ //
1022
+ // `if (!abortSignal.aborted)` alone was too blunt. It exists so that an
1023
+ // aborted fetch cannot report "not fetching" over the top of the fetch
1024
+ // that replaced it, which is only a risk when the replacement uses the
1025
+ // same flag. Every other abort left the flag stuck true with nothing
1026
+ // alive behind it: fetchPreviousPage() over a fetchNextPage() still in
1027
+ // flight left isFetchingNextPage() true for the rest of the page's life,
1028
+ // and so isFetching() with it, which is a "loading more" spinner that
1029
+ // never comes down. The effect's cleanup abort (a gate flip, or the query
1030
+ // function's own signals moving) does the same for whichever direction it
1031
+ // interrupted.
1032
+ const successor = inFlight && inFlight.controller !== controller ? inFlight : null;
1033
+ if (!abortSignal.aborted || !successor || successor.direction !== direction) {
1034
+ loading.set(false);
1035
+ }
1036
+ // Release ownership, unless a newer fetch already superseded this one.
1037
+ if (inFlight && inFlight.controller === controller) inFlight = null;
636
1038
  }
637
1039
  }
638
1040
 
639
- // Initial fetch, abort on unmount
1041
+ // Explicit call from application code, so never gated by `enabled` (the same
1042
+ // rule as useQuery's refetch). `manual` distinguishes the public refetch()
1043
+ // from the invalidation subscriber below, which is automatic fetching aimed
1044
+ // at a key and is owned by the effect.
1045
+ function refetchAll({ manual = false } = {}) {
1046
+ // Keep old pages visible during refetch (SWR pattern).
1047
+ // fetchPage swaps them atomically when the data arrives.
1048
+ return fetchPage(initialPageParam, 'next', { replace: true, manual });
1049
+ }
1050
+
1051
+ const view = computed(() => {
1052
+ const raw = { pages: pages(), pageParams: pageParams() };
1053
+ return select ? select(raw) : raw;
1054
+ });
1055
+
1056
+ // Initial fetch, plus the invalidation subscription for this key. See the
1057
+ // matching comment in useQuery for why the subscription lives INSIDE the
1058
+ // effect.
640
1059
  scopedEffect(() => {
641
- fetchPage(initialPageParam).catch(() => {});
1060
+ // The normalized key was computed and then never used, so an infinite query
1061
+ // was invisible to invalidateQueries(). Refetching from the first page is
1062
+ // the only coherent answer for a list whose later pages may no longer exist
1063
+ // once the data behind it changed.
1064
+ const unsubscribe = subscribeToKey(key, () => {
1065
+ if (gate.peek()) refetchAll().catch(() => {});
1066
+ });
1067
+ const releaseHandler = registerClearCacheHandler(resetOnClearCache);
1068
+ // Tracked read of the MIRRORED gate: this effect re-runs when the gate
1069
+ // actually flips, not whenever some unrelated signal a gate thunk happens
1070
+ // to touch moves. See createGate.
1071
+ if (gate()) {
1072
+ // `replace`, because a re-run of this effect means an input to page one
1073
+ // changed (the query function read a signal that moved, or `enabled`
1074
+ // flipped). Appending in that case left a stale copy of page one sitting
1075
+ // above the fresh one, forever. On the first run the list is empty, so
1076
+ // replacing and appending are the same thing.
1077
+ fetchPage(initialPageParam, 'next', { replace: true }).catch(() => {});
1078
+ } else if (!inFlight?.manual) {
1079
+ // Turning it off settles it: the cleanup below aborted anything the
1080
+ // effect started, and an aborted page fetch returns without clearing its
1081
+ // own loading flag. A page an explicit call is still fetching is left
1082
+ // alone, and so is the status describing it.
1083
+ batch(() => {
1084
+ if (status.peek() === 'loading') status.set('idle');
1085
+ if (isFetchingNextPage.peek()) isFetchingNextPage.set(false);
1086
+ if (isFetchingPreviousPage.peek()) isFetchingPreviousPage.set(false);
1087
+ });
1088
+ }
642
1089
  return () => {
643
- if (abortController) abortController.abort();
1090
+ // Cancel only what this effect started; see the matching note in useQuery.
1091
+ if (inFlight && !inFlight.manual) {
1092
+ inFlight.controller.abort();
1093
+ inFlight = null;
1094
+ }
1095
+ unsubscribe();
1096
+ releaseHandler();
644
1097
  };
645
1098
  });
646
1099
 
1100
+ // Unmount cancels everything, including a page fetch the application asked
1101
+ // for that the effect's own cleanup spares.
1102
+ onComponentDispose(() => {
1103
+ if (inFlight) {
1104
+ inFlight.controller.abort();
1105
+ inFlight = null;
1106
+ }
1107
+ });
1108
+
647
1109
  return {
648
- data: () => ({ pages: pages(), pageParams: pageParams() }),
1110
+ data: () => view(),
1111
+ error: () => error(),
1112
+ status: () => status(),
1113
+ isLoading: () => status() === 'loading',
1114
+ isError: () => status() === 'error',
1115
+ isSuccess: () => status() === 'success',
1116
+ isIdle: () => status() === 'idle',
1117
+ isFetching: () => isFetchingNextPage() || isFetchingPreviousPage(),
1118
+ isEnabled: () => gate(),
649
1119
  hasNextPage: () => hasNextPage(),
650
1120
  hasPreviousPage: () => hasPreviousPage(),
651
1121
  isFetchingNextPage: () => isFetchingNextPage(),
652
1122
  isFetchingPreviousPage: () => isFetchingPreviousPage(),
1123
+ // "Load more" is an explicit call from application code, so it is `manual`
1124
+ // for the same reason refetch() is: a re-run of the effect must not cancel
1125
+ // a page the user asked for and hand the caller back `undefined`.
1126
+ //
1127
+ // An EMPTY page list is answered by fetching page one, and the user's
1128
+ // getNextPageParam/getPreviousPageParam is not consulted at all.
1129
+ //
1130
+ // Both of these are ungated by `enabled`, like refetch(), but a disabled
1131
+ // query's state is always the empty list, and the empty list had no last
1132
+ // page to derive a param from. So they called the user's callback with
1133
+ // `undefined`, and the documented callback shape
1134
+ // `(lastPage) => lastPage.nextCursor` throws on it -- while a defensive
1135
+ // `lastPage?.nextCursor` returns undefined and made the call a silent
1136
+ // no-op that fetched nothing. Either way refetch() was the only thing that
1137
+ // could load a disabled list, so "explicit calls run either way" was true
1138
+ // of one of the three. There is exactly one page it can mean here, and it
1139
+ // is the first one; asking a getNextPageParam what follows a page that does
1140
+ // not exist is not a question it can answer.
1141
+ //
1142
+ // `replace`, because this IS page one: it must land as the whole list, not
1143
+ // as an append onto whatever a racing call left behind.
653
1144
  fetchNextPage: async () => {
654
- const lastPage = pages.peek()[pages.peek().length - 1];
655
- const nextParam = getNextPageParam?.(lastPage, pages.peek());
1145
+ const loaded = pages.peek();
1146
+ if (loaded.length === 0) {
1147
+ return fetchPage(initialPageParam, 'next', { replace: true, manual: true });
1148
+ }
1149
+ const nextParam = getNextPageParam?.(loaded[loaded.length - 1], loaded);
656
1150
  if (nextParam !== undefined) {
657
- return fetchPage(nextParam, 'next');
1151
+ return fetchPage(nextParam, 'next', { manual: true });
658
1152
  }
659
1153
  },
660
1154
  fetchPreviousPage: async () => {
661
- const firstPage = pages.peek()[0];
662
- const prevParam = getPreviousPageParam?.(firstPage, pages.peek());
1155
+ const loaded = pages.peek();
1156
+ if (loaded.length === 0) {
1157
+ // Same reasoning, and the direction is kept so that a caller watching
1158
+ // isFetchingPreviousPage() still sees the request it made.
1159
+ return fetchPage(initialPageParam, 'previous', { replace: true, manual: true });
1160
+ }
1161
+ const prevParam = getPreviousPageParam?.(loaded[0], loaded);
663
1162
  if (prevParam !== undefined) {
664
- return fetchPage(prevParam, 'previous');
1163
+ return fetchPage(prevParam, 'previous', { manual: true });
665
1164
  }
666
1165
  },
667
- refetch: async () => {
668
- // Keep old pages visible during refetch (SWR pattern).
669
- // The fetchPage callback swaps them atomically when data arrives.
670
- isRefetching = true;
671
- return fetchPage(initialPageParam);
672
- },
1166
+ refetch: () => refetchAll({ manual: true }),
673
1167
  };
674
1168
  }
675
1169
 
@@ -688,7 +1182,12 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
688
1182
  const { hard = false, exact = false } = options;
689
1183
  const keysToInvalidate = [];
690
1184
  if (typeof keyOrPredicate === 'function') {
691
- for (const [key] of cacheSignals) {
1185
+ // allKnownKeys, not cacheSignals: a query that has subscribed but not yet
1186
+ // resolved has no cache entry, and an infinite query never gets one at all
1187
+ // (its pages are local), so iterating the data Map made a predicate blind
1188
+ // to exactly the queries an invalidation is aimed at. The prefix branch
1189
+ // below already looks in both places.
1190
+ for (const key of allKnownKeys()) {
692
1191
  if (keyOrPredicate(key)) keysToInvalidate.push(key);
693
1192
  }
694
1193
  } else if (Array.isArray(keyOrPredicate) && !exact) {
@@ -746,11 +1245,62 @@ export function getQueryData(key) {
746
1245
  return cacheSignals.has(key) ? cacheSignals.get(key).peek() : undefined;
747
1246
  }
748
1247
 
1248
+ // Empty the cache without detaching anything that is currently on screen.
1249
+ //
1250
+ // This used to .clear() the Maps. A mounted component captured its key's signal
1251
+ // OBJECTS when it mounted, so dropping the entries produced two failures at
1252
+ // once: the component kept displaying the old value (the signal it holds was
1253
+ // never reset, which is precisely wrong for the case clearCache exists for, a
1254
+ // logout), and the next getCacheSignal() for that key minted a FRESH signal, so
1255
+ // every later write (setQueryData, a sibling component's fetch, prefetchQuery)
1256
+ // landed somewhere the mounted component was not reading. That contradicts the
1257
+ // documented promise that components sharing a key share one set of signals.
1258
+ //
1259
+ // So: every key is EMPTIED IN PLACE, which is the only write that reaches what
1260
+ // is currently on screen. It is emptied to `undefined`, not `null`, so the
1261
+ // entry reads as ABSENT afterwards: getQueryData() answers `undefined` for a
1262
+ // cleared key whether or not the object had to be kept alive for a consumer.
1263
+ // That matters because "is anything still reading this?" is a guess, and it is
1264
+ // a wrong one for a hook created OUTSIDE a component (a module-scope store
1265
+ // query is never disposed, so its subscription is immortal); callers have no
1266
+ // business being able to tell those two cases apart.
1267
+ //
1268
+ // Dropping the entry is then purely about reclaiming memory, and the guess is
1269
+ // free to be wrong: a key whose emptied signals are kept reads exactly like one
1270
+ // whose entry went away.
1271
+ //
1272
+ // Deliberately NOT cleared: revalidationSubscribers. A subscriber is the live
1273
+ // wiring between a mounted component and its key; dropping it would deafen
1274
+ // every mounted query to invalidateQueries() forever, which is the same
1275
+ // detachment bug in the other channel. Subscriptions are owned by the effect
1276
+ // that created them and are released on unmount.
749
1277
  export function clearCache() {
750
- cacheSignals.clear();
751
- errorSignals.clear();
752
- validatingSignals.clear();
753
- cacheTimestamps.clear();
1278
+ batch(() => {
1279
+ for (const key of allKnownKeys()) {
1280
+ cacheSignals.get(key)?.set(undefined);
1281
+ errorSignals.get(key)?.set(null);
1282
+ validatingSignals.get(key)?.set(false);
1283
+
1284
+ const subs = revalidationSubscribers.get(key);
1285
+ if (subs !== undefined && subs.size > 0) {
1286
+ cacheTimestamps.set(key, Date.now());
1287
+ } else {
1288
+ cacheSignals.delete(key);
1289
+ errorSignals.delete(key);
1290
+ validatingSignals.delete(key);
1291
+ cacheTimestamps.delete(key);
1292
+ }
1293
+ }
1294
+
1295
+ // What walking the Maps above cannot reach: an infinite query's pages,
1296
+ // which live in signals local to the hook, and every hook's in-flight
1297
+ // REQUEST, which is not data at all but lands as data a moment later. See
1298
+ // registerClearCacheHandler.
1299
+ //
1300
+ // Last, and inside the batch: the handlers read the emptied signals to
1301
+ // decide what to settle to, so they must run after every key is emptied.
1302
+ for (const handler of clearCacheHandlers) handler();
1303
+ });
754
1304
  lastFetchTimestamps.clear();
755
1305
  inFlightRequests.clear();
756
1306
  keyEpochs.clear();