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/dist/{chunk-VTPLA4AS.min.js → chunk-M5GDJRVX.min.js} +1 -1
- package/dist/chunk-T2SKNKT5.min.js +11 -0
- package/dist/index.min.js +5 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +329 -40
- package/package.json +1 -1
- package/src/a11y.js +234 -23
- package/src/agent-context.js +1 -1
- package/src/animation.js +8 -0
- package/src/data.js +642 -83
- package/src/errors.js +12 -1
- package/src/form.js +329 -31
- package/src/hooks.js +20 -3
- package/src/render.js +491 -22
- package/src/scheduler.js +17 -0
- package/src/warnings.js +83 -0
- package/dist/chunk-JVEPLFIB.min.js +0 -11
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
968
|
+
if (abortSignal.aborted) return;
|
|
608
969
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
625
|
-
|
|
984
|
+
const nextParam = getNextPageParam?.(result, pages.peek());
|
|
985
|
+
hasNextPage.set(nextParam !== undefined);
|
|
626
986
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
});
|
|
987
|
+
if (getPreviousPageParam) {
|
|
988
|
+
const prevParam = getPreviousPageParam(result, pages.peek());
|
|
989
|
+
hasPreviousPage.set(prevParam !== undefined);
|
|
990
|
+
}
|
|
632
991
|
|
|
633
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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: () => (
|
|
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
|
|
655
|
-
|
|
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
|
|
662
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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();
|