what-core 0.12.3 → 0.12.4

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