what-core 0.12.2 → 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,9 +87,78 @@ function subscribeToKey(key, revalidateFn) {
87
87
  };
88
88
  }
89
89
 
90
- const inFlightRequests = new Map(); // key -> { promise, timestamp, refCount }
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
+
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
+ // How many times each key has been invalidated. This orders invalidations
116
+ // against in-flight requests, which a wall clock cannot: Date.now() has 1ms
117
+ // resolution, so a refetch and an unrelated invalidation issued microseconds
118
+ // apart share a timestamp, and the invalidation would be answered by a request
119
+ // that predates the mutation. A counter has no ties.
120
+ const keyEpochs = new Map();
121
+
122
+ function currentEpoch(key) {
123
+ return keyEpochs.get(key) || 0;
124
+ }
125
+
126
+ function bumpEpoch(key) {
127
+ keyEpochs.set(key, currentEpoch(key) + 1);
128
+ }
129
+
130
+ // Every timer this module arms is housekeeping or polling. None of it is work
131
+ // worth keeping a Node process alive for, and on the server the usual owner of a
132
+ // timer (a component that unmounts) does not exist, so nothing ever clears them:
133
+ // an SSR render that touched one query pinned the event loop for minutes and kept
134
+ // firing the query function long after the HTML had been sent.
135
+ function unrefTimer(timer) {
136
+ if (timer && typeof timer.unref === 'function') timer.unref();
137
+ return timer;
138
+ }
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
+
93
162
  // Create an effect scoped to the current component's lifecycle.
94
163
  // When the component unmounts, the effect is automatically disposed.
95
164
  function scopedEffect(fn) {
@@ -99,6 +168,49 @@ function scopedEffect(fn) {
99
168
  return dispose;
100
169
  }
101
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
+
102
214
  // --- useFetch Hook ---
103
215
  // Simple fetch with automatic JSON parsing and error handling
104
216
 
@@ -175,7 +287,7 @@ export function useFetch(url, options = {}) {
175
287
  // --- useSWR Hook ---
176
288
  // Stale-while-revalidate pattern with caching
177
289
 
178
- export function useSWR(key, fetcher, options = {}) {
290
+ export function useSWR(rawKey, fetcher, options = {}) {
179
291
  const {
180
292
  revalidateOnFocus = true,
181
293
  revalidateOnReconnect = true,
@@ -189,7 +301,7 @@ export function useSWR(key, fetcher, options = {}) {
189
301
 
190
302
  // Support null/undefined/false key for conditional/dependent fetching
191
303
  // When key is falsy, don't fetch — return idle state
192
- if (key == null || key === false) {
304
+ if (rawKey == null || rawKey === false) {
193
305
  const data = signal(fallbackData || null);
194
306
  const error = signal(null);
195
307
  return {
@@ -202,23 +314,72 @@ export function useSWR(key, fetcher, options = {}) {
202
314
  };
203
315
  }
204
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
+
205
334
  // Shared reactive cache signals — all useSWR instances with the same key
206
335
  // read from these signals, so mutating from one component updates all others.
207
336
  const cacheS = getCacheSignal(key);
208
337
  const error = getErrorSignal(key);
209
338
  const isValidating = getValidatingSignal(key);
210
339
  const data = computed(() => cacheS() ?? fallbackData ?? null);
211
- 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
+ });
212
353
 
213
354
  let abortController = null;
214
355
 
215
- async function revalidate() {
356
+ // `force` is invalidateQueries(): "this data is wrong now". It bypasses the
357
+ // FRESHNESS window, which is the one caller that must never be answered from
358
+ // it (with the default 2s dedupingInterval an invalidation issued right after
359
+ // a fetch was silently swallowed and the stale data stayed on screen).
360
+ //
361
+ // It does NOT bypass request COALESCING, which is a different mechanism
362
+ // wearing a similar name. Every component reading a key subscribes
363
+ // separately, so one invalidateQueries() call fans out to N subscribers; the
364
+ // in-flight map is what collapses those back into one request. Skipping it
365
+ // opened N concurrent fetches of the same key whose responses then raced to
366
+ // write the cache.
367
+ //
368
+ // What force does change is WHICH in-flight request is acceptable. A response
369
+ // to a request that started before the data was invalidated is already stale,
370
+ // so it cannot answer the invalidation. One that started after it is a
371
+ // sibling subscriber's, and is exactly what we want to join.
372
+ async function revalidate({ force = false } = {}) {
216
373
  const now = Date.now();
374
+ const epoch = currentEpoch(key);
217
375
 
218
376
  // Deduplication: if there's already a request in flight, reuse it
219
377
  if (inFlightRequests.has(key)) {
220
378
  const existing = inFlightRequests.get(key);
221
- if (now - existing.timestamp < dedupingInterval) {
379
+ const usable = force
380
+ ? existing.epoch === epoch
381
+ : now - existing.timestamp < dedupingInterval;
382
+ if (usable) {
222
383
  existing.refCount++;
223
384
  return existing.promise;
224
385
  }
@@ -226,7 +387,7 @@ export function useSWR(key, fetcher, options = {}) {
226
387
 
227
388
  // Also deduplicate against recently completed fetches
228
389
  const lastFetch = lastFetchTimestamps.get(key);
229
- if (lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
390
+ if (!force && lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
230
391
  return cacheS.peek();
231
392
  }
232
393
 
@@ -242,8 +403,9 @@ export function useSWR(key, fetcher, options = {}) {
242
403
 
243
404
  isValidating.set(true);
244
405
 
245
- const promise = fetcher(key, { signal: abortSignal });
246
- inFlightRequests.set(key, { promise, timestamp: now, refCount: 1 });
406
+ // rawKey, not the normalized one: see the note where `key` is derived.
407
+ const promise = fetcher(rawKey, { signal: abortSignal });
408
+ inFlightRequests.set(key, { promise, timestamp: now, refCount: 1, epoch });
247
409
 
248
410
  try {
249
411
  const result = await promise;
@@ -254,12 +416,12 @@ export function useSWR(key, fetcher, options = {}) {
254
416
  });
255
417
  cacheTimestamps.set(key, Date.now());
256
418
  lastFetchTimestamps.set(key, Date.now());
257
- if (onSuccess) onSuccess(result, key);
419
+ if (onSuccess) onSuccess(result, rawKey);
258
420
  return result;
259
421
  } catch (e) {
260
422
  if (abortSignal.aborted) return;
261
423
  error.set(e);
262
- if (onError) onError(e, key);
424
+ if (onError) onError(e, rawKey);
263
425
  throw e;
264
426
  } finally {
265
427
  if (!abortSignal.aborted) isValidating.set(false);
@@ -271,16 +433,34 @@ export function useSWR(key, fetcher, options = {}) {
271
433
  }
272
434
  }
273
435
 
274
- // Subscribe to invalidation events for this key
275
- const unsubscribe = subscribeToKey(key, () => revalidate().catch(() => {}));
276
-
277
- // Initial fetch
436
+ // Initial fetch, plus the invalidation subscription for this key.
437
+ //
438
+ // The subscription lives INSIDE the effect. It used to be created once
439
+ // outside, while the effect's cleanup tore it down, and this effect re-runs
440
+ // whenever the fetcher reads a signal that changed. So the first reactive
441
+ // refetch unsubscribed the key and never resubscribed: from then on
442
+ // invalidateQueries() had no subscriber to call and silently did nothing.
443
+ // Re-subscribing per run keeps the two halves in the same lifecycle.
278
444
  scopedEffect(() => {
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
+ });
279
458
  revalidate().catch(() => {});
280
459
  // Cleanup: abort and unsubscribe on unmount
281
460
  return () => {
282
461
  if (abortController) abortController.abort();
283
462
  unsubscribe();
463
+ releaseHandler();
284
464
  };
285
465
  });
286
466
 
@@ -309,9 +489,9 @@ export function useSWR(key, fetcher, options = {}) {
309
489
  // Polling
310
490
  if (refreshInterval > 0) {
311
491
  scopedEffect(() => {
312
- const interval = setInterval(() => {
492
+ const interval = unrefTimer(setInterval(() => {
313
493
  revalidate().catch(() => {});
314
- }, refreshInterval);
494
+ }, refreshInterval));
315
495
  return () => clearInterval(interval);
316
496
  });
317
497
  }
@@ -355,36 +535,120 @@ export function useQuery(options) {
355
535
  } = options;
356
536
 
357
537
  const key = normalizeQueryKey(queryKey);
538
+ const gate = createGate(enabled);
358
539
 
359
540
  const cacheS = getCacheSignal(key);
360
541
  const data = computed(() => {
361
542
  const d = cacheS();
362
- 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;
363
547
  });
364
548
  const error = getErrorSignal(key);
365
- 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
+ );
366
559
  const fetchStatus = signal('idle');
367
560
 
368
- let lastFetchTime = 0;
369
- let abortController = null;
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
+ });
370
607
 
371
- async function fetchQuery() {
372
- if (!enabled) return;
608
+ let lastFetchTime = 0;
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;
616
+ let cleanupTimer = null;
617
+
618
+ // See the note on useSWR's revalidate: an invalidation must not be answered
619
+ // from the freshness window, or `invalidateQueries` becomes a no-op for every
620
+ // query with a staleTime.
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;
373
633
 
374
634
  // Check if data is still fresh
375
635
  const now = Date.now();
376
- if (cacheS.peek() != null && now - lastFetchTime < staleTime) {
636
+ if (!force && cacheS.peek() != null && now - lastFetchTime < staleTime) {
377
637
  return cacheS.peek();
378
638
  }
379
639
 
380
- // Abort previous request
381
- if (abortController) abortController.abort();
382
- abortController = new AbortController();
383
- 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;
384
648
 
385
649
  fetchStatus.set('fetching');
386
650
  if (cacheS.peek() == null) {
387
- status.set('loading');
651
+ rawStatus.set('loading');
388
652
  }
389
653
 
390
654
  let attempts = 0;
@@ -396,7 +660,7 @@ export function useQuery(options) {
396
660
  batch(() => {
397
661
  cacheS.set(result); // Updates all components reading this key
398
662
  error.set(null);
399
- status.set('success');
663
+ rawStatus.set('success');
400
664
  fetchStatus.set('idle');
401
665
  });
402
666
  lastFetchTime = Date.now();
@@ -405,8 +669,13 @@ export function useQuery(options) {
405
669
  if (onSuccess) onSuccess(result);
406
670
  if (onSettled) onSettled(result, null);
407
671
 
408
- // Schedule cache cleanup (only if no active subscribers)
409
- setTimeout(() => {
672
+ // Schedule cache cleanup (only if no active subscribers).
673
+ //
674
+ // Replaces the previous timer instead of arming another: every
675
+ // successful fetch used to add one and clear none, so a polling query
676
+ // accumulated pending Timeout objects at roughly (fetch rate x cacheTime).
677
+ if (cleanupTimer) clearTimeout(cleanupTimer);
678
+ cleanupTimer = unrefTimer(setTimeout(() => {
410
679
  if (Date.now() - lastFetchTime >= cacheTime) {
411
680
  const subs = revalidationSubscribers.get(key);
412
681
  if (!subs || subs.size === 0) {
@@ -417,7 +686,7 @@ export function useQuery(options) {
417
686
  lastFetchTimestamps.delete(key);
418
687
  }
419
688
  }
420
- }, cacheTime);
689
+ }, cacheTime));
421
690
 
422
691
  return result;
423
692
  } catch (e) {
@@ -426,7 +695,7 @@ export function useQuery(options) {
426
695
  if (attempts < retry) {
427
696
  // Abort-aware retry delay: cancel the wait if the component unmounts
428
697
  await new Promise((resolve, reject) => {
429
- const id = setTimeout(resolve, retryDelay(attempts));
698
+ const id = unrefTimer(setTimeout(resolve, retryDelay(attempts)));
430
699
  abortSignal.addEventListener('abort', () => {
431
700
  clearTimeout(id);
432
701
  reject(new DOMException('Aborted', 'AbortError'));
@@ -438,7 +707,7 @@ export function useQuery(options) {
438
707
 
439
708
  batch(() => {
440
709
  error.set(e);
441
- status.set('error');
710
+ rawStatus.set('error');
442
711
  fetchStatus.set('idle');
443
712
  });
444
713
 
@@ -449,23 +718,86 @@ export function useQuery(options) {
449
718
  }
450
719
  }
451
720
 
452
- 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
+ }
453
728
  }
454
729
 
455
- // Subscribe to invalidation events for this key
456
- const unsubscribe = subscribeToKey(key, () => fetchQuery().catch(() => {}));
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
+ });
754
+ }
457
755
 
458
- // Initial fetch
756
+ // Initial fetch, plus the invalidation subscription for this key.
757
+ // Subscribing inside the effect is deliberate: see the matching comment in
758
+ // useSWR above. A query whose queryFn reads a signal re-runs this effect, and
759
+ // a subscription created once outside it was cancelled by the first such
760
+ // re-run, leaving the query permanently deaf to invalidateQueries().
459
761
  scopedEffect(() => {
460
- if (enabled) {
762
+ const unsubscribe = subscribeToKey(key, () => fetchQuery({ force: true }).catch(() => {}));
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()) {
461
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
+ });
462
779
  }
463
780
  return () => {
464
- 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
+ }
465
787
  unsubscribe();
788
+ releaseHandler();
466
789
  };
467
790
  });
468
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
+
469
801
  // Refetch on focus
470
802
  if (refetchOnWindowFocus && typeof window !== 'undefined') {
471
803
  scopedEffect(() => {
@@ -482,9 +814,9 @@ export function useQuery(options) {
482
814
  // Polling
483
815
  if (refetchInterval) {
484
816
  scopedEffect(() => {
485
- const interval = setInterval(() => {
817
+ const interval = unrefTimer(setInterval(() => {
486
818
  fetchQuery().catch(() => {});
487
- }, refetchInterval);
819
+ }, refetchInterval));
488
820
  return () => clearInterval(interval);
489
821
  });
490
822
  }
@@ -497,14 +829,38 @@ export function useQuery(options) {
497
829
  isLoading: () => status() === 'loading',
498
830
  isError: () => status() === 'error',
499
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',
500
836
  isFetching: () => fetchStatus() === 'fetching',
501
- 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 }),
502
843
  };
503
844
  }
504
845
 
505
846
  // --- useInfiniteQuery Hook ---
506
847
  // For paginated/infinite scroll data
507
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.
508
864
  export function useInfiniteQuery(options) {
509
865
  const {
510
866
  queryKey,
@@ -512,103 +868,311 @@ export function useInfiniteQuery(options) {
512
868
  getNextPageParam,
513
869
  getPreviousPageParam,
514
870
  initialPageParam,
515
- ...rest
871
+ enabled = true,
872
+ select,
873
+ retry = 3,
874
+ retryDelay = (attempt) => Math.min(1000 * 2 ** attempt, 30000),
875
+ onSuccess,
876
+ onError,
877
+ onSettled,
516
878
  } = options;
517
879
 
880
+ const gate = createGate(enabled);
881
+
518
882
  const pages = signal([]);
519
- 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([]);
520
888
  const hasNextPage = signal(true);
521
889
  const hasPreviousPage = signal(false);
522
890
  const isFetchingNextPage = signal(false);
523
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');
524
902
 
525
903
  const key = normalizeQueryKey(queryKey);
526
- let abortController = null;
527
-
528
- 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
+ }
529
932
 
530
- async function fetchPage(pageParam, direction = 'next') {
531
- // Abort previous page fetch
532
- if (abortController) abortController.abort();
533
- abortController = new AbortController();
534
- 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;
535
945
 
536
946
  const loading = direction === 'next' ? isFetchingNextPage : isFetchingPreviousPage;
537
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;
538
954
 
539
955
  try {
540
- const result = await queryFn({
541
- queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
542
- pageParam,
543
- signal: abortSignal,
544
- });
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
+ });
545
967
 
546
- if (abortSignal.aborted) return;
968
+ if (abortSignal.aborted) return;
547
969
 
548
- batch(() => {
549
- if (isRefetching) {
550
- // Refetch: replace all pages with fresh first page (SWR pattern —
551
- // old pages stayed visible during fetch, now swap atomically)
552
- pages.set([result]);
553
- pageParams.set([pageParam]);
554
- isRefetching = false;
555
- } else if (direction === 'next') {
556
- pages.set([...pages.peek(), result]);
557
- pageParams.set([...pageParams.peek(), pageParam]);
558
- } else {
559
- pages.set([result, ...pages.peek()]);
560
- pageParams.set([pageParam, ...pageParams.peek()]);
561
- }
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
+ }
562
983
 
563
- const nextParam = getNextPageParam?.(result, pages.peek());
564
- hasNextPage.set(nextParam !== undefined);
984
+ const nextParam = getNextPageParam?.(result, pages.peek());
985
+ hasNextPage.set(nextParam !== undefined);
565
986
 
566
- if (getPreviousPageParam) {
567
- const prevParam = getPreviousPageParam(result, pages.peek());
568
- hasPreviousPage.set(prevParam !== undefined);
569
- }
570
- });
987
+ if (getPreviousPageParam) {
988
+ const prevParam = getPreviousPageParam(result, pages.peek());
989
+ hasPreviousPage.set(prevParam !== undefined);
990
+ }
571
991
 
572
- 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
+ }
573
1027
  } finally {
574
- 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;
575
1047
  }
576
1048
  }
577
1049
 
578
- // 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.
579
1068
  scopedEffect(() => {
580
- 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
+ }
581
1098
  return () => {
582
- 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();
583
1106
  };
584
1107
  });
585
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
+
586
1118
  return {
587
- 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(),
588
1128
  hasNextPage: () => hasNextPage(),
589
1129
  hasPreviousPage: () => hasPreviousPage(),
590
1130
  isFetchingNextPage: () => isFetchingNextPage(),
591
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.
592
1153
  fetchNextPage: async () => {
593
- const lastPage = pages.peek()[pages.peek().length - 1];
594
- 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);
595
1159
  if (nextParam !== undefined) {
596
- return fetchPage(nextParam, 'next');
1160
+ return fetchPage(nextParam, 'next', { manual: true });
597
1161
  }
598
1162
  },
599
1163
  fetchPreviousPage: async () => {
600
- const firstPage = pages.peek()[0];
601
- 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);
602
1171
  if (prevParam !== undefined) {
603
- return fetchPage(prevParam, 'previous');
1172
+ return fetchPage(prevParam, 'previous', { manual: true });
604
1173
  }
605
1174
  },
606
- refetch: async () => {
607
- // Keep old pages visible during refetch (SWR pattern).
608
- // The fetchPage callback swaps them atomically when data arrives.
609
- isRefetching = true;
610
- return fetchPage(initialPageParam);
611
- },
1175
+ refetch: () => refetchAll({ manual: true }),
612
1176
  };
613
1177
  }
614
1178
 
@@ -627,7 +1191,12 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
627
1191
  const { hard = false, exact = false } = options;
628
1192
  const keysToInvalidate = [];
629
1193
  if (typeof keyOrPredicate === 'function') {
630
- 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()) {
631
1200
  if (keyOrPredicate(key)) keysToInvalidate.push(key);
632
1201
  }
633
1202
  } else if (Array.isArray(keyOrPredicate) && !exact) {
@@ -647,6 +1216,10 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
647
1216
  }
648
1217
 
649
1218
  for (const key of keysToInvalidate) {
1219
+ // Before notifying anyone: every subscriber woken below reads this epoch, so
1220
+ // they agree on one refetch, and any request already in flight is now a
1221
+ // generation behind and cannot answer for them.
1222
+ bumpEpoch(key);
650
1223
  // Hard invalidation clears data immediately (shows loading state)
651
1224
  // Soft invalidation (default) keeps stale data visible during re-fetch (SWR pattern)
652
1225
  if (hard && cacheSignals.has(key)) cacheSignals.get(key).set(null);
@@ -681,13 +1254,65 @@ export function getQueryData(key) {
681
1254
  return cacheSignals.has(key) ? cacheSignals.get(key).peek() : undefined;
682
1255
  }
683
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.
684
1286
  export function clearCache() {
685
- cacheSignals.clear();
686
- errorSignals.clear();
687
- validatingSignals.clear();
688
- 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
+ });
689
1313
  lastFetchTimestamps.clear();
690
1314
  inFlightRequests.clear();
1315
+ keyEpochs.clear();
691
1316
  }
692
1317
 
693
1318
  /**