From e5c9fca7cd02e2ef8859be45f5809cf9a517d904 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 22:30:16 +0000 Subject: [PATCH 01/10] fix(query-core): retry a hydrated query that rejects with its current 'retry' options A query hydrated while pending resumes the dehydrated promise through a retryer created at hydration time, before any observer has set the query's options. Its retries used the 'retry' and 'retryDelay' the query had then, so an observer's 'retry: false' or 'retryDelay' was ignored. Read 'retry' and 'retryDelay' from the query's options at the time of each failure for fetches that resume an 'initialPromise'. Other fetches keep the options they started with. Co-authored-by: alexander --- .changeset/hydrated-rejection-retry.md | 5 + .../src/__tests__/hydration.test.tsx | 140 ++++++++++++++++++ .../query-core/src/__tests__/query.test.tsx | 25 ++++ packages/query-core/src/query.ts | 17 ++- 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 .changeset/hydrated-rejection-retry.md diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md new file mode 100644 index 00000000000..209a7467cf0 --- /dev/null +++ b/.changeset/hydrated-rejection-retry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Retry a query that was hydrated while pending and then rejects with the `retry` and `retryDelay` options that are current when it fails. An observer that mounts before the hydrated promise settles now decides whether and when it is retried, instead of the options the query had when it was hydrated. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index ee037a11afe..0fe01f61b9d 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -5,6 +5,7 @@ import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' import { dehydrateQuery } from '../index' import { MutationCache } from '../mutationCache' +import { QueryObserver } from '../queryObserver' import { noop } from '../utils' import { executeMutation, mockOnlineManagerIsOnline } from './utils' import type { MutationFunction, MutationFunctionContext } from '../index' @@ -1096,6 +1097,145 @@ describe('dehydration and rehydration', () => { }) }) + describe('a hydrated promise that rejects', () => { + function dehydrateRejectingQuery(key: Array) { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: () => true, + shouldRedactErrors: () => false, + }, + }, + }) + void serverClient + .query({ + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('server error'))), + retry: false, + }) + .catch(noop) + return dehydrate(serverClient) + } + + it('should retry with the retry options of an observer that mounted before it rejected', async () => { + const key = createKey() + const dehydrated = dehydrateRejectingQuery(key) + + const queryClient = new QueryClient() + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: 'client data', + fetchStatus: 'idle', + }) + unsubscribe() + }) + + it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { + const key = createKey() + const dehydrated = dehydrateRejectingQuery(key) + + const queryClient = new QueryClient() + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: false, + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + error: new Error('server error'), + fetchStatus: 'idle', + }) + expect(queryFn).not.toHaveBeenCalled() + unsubscribe() + }) + + it('should keep the retry options it was hydrated with that an observer does not set', async () => { + const key = createKey() + const dehydrated = dehydrateRejectingQuery(key) + + const queryClient = new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + const unsubscribe = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }).subscribe(noop) + + await vi.advanceTimersByTimeAsync(15) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: 'client data', + }) + unsubscribe() + }) + + it('should retry with the options it was hydrated with when no observer has mounted', async () => { + const key = createKey() + const dehydrated = dehydrateRejectingQuery(key) + + const queryClient = new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }) + hydrate(queryClient, dehydrated) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + error: new Error('server error'), + fetchFailureCount: 2, + fetchStatus: 'idle', + }) + }) + }) + it('should transform promise result', async () => { const key = createKey() const queryClient = new QueryClient({ diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 4e1fc8dccdf..df596c15f4b 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -114,6 +114,31 @@ describe('query', () => { expect(result).toBe('data3') }) + it('should keep the retry options a fetch started with when the options change before a retry', async () => { + const key = createKey() + const queryFn = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('error')) + .mockResolvedValue('data') + + void queryClient + .query({ queryKey: key, queryFn, retry: 1, retryDelay: 50 }) + .catch(noop) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(1) + + queryCache + .find({ queryKey: key })! + .setOptions({ queryKey: key, queryFn, retry: 1, retryDelay: 5 }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(45) + expect(queryFn).toHaveBeenCalledTimes(2) + expect(queryClient.getQueryData(key)).toBe('data') + }) + it('should continue retry after reconnect and resolve all promises', async () => { const key = createKey() diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index af9a81baebd..e2646dbb308 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -829,6 +829,15 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) + // A fetch that resumes a hydrated promise starts before any observer has + // set this query's options, so an observer's retry options set by the time + // of a failure win over the ones present at hydration + const getRetryOption = ( + key: TKey, + ): QueryOptions[TKey] => + (fetchOptions?.initialPromise ? this.options[key] : undefined) ?? + context.options[key] + // Store state in case the current fetch needs to be reverted this.#revertState = this.state @@ -864,8 +873,12 @@ export class Query< onContinue: () => { this.#dispatch({ type: 'continue' }) }, - retry: context.options.retry, - retryDelay: context.options.retryDelay, + get retry() { + return getRetryOption('retry') + }, + get retryDelay() { + return getRetryOption('retryDelay') + }, networkMode: context.options.networkMode, canRun: () => true, })) From c60c7122f519235a8718060cb1ab0baa0b0550c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 23:47:04 +0000 Subject: [PATCH 02/10] test(query-core): dispose the clients of the hydrated rejection tests with 'using' Co-authored-by: alexander --- .../src/__tests__/hydration.test.tsx | 46 +++++++++++-------- .../query-test-utils/src/clearOnDispose.ts | 20 ++++++++ packages/query-test-utils/src/index.ts | 1 + 3 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 packages/query-test-utils/src/clearOnDispose.ts diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 0fe01f61b9d..f3268e50060 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createKey, sleep } from '@tanstack/query-test-utils' +import { clearOnDispose, createKey, sleep } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' @@ -1098,7 +1098,7 @@ describe('dehydration and rehydration', () => { }) describe('a hydrated promise that rejects', () => { - function dehydrateRejectingQuery(key: Array) { + function startRejectingQuery(key: Array) { const serverClient = new QueryClient({ defaultOptions: { dehydrate: { @@ -1115,14 +1115,15 @@ describe('dehydration and rehydration', () => { retry: false, }) .catch(noop) - return dehydrate(serverClient) + return clearOnDispose(serverClient) } it('should retry with the retry options of an observer that mounted before it rejected', async () => { const key = createKey() - const dehydrated = dehydrateRejectingQuery(key) + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) - const queryClient = new QueryClient() + using queryClient = clearOnDispose(new QueryClient()) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), @@ -1156,9 +1157,10 @@ describe('dehydration and rehydration', () => { it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { const key = createKey() - const dehydrated = dehydrateRejectingQuery(key) + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) - const queryClient = new QueryClient() + using queryClient = clearOnDispose(new QueryClient()) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), @@ -1182,13 +1184,16 @@ describe('dehydration and rehydration', () => { it('should keep the retry options it was hydrated with that an observer does not set', async () => { const key = createKey() - const dehydrated = dehydrateRejectingQuery(key) + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) - const queryClient = new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }) + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }), + ) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), @@ -1211,13 +1216,16 @@ describe('dehydration and rehydration', () => { it('should retry with the options it was hydrated with when no observer has mounted', async () => { const key = createKey() - const dehydrated = dehydrateRejectingQuery(key) + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) - const queryClient = new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }) + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }), + ) hydrate(queryClient, dehydrated) await vi.advanceTimersByTimeAsync(10) diff --git a/packages/query-test-utils/src/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts new file mode 100644 index 00000000000..8e2eb4c3019 --- /dev/null +++ b/packages/query-test-utils/src/clearOnDispose.ts @@ -0,0 +1,20 @@ +/** + * Makes a client, like a `QueryClient`, clear its caches when it is disposed, for a test to hold + * with `using`. + * @param client - The client to clear on dispose. + * @returns The same client, disposable. + * @example + * ```ts + * it('fetches', async () => { + * using queryClient = clearOnDispose(new QueryClient()) + * }) + * ``` + */ +export const clearOnDispose = void }>( + client: TClient, +): TClient & Disposable => + Object.assign(client, { + [Symbol.dispose]: () => { + client.clear() + }, + }) diff --git a/packages/query-test-utils/src/index.ts b/packages/query-test-utils/src/index.ts index 6366858002e..567404a1ea7 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -1,3 +1,4 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' +export { clearOnDispose } from './clearOnDispose' From 2f740eeade080f167beb1528e1b79ad2d43294fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 00:55:17 +0000 Subject: [PATCH 03/10] test(query-core): unsubscribe the hydrated rejection tests' observers with 'using' Adds a 'subscribe' helper to @tanstack/query-test-utils that returns a Disposable, and a test for it and for 'clearOnDispose'. Co-authored-by: alexander --- .../src/__tests__/hydration.test.tsx | 48 +++++++++++-------- .../src/__tests__/clearOnDispose.test.ts | 14 ++++++ .../src/__tests__/subscribe.test.ts | 22 +++++++++ packages/query-test-utils/src/index.ts | 1 + packages/query-test-utils/src/subscribe.ts | 24 ++++++++++ 5 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 packages/query-test-utils/src/__tests__/clearOnDispose.test.ts create mode 100644 packages/query-test-utils/src/__tests__/subscribe.test.ts create mode 100644 packages/query-test-utils/src/subscribe.ts diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index f3268e50060..fe36a73a7eb 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { clearOnDispose, createKey, sleep } from '@tanstack/query-test-utils' +import { + clearOnDispose, + createKey, + sleep, + subscribe, +} from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' @@ -1128,13 +1133,15 @@ describe('dehydration and rehydration', () => { const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }) - const unsubscribe = observer.subscribe(noop) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), + noop, + ) await vi.advanceTimersByTimeAsync(10) expect(queryFn).not.toHaveBeenCalled() @@ -1152,7 +1159,6 @@ describe('dehydration and rehydration', () => { data: 'client data', fetchStatus: 'idle', }) - unsubscribe() }) it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { @@ -1165,12 +1171,14 @@ describe('dehydration and rehydration', () => { const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: false, - }) - const unsubscribe = observer.subscribe(noop) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: false, + }), + noop, + ) await vi.advanceTimersByTimeAsync(10) expect(queryClient.getQueryState(key)).toMatchObject({ @@ -1179,7 +1187,6 @@ describe('dehydration and rehydration', () => { fetchStatus: 'idle', }) expect(queryFn).not.toHaveBeenCalled() - unsubscribe() }) it('should keep the retry options it was hydrated with that an observer does not set', async () => { @@ -1198,10 +1205,10 @@ describe('dehydration and rehydration', () => { const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - const unsubscribe = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - }).subscribe(noop) + using _subscription = subscribe( + new QueryObserver(queryClient, { queryKey: key, queryFn }), + noop, + ) await vi.advanceTimersByTimeAsync(15) expect(queryFn).toHaveBeenCalledTimes(1) @@ -1211,7 +1218,6 @@ describe('dehydration and rehydration', () => { status: 'success', data: 'client data', }) - unsubscribe() }) it('should retry with the options it was hydrated with when no observer has mounted', async () => { diff --git a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts new file mode 100644 index 00000000000..beeb1557670 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' +import { clearOnDispose } from '../clearOnDispose' + +describe('clearOnDispose', () => { + it('should clear the client when it is disposed', () => { + const clear = vi.fn<() => void>() + { + using client = clearOnDispose({ clear }) + expect(client.clear).toBe(clear) + expect(clear).not.toHaveBeenCalled() + } + expect(clear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/query-test-utils/src/__tests__/subscribe.test.ts b/packages/query-test-utils/src/__tests__/subscribe.test.ts new file mode 100644 index 00000000000..d815c1e4960 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/subscribe.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribe } from '../subscribe' + +describe('subscribe', () => { + it('should keep the listener subscribed until it is disposed', () => { + const listeners = new Set<() => void>() + const subscribable = { + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } + const listener = vi.fn<() => void>() + { + using _subscription = subscribe(subscribable, listener) + expect(listeners).toStrictEqual(new Set([listener])) + } + expect(listeners.size).toBe(0) + }) +}) diff --git a/packages/query-test-utils/src/index.ts b/packages/query-test-utils/src/index.ts index 567404a1ea7..cabb3e34cbe 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -2,3 +2,4 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' export { clearOnDispose } from './clearOnDispose' +export { subscribe } from './subscribe' diff --git a/packages/query-test-utils/src/subscribe.ts b/packages/query-test-utils/src/subscribe.ts new file mode 100644 index 00000000000..4df5d887d88 --- /dev/null +++ b/packages/query-test-utils/src/subscribe.ts @@ -0,0 +1,24 @@ +/** + * Subscribes a listener, like a `QueryObserver`'s, until the returned object is disposed, for a + * test to hold with `using`. + * @param subscribable - What to subscribe to, like a `QueryObserver` or a `QueryCache`. + * @param listener - The listener to subscribe. + * @returns A disposable that unsubscribes the listener. + * @example + * ```ts + * it('fetches', () => { + * using _subscription = subscribe(new QueryObserver(queryClient, options), noop) + * }) + * ``` + */ +export const subscribe = ( + subscribable: { subscribe: (listener: TListener) => () => void }, + listener: NoInfer, +): Disposable => { + const unsubscribe = subscribable.subscribe(listener) + return { + [Symbol.dispose]: () => { + unsubscribe() + }, + } +} From e95e117c51af230b7b28d768d9ef1bcbe8c639a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:05:09 +0000 Subject: [PATCH 04/10] docs(query-core): use plainer words in the hydrated retry copy Co-authored-by: alexander --- .changeset/hydrated-rejection-retry.md | 2 +- packages/query-core/src/__tests__/hydration.test.tsx | 4 ++-- packages/query-core/src/query.ts | 6 +++--- .../query-test-utils/src/__tests__/clearOnDispose.test.ts | 2 +- packages/query-test-utils/src/__tests__/subscribe.test.ts | 2 +- packages/query-test-utils/src/clearOnDispose.ts | 5 ++--- packages/query-test-utils/src/subscribe.ts | 3 +-- 7 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md index 209a7467cf0..2c49420ede3 100644 --- a/.changeset/hydrated-rejection-retry.md +++ b/.changeset/hydrated-rejection-retry.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -Retry a query that was hydrated while pending and then rejects with the `retry` and `retryDelay` options that are current when it fails. An observer that mounts before the hydrated promise settles now decides whether and when it is retried, instead of the options the query had when it was hydrated. +When a query that `hydrate` resumed while pending rejects, its retries now use the `retry` and `retryDelay` options the query has at that moment. An observer that mounts before the promise settles now decides whether and when the query retries, not the options from hydration. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index fe36a73a7eb..ec37bf208d6 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1189,7 +1189,7 @@ describe('dehydration and rehydration', () => { expect(queryFn).not.toHaveBeenCalled() }) - it('should keep the retry options it was hydrated with that an observer does not set', async () => { + it('should keep the hydration retry options that an observer does not set', async () => { const key = createKey() using serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) @@ -1220,7 +1220,7 @@ describe('dehydration and rehydration', () => { }) }) - it('should retry with the options it was hydrated with when no observer has mounted', async () => { + it('should retry with the hydration options when no observer has mounted', async () => { const key = createKey() using serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index e2646dbb308..cf2dc99d8a4 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -829,9 +829,9 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) - // A fetch that resumes a hydrated promise starts before any observer has - // set this query's options, so an observer's retry options set by the time - // of a failure win over the ones present at hydration + // A fetch that resumes a hydrated promise starts before any observer sets + // this query's options, so retry options an observer sets before a failure + // win over those from hydration const getRetryOption = ( key: TKey, ): QueryOptions[TKey] => diff --git a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts index beeb1557670..c50b7ed1d89 100644 --- a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts +++ b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { clearOnDispose } from '../clearOnDispose' describe('clearOnDispose', () => { - it('should clear the client when it is disposed', () => { + it('should clear the client on dispose', () => { const clear = vi.fn<() => void>() { using client = clearOnDispose({ clear }) diff --git a/packages/query-test-utils/src/__tests__/subscribe.test.ts b/packages/query-test-utils/src/__tests__/subscribe.test.ts index d815c1e4960..ffafc471705 100644 --- a/packages/query-test-utils/src/__tests__/subscribe.test.ts +++ b/packages/query-test-utils/src/__tests__/subscribe.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { subscribe } from '../subscribe' describe('subscribe', () => { - it('should keep the listener subscribed until it is disposed', () => { + it('should unsubscribe the listener on dispose', () => { const listeners = new Set<() => void>() const subscribable = { subscribe: (listener: () => void) => { diff --git a/packages/query-test-utils/src/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts index 8e2eb4c3019..9bca266bff4 100644 --- a/packages/query-test-utils/src/clearOnDispose.ts +++ b/packages/query-test-utils/src/clearOnDispose.ts @@ -1,7 +1,6 @@ /** - * Makes a client, like a `QueryClient`, clear its caches when it is disposed, for a test to hold - * with `using`. - * @param client - The client to clear on dispose. + * Clears a client, like a `QueryClient`, when a test's `using` declaration disposes it. + * @param client - The client to clear. * @returns The same client, disposable. * @example * ```ts diff --git a/packages/query-test-utils/src/subscribe.ts b/packages/query-test-utils/src/subscribe.ts index 4df5d887d88..63057dfeebe 100644 --- a/packages/query-test-utils/src/subscribe.ts +++ b/packages/query-test-utils/src/subscribe.ts @@ -1,6 +1,5 @@ /** - * Subscribes a listener, like a `QueryObserver`'s, until the returned object is disposed, for a - * test to hold with `using`. + * Subscribes a listener until a test's `using` declaration disposes the result. * @param subscribable - What to subscribe to, like a `QueryObserver` or a `QueryCache`. * @param listener - The listener to subscribe. * @returns A disposable that unsubscribes the listener. From 684374aa58cbf9b7f9f599a81edbc6cee5262675 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:23:26 +0000 Subject: [PATCH 05/10] fix(query-core): retry a hydrated infinite query with its current options Co-authored-by: alexander --- .changeset/hydrated-rejection-retry.md | 2 +- .../src/__tests__/hydration.test.tsx | 106 ++++++++++++++++++ .../query-core/src/__tests__/query.test.tsx | 11 +- .../query-core/src/infiniteQueryBehavior.ts | 13 ++- packages/query-core/src/query.ts | 5 +- .../src/__tests__/fakeTimers.test.ts | 12 ++ packages/query-test-utils/src/fakeTimers.ts | 21 ++++ packages/query-test-utils/src/index.ts | 1 + 8 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 packages/query-test-utils/src/__tests__/fakeTimers.test.ts create mode 100644 packages/query-test-utils/src/fakeTimers.ts diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md index 2c49420ede3..ebc0348b3e9 100644 --- a/.changeset/hydrated-rejection-retry.md +++ b/.changeset/hydrated-rejection-retry.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -When a query that `hydrate` resumed while pending rejects, its retries now use the `retry` and `retryDelay` options the query has at that moment. An observer that mounts before the promise settles now decides whether and when the query retries, not the options from hydration. +When a query hydrated while pending rejects, it now retries with the `retry` and `retryDelay` options it has when it fails. An observer that mounts before the promise settles now decides whether and when the query retries. For an infinite query, the retry also runs that observer's `queryFn` from the first page, instead of replaying the rejected promise. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index ec37bf208d6..29a74bf607e 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearOnDispose, createKey, + fakeTimers, sleep, subscribe, } from '@tanstack/query-test-utils' @@ -10,6 +11,7 @@ import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' import { dehydrateQuery } from '../index' import { MutationCache } from '../mutationCache' +import { InfiniteQueryObserver } from '../infiniteQueryObserver' import { QueryObserver } from '../queryObserver' import { noop } from '../utils' import { executeMutation, mockOnlineManagerIsOnline } from './utils' @@ -1123,7 +1125,30 @@ describe('dehydration and rehydration', () => { return clearOnDispose(serverClient) } + function startRejectingInfiniteQuery(key: Array) { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: () => true, + shouldRedactErrors: () => false, + }, + }, + }) + void serverClient + .infiniteQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('server error'))), + initialPageParam: 0, + getNextPageParam: () => undefined, + retry: false, + }) + .catch(noop) + return clearOnDispose(serverClient) + } + it('should retry with the retry options of an observer that mounted before it rejected', async () => { + using _timers = fakeTimers() const key = createKey() using serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) @@ -1162,6 +1187,7 @@ describe('dehydration and rehydration', () => { }) it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { + using _timers = fakeTimers() const key = createKey() using serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) @@ -1190,6 +1216,7 @@ describe('dehydration and rehydration', () => { }) it('should keep the hydration retry options that an observer does not set', async () => { + using _timers = fakeTimers() const key = createKey() using serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) @@ -1221,6 +1248,7 @@ describe('dehydration and rehydration', () => { }) it('should retry with the hydration options when no observer has mounted', async () => { + using _timers = fakeTimers() const key = createKey() using serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) @@ -1248,6 +1276,84 @@ describe('dehydration and rehydration', () => { fetchStatus: 'idle', }) }) + + it('should retry a hydrated infinite query with the queryFn of an observer that mounted before it rejected', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingInfiniteQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 0 } } }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn< + (context: { pageParam: number }) => Promise + >(({ pageParam }) => sleep(10).then(() => `page ${pageParam}`)) + using _subscription = subscribe( + new InfiniteQueryObserver(queryClient, { + queryKey: key, + queryFn, + initialPageParam: 0, + getNextPageParam: () => undefined, + retry: 1, + retryDelay: 5, + }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryFn).toHaveBeenCalledTimes(1) + expect(queryFn).toHaveBeenCalledWith( + expect.objectContaining({ pageParam: 0 }), + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: { pages: ['page 0'], pageParams: [0] }, + fetchStatus: 'idle', + }) + }) + + it('should retry a hydrated infinite query with the hydration options when no observer has mounted', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingInfiniteQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + error: new Error('server error'), + fetchFailureCount: 2, + fetchStatus: 'idle', + }) + }) }) it('should transform promise result', async () => { diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index df596c15f4b..f5851ff98bc 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + clearOnDispose, createKey, + fakeTimers, mockVisibilityState, sleep, } from '@tanstack/query-test-utils' @@ -115,19 +117,22 @@ describe('query', () => { }) it('should keep the retry options a fetch started with when the options change before a retry', async () => { + using _timers = fakeTimers() + using client = clearOnDispose(new QueryClient()) const key = createKey() const queryFn = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error('error')) .mockResolvedValue('data') - void queryClient + void client .query({ queryKey: key, queryFn, retry: 1, retryDelay: 50 }) .catch(noop) await vi.advanceTimersByTimeAsync(0) expect(queryFn).toHaveBeenCalledTimes(1) - queryCache + client + .getQueryCache() .find({ queryKey: key })! .setOptions({ queryKey: key, queryFn, retry: 1, retryDelay: 5 }) @@ -136,7 +141,7 @@ describe('query', () => { await vi.advanceTimersByTimeAsync(45) expect(queryFn).toHaveBeenCalledTimes(2) - expect(queryClient.getQueryData(key)).toBe('data') + expect(client.getQueryData(key)).toBe('data') }) it('should continue retry after reconnect and resolve all promises', async () => { diff --git a/packages/query-core/src/infiniteQueryBehavior.ts b/packages/query-core/src/infiniteQueryBehavior.ts index 20bc95ff225..944793de156 100644 --- a/packages/query-core/src/infiniteQueryBehavior.ts +++ b/packages/query-core/src/infiniteQueryBehavior.ts @@ -28,7 +28,6 @@ export function infiniteQueryBehavior( ): QueryBehavior> { return { onFetch: (context, query) => { - const options = context.options as InfiniteQueryPageParamsOptions const direction = context.fetchOptions?.meta?.fetchMore?.direction const oldPages = context.state.data?.pages || [] const oldPageParams = context.state.data?.pageParams || [] @@ -36,6 +35,12 @@ export function infiniteQueryBehavior( let currentPage = 0 const fetchFn = async () => { + // A fetch that resumes a hydrated promise only calls this to retry, and + // an observer may have set the query's options since the fetch started + const queryOptions = ( + context.fetchOptions?.initialPromise ? query.options : context.options + ) as typeof context.options + const options = queryOptions as InfiniteQueryPageParamsOptions let cancelled = false const signalContext: FetchSignalContext = context const addSignalProperty = (object: object) => { @@ -47,7 +52,7 @@ export function infiniteQueryBehavior( ) } - const queryFn = ensureQueryFn(context.options, context.fetchOptions) + const queryFn = ensureQueryFn(queryOptions, context.fetchOptions) // Create function to fetch a page const fetchPage = async ( @@ -72,7 +77,7 @@ export function infiniteQueryBehavior( queryKey: context.queryKey, pageParam: param, direction: previous ? 'backward' : 'forward', - meta: context.options.meta, + meta: queryOptions.meta, } addSignalProperty(queryFnContext) return queryFnContext as QueryFunctionContext @@ -82,7 +87,7 @@ export function infiniteQueryBehavior( const page = await queryFn(queryFnContext) - const { maxPages } = context.options + const { maxPages } = queryOptions const addTo = previous ? addToStart : addToEnd return { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index cf2dc99d8a4..27990032a49 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -829,9 +829,8 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) - // A fetch that resumes a hydrated promise starts before any observer sets - // this query's options, so retry options an observer sets before a failure - // win over those from hydration + // A hydrated promise starts fetching before any observer mounts. Read the + // retry options at each failure, so an observer that mounted since decides const getRetryOption = ( key: TKey, ): QueryOptions[TKey] => diff --git a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts new file mode 100644 index 00000000000..0bbf2f6090b --- /dev/null +++ b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest' +import { fakeTimers } from '../fakeTimers' + +describe('fakeTimers', () => { + it('should switch back to real timers on dispose', () => { + { + using _timers = fakeTimers() + expect(vi.isFakeTimers()).toBe(true) + } + expect(vi.isFakeTimers()).toBe(false) + }) +}) diff --git a/packages/query-test-utils/src/fakeTimers.ts b/packages/query-test-utils/src/fakeTimers.ts new file mode 100644 index 00000000000..5988b14d8b7 --- /dev/null +++ b/packages/query-test-utils/src/fakeTimers.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest' + +/** + * Switches to fake timers until a test's `using` declaration disposes the result. + * @returns A disposable that switches back to real timers. + * @example + * ```ts + * it('waits', async () => { + * using _timers = fakeTimers() + * await vi.advanceTimersByTimeAsync(10) + * }) + * ``` + */ +export const fakeTimers = (): Disposable => { + vi.useFakeTimers() + return { + [Symbol.dispose]: () => { + vi.useRealTimers() + }, + } +} diff --git a/packages/query-test-utils/src/index.ts b/packages/query-test-utils/src/index.ts index cabb3e34cbe..60f71375191 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -1,5 +1,6 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' +export { fakeTimers } from './fakeTimers' export { clearOnDispose } from './clearOnDispose' export { subscribe } from './subscribe' From eb1b7f6a91238e8dd4d635f92dad8c95d4a034c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:47:16 +0000 Subject: [PATCH 06/10] fix(query-core): only let retry options an observer set itself override hydration Client defaults and setQueryDefaults no longer count as set, so they don't override hydrate.queries.retry. Co-authored-by: alexander --- .changeset/hydrated-rejection-retry.md | 2 +- .../src/__tests__/hydration.test.tsx | 101 ++++++++++++++++++ packages/query-core/src/query.ts | 20 +++- packages/query-core/src/queryClient.ts | 2 + packages/query-core/src/utils.ts | 37 +++++++ 5 files changed, 156 insertions(+), 6 deletions(-) diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md index ebc0348b3e9..6d4d225fe86 100644 --- a/.changeset/hydrated-rejection-retry.md +++ b/.changeset/hydrated-rejection-retry.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -When a query hydrated while pending rejects, it now retries with the `retry` and `retryDelay` options it has when it fails. An observer that mounts before the promise settles now decides whether and when the query retries. For an infinite query, the retry also runs that observer's `queryFn` from the first page, instead of replaying the rejected promise. +When a query hydrated while pending rejects, it now retries with the `retry` and `retryDelay` an observer set, if one mounted before the promise settled. Options the observer doesn't set itself, including client defaults, keep the values the query was hydrated with. Once an observer has mounted, a hydrated infinite query's retries run its `queryFn` from the first page, instead of replaying the rejected promise. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 29a74bf607e..0cc4b43a680 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1247,6 +1247,107 @@ describe('dehydration and rehydration', () => { }) }) + it("should keep the hydration retry options over the client's query defaults", async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + queries: { retry: 2, retryDelay: 5 }, + hydrate: { queries: { retry: 0 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { queryKey: key, queryFn }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + fetchFailureCount: 1, + }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should keep the hydration retry options over setQueryDefaults', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 0 } } }, + }), + ) + queryClient.setQueryDefaults(key, { retry: 2, retryDelay: 5 }) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { queryKey: key, queryFn }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + fetchFailureCount: 1, + }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it("should retry with the retry options an observer sets itself over the client's query defaults", async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + queries: { retry: 0 }, + hydrate: { queries: { retry: 0 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver( + queryClient, + queryClient.defaultQueryOptions({ + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), + ), + noop, + ) + + await vi.advanceTimersByTimeAsync(15) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: 'client data', + }) + }) + it('should retry with the hydration options when no observer has mounted', async () => { using _timers = fakeTimers() const key = createKey() diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 27990032a49..544da6d1498 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -1,6 +1,7 @@ import { ensureQueryFn, fetchSignal, + getExplicitRetryOptions, noop, replaceData, resolveQueryValue, @@ -829,13 +830,22 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) - // A hydrated promise starts fetching before any observer mounts. Read the - // retry options at each failure, so an observer that mounted since decides + // A hydrated promise starts fetching before any observer mounts. At each + // failure, retry options an observer set itself win over those from + // hydration, and client defaults don't count as set const getRetryOption = ( key: TKey, - ): QueryOptions[TKey] => - (fetchOptions?.initialPromise ? this.options[key] : undefined) ?? - context.options[key] + ): QueryOptions[TKey] => { + if (fetchOptions?.initialPromise) { + for (const observer of this.observers) { + const value = getExplicitRetryOptions(observer.options)?.[key] + if (value !== undefined) { + return value + } + } + } + return context.options[key] + } // Store state in case the current fetch needs to be reverted this.#revertState = this.state diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index faa0b130ab2..390494bc0aa 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -5,6 +5,7 @@ import { noop, partialMatchKey, resolveQueryValue, + setExplicitRetryOptions, skipToken, } from './utils' import { QueryCache } from './queryCache' @@ -1102,6 +1103,7 @@ export class QueryClient { ...options, _defaulted: true, } + setExplicitRetryOptions(defaultedOptions, options) if (!defaultedOptions.queryHash) { defaultedOptions.queryHash = hashQueryKeyByOptions( diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 64e6a664ab5..f9252df56e0 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -629,6 +629,43 @@ export const skipToken = Symbol() */ export type SkipToken = typeof skipToken +type RetryOptions = Pick< + QueryOptions, + 'retry' | 'retryDelay' +> + +const explicitRetryOptions = new WeakMap() + +/** + * Remembers the `retry` and `retryDelay` a caller set itself, before client defaults filled them in. + * @internal + * @param defaultedOptions - The options `defaultQueryOptions` returns. + * @param options - The options the caller passed in. + */ +export function setExplicitRetryOptions( + defaultedOptions: object, + options: RetryOptions, +): void { + if (options.retry !== undefined || options.retryDelay !== undefined) { + explicitRetryOptions.set(defaultedOptions, { + retry: options.retry, + retryDelay: options.retryDelay, + }) + } +} + +/** + * Returns the `retry` and `retryDelay` a caller set itself on options from `defaultQueryOptions`. + * @internal + * @param defaultedOptions - The options `defaultQueryOptions` returned. + * @returns The options the caller set, or `undefined` if it set neither. + */ +export function getExplicitRetryOptions( + defaultedOptions: object, +): RetryOptions | undefined { + return explicitRetryOptions.get(defaultedOptions) +} + /** * Returns the function to call for a fetch. If there is no `queryFn` but the fetch was started from * an `initialPromise`, that promise is returned instead. If there is no usable `queryFn` (missing or From 9e4a941c205776ff100cec882f70ebfeb45461d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 19:54:01 +0000 Subject: [PATCH 07/10] test(query-core): resolve a fetch that joins a hydrated promise with the observer's retry Co-authored-by: alexander --- .../src/__tests__/hydration.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 0cc4b43a680..615c3ce52c8 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1186,6 +1186,34 @@ describe('dehydration and rehydration', () => { }) }) + it("should resolve a fetch that joins the hydrated promise with the data of the observer's retry", async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose(new QueryClient()) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), + noop, + ) + const joined = queryClient.query({ queryKey: key, queryFn }) + + await vi.advanceTimersByTimeAsync(25) + + await expect(joined).resolves.toBe('client data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { using _timers = fakeTimers() const key = createKey() From e09fc72a2b5bf59637b233a15e6219bbcf084dec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 15:20:26 +0000 Subject: [PATCH 08/10] test(query-core): write the hydrated retry tests like the tests around them Use the files' own fake timers and clean up with unsubscribe() and clear(), as the other tests do. Remove the clearOnDispose, fakeTimers and subscribe helpers from @tanstack/query-test-utils. Co-authored-by: alexander --- .../src/__tests__/hydration.test.tsx | 279 +++++++++--------- .../query-core/src/__tests__/query.test.tsx | 11 +- .../src/__tests__/clearOnDispose.test.ts | 14 - .../src/__tests__/fakeTimers.test.ts | 12 - .../src/__tests__/subscribe.test.ts | 22 -- .../query-test-utils/src/clearOnDispose.ts | 19 -- packages/query-test-utils/src/fakeTimers.ts | 21 -- packages/query-test-utils/src/index.ts | 3 - packages/query-test-utils/src/subscribe.ts | 23 -- 9 files changed, 143 insertions(+), 261 deletions(-) delete mode 100644 packages/query-test-utils/src/__tests__/clearOnDispose.test.ts delete mode 100644 packages/query-test-utils/src/__tests__/fakeTimers.test.ts delete mode 100644 packages/query-test-utils/src/__tests__/subscribe.test.ts delete mode 100644 packages/query-test-utils/src/clearOnDispose.ts delete mode 100644 packages/query-test-utils/src/fakeTimers.ts delete mode 100644 packages/query-test-utils/src/subscribe.ts diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 615c3ce52c8..a5faf906d91 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1,11 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - clearOnDispose, - createKey, - fakeTimers, - sleep, - subscribe, -} from '@tanstack/query-test-utils' +import { createKey, sleep } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' @@ -1122,7 +1116,7 @@ describe('dehydration and rehydration', () => { retry: false, }) .catch(noop) - return clearOnDispose(serverClient) + return serverClient } function startRejectingInfiniteQuery(key: Array) { @@ -1144,29 +1138,26 @@ describe('dehydration and rehydration', () => { retry: false, }) .catch(noop) - return clearOnDispose(serverClient) + return serverClient } it('should retry with the retry options of an observer that mounted before it rejected', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose(new QueryClient()) + const queryClient = new QueryClient() hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }), - noop, - ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(10) expect(queryFn).not.toHaveBeenCalled() @@ -1184,55 +1175,57 @@ describe('dehydration and rehydration', () => { data: 'client data', fetchStatus: 'idle', }) + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it("should resolve a fetch that joins the hydrated promise with the data of the observer's retry", async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose(new QueryClient()) + const queryClient = new QueryClient() hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }), - noop, - ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }) + const unsubscribe = observer.subscribe(noop) const joined = queryClient.query({ queryKey: key, queryFn }) await vi.advanceTimersByTimeAsync(25) await expect(joined).resolves.toBe('client data') expect(queryFn).toHaveBeenCalledTimes(1) + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose(new QueryClient()) + const queryClient = new QueryClient() hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: false, - }), - noop, - ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: false, + }) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(10) expect(queryClient.getQueryState(key)).toMatchObject({ @@ -1241,29 +1234,31 @@ describe('dehydration and rehydration', () => { fetchStatus: 'idle', }) expect(queryFn).not.toHaveBeenCalled() + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it('should keep the hydration retry options that an observer does not set', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver(queryClient, { queryKey: key, queryFn }), - noop, - ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(15) expect(queryFn).toHaveBeenCalledTimes(1) @@ -1273,30 +1268,32 @@ describe('dehydration and rehydration', () => { status: 'success', data: 'client data', }) + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it("should keep the hydration retry options over the client's query defaults", async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { - queries: { retry: 2, retryDelay: 5 }, - hydrate: { queries: { retry: 0 } }, - }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 2, retryDelay: 5 }, + hydrate: { queries: { retry: 0 } }, + }, + }) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver(queryClient, { queryKey: key, queryFn }), - noop, - ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(10) expect(queryClient.getQueryState(key)).toMatchObject({ @@ -1304,28 +1301,30 @@ describe('dehydration and rehydration', () => { fetchFailureCount: 1, }) expect(queryFn).not.toHaveBeenCalled() + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it('should keep the hydration retry options over setQueryDefaults', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { hydrate: { queries: { retry: 0 } } }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 0 } } }, + }) queryClient.setQueryDefaults(key, { retry: 2, retryDelay: 5 }) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver(queryClient, { queryKey: key, queryFn }), - noop, - ) + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(10) expect(queryClient.getQueryState(key)).toMatchObject({ @@ -1333,38 +1332,37 @@ describe('dehydration and rehydration', () => { fetchFailureCount: 1, }) expect(queryFn).not.toHaveBeenCalled() + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it("should retry with the retry options an observer sets itself over the client's query defaults", async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { - queries: { retry: 0 }, - hydrate: { queries: { retry: 0 } }, - }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 0 }, + hydrate: { queries: { retry: 0 } }, + }, + }) hydrate(queryClient, dehydrated) const queryFn = vi.fn<() => Promise>(() => sleep(10).then(() => 'client data'), ) - using _subscription = subscribe( - new QueryObserver( - queryClient, - queryClient.defaultQueryOptions({ - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }), - ), - noop, + const observer = new QueryObserver( + queryClient, + queryClient.defaultQueryOptions({ + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), ) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(15) expect(queryFn).toHaveBeenCalledTimes(1) @@ -1374,21 +1372,22 @@ describe('dehydration and rehydration', () => { status: 'success', data: 'client data', }) + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it('should retry with the hydration options when no observer has mounted', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingQuery(key) + const serverClient = startRejectingQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }) hydrate(queryClient, dehydrated) await vi.advanceTimersByTimeAsync(10) @@ -1404,34 +1403,32 @@ describe('dehydration and rehydration', () => { fetchFailureCount: 2, fetchStatus: 'idle', }) + + queryClient.clear() + serverClient.clear() }) it('should retry a hydrated infinite query with the queryFn of an observer that mounted before it rejected', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingInfiniteQuery(key) + const serverClient = startRejectingInfiniteQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { hydrate: { queries: { retry: 0 } } }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 0 } } }, + }) hydrate(queryClient, dehydrated) const queryFn = vi.fn< (context: { pageParam: number }) => Promise >(({ pageParam }) => sleep(10).then(() => `page ${pageParam}`)) - using _subscription = subscribe( - new InfiniteQueryObserver(queryClient, { - queryKey: key, - queryFn, - initialPageParam: 0, - getNextPageParam: () => undefined, - retry: 1, - retryDelay: 5, - }), - noop, - ) + const observer = new InfiniteQueryObserver(queryClient, { + queryKey: key, + queryFn, + initialPageParam: 0, + getNextPageParam: () => undefined, + retry: 1, + retryDelay: 5, + }) + const unsubscribe = observer.subscribe(noop) await vi.advanceTimersByTimeAsync(10) expect(queryFn).not.toHaveBeenCalled() @@ -1452,21 +1449,22 @@ describe('dehydration and rehydration', () => { data: { pages: ['page 0'], pageParams: [0] }, fetchStatus: 'idle', }) + + unsubscribe() + queryClient.clear() + serverClient.clear() }) it('should retry a hydrated infinite query with the hydration options when no observer has mounted', async () => { - using _timers = fakeTimers() const key = createKey() - using serverClient = startRejectingInfiniteQuery(key) + const serverClient = startRejectingInfiniteQuery(key) const dehydrated = dehydrate(serverClient) - using queryClient = clearOnDispose( - new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }), - ) + const queryClient = new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }) hydrate(queryClient, dehydrated) await vi.advanceTimersByTimeAsync(10) @@ -1482,6 +1480,9 @@ describe('dehydration and rehydration', () => { fetchFailureCount: 2, fetchStatus: 'idle', }) + + queryClient.clear() + serverClient.clear() }) }) diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index f5851ff98bc..df596c15f4b 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1,8 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - clearOnDispose, createKey, - fakeTimers, mockVisibilityState, sleep, } from '@tanstack/query-test-utils' @@ -117,22 +115,19 @@ describe('query', () => { }) it('should keep the retry options a fetch started with when the options change before a retry', async () => { - using _timers = fakeTimers() - using client = clearOnDispose(new QueryClient()) const key = createKey() const queryFn = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error('error')) .mockResolvedValue('data') - void client + void queryClient .query({ queryKey: key, queryFn, retry: 1, retryDelay: 50 }) .catch(noop) await vi.advanceTimersByTimeAsync(0) expect(queryFn).toHaveBeenCalledTimes(1) - client - .getQueryCache() + queryCache .find({ queryKey: key })! .setOptions({ queryKey: key, queryFn, retry: 1, retryDelay: 5 }) @@ -141,7 +136,7 @@ describe('query', () => { await vi.advanceTimersByTimeAsync(45) expect(queryFn).toHaveBeenCalledTimes(2) - expect(client.getQueryData(key)).toBe('data') + expect(queryClient.getQueryData(key)).toBe('data') }) it('should continue retry after reconnect and resolve all promises', async () => { diff --git a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts deleted file mode 100644 index c50b7ed1d89..00000000000 --- a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { clearOnDispose } from '../clearOnDispose' - -describe('clearOnDispose', () => { - it('should clear the client on dispose', () => { - const clear = vi.fn<() => void>() - { - using client = clearOnDispose({ clear }) - expect(client.clear).toBe(clear) - expect(clear).not.toHaveBeenCalled() - } - expect(clear).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts deleted file mode 100644 index 0bbf2f6090b..00000000000 --- a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { fakeTimers } from '../fakeTimers' - -describe('fakeTimers', () => { - it('should switch back to real timers on dispose', () => { - { - using _timers = fakeTimers() - expect(vi.isFakeTimers()).toBe(true) - } - expect(vi.isFakeTimers()).toBe(false) - }) -}) diff --git a/packages/query-test-utils/src/__tests__/subscribe.test.ts b/packages/query-test-utils/src/__tests__/subscribe.test.ts deleted file mode 100644 index ffafc471705..00000000000 --- a/packages/query-test-utils/src/__tests__/subscribe.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { subscribe } from '../subscribe' - -describe('subscribe', () => { - it('should unsubscribe the listener on dispose', () => { - const listeners = new Set<() => void>() - const subscribable = { - subscribe: (listener: () => void) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } - }, - } - const listener = vi.fn<() => void>() - { - using _subscription = subscribe(subscribable, listener) - expect(listeners).toStrictEqual(new Set([listener])) - } - expect(listeners.size).toBe(0) - }) -}) diff --git a/packages/query-test-utils/src/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts deleted file mode 100644 index 9bca266bff4..00000000000 --- a/packages/query-test-utils/src/clearOnDispose.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Clears a client, like a `QueryClient`, when a test's `using` declaration disposes it. - * @param client - The client to clear. - * @returns The same client, disposable. - * @example - * ```ts - * it('fetches', async () => { - * using queryClient = clearOnDispose(new QueryClient()) - * }) - * ``` - */ -export const clearOnDispose = void }>( - client: TClient, -): TClient & Disposable => - Object.assign(client, { - [Symbol.dispose]: () => { - client.clear() - }, - }) diff --git a/packages/query-test-utils/src/fakeTimers.ts b/packages/query-test-utils/src/fakeTimers.ts deleted file mode 100644 index 5988b14d8b7..00000000000 --- a/packages/query-test-utils/src/fakeTimers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { vi } from 'vitest' - -/** - * Switches to fake timers until a test's `using` declaration disposes the result. - * @returns A disposable that switches back to real timers. - * @example - * ```ts - * it('waits', async () => { - * using _timers = fakeTimers() - * await vi.advanceTimersByTimeAsync(10) - * }) - * ``` - */ -export const fakeTimers = (): Disposable => { - vi.useFakeTimers() - return { - [Symbol.dispose]: () => { - vi.useRealTimers() - }, - } -} diff --git a/packages/query-test-utils/src/index.ts b/packages/query-test-utils/src/index.ts index 60f71375191..6366858002e 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -1,6 +1,3 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' -export { fakeTimers } from './fakeTimers' -export { clearOnDispose } from './clearOnDispose' -export { subscribe } from './subscribe' diff --git a/packages/query-test-utils/src/subscribe.ts b/packages/query-test-utils/src/subscribe.ts deleted file mode 100644 index 63057dfeebe..00000000000 --- a/packages/query-test-utils/src/subscribe.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Subscribes a listener until a test's `using` declaration disposes the result. - * @param subscribable - What to subscribe to, like a `QueryObserver` or a `QueryCache`. - * @param listener - The listener to subscribe. - * @returns A disposable that unsubscribes the listener. - * @example - * ```ts - * it('fetches', () => { - * using _subscription = subscribe(new QueryObserver(queryClient, options), noop) - * }) - * ``` - */ -export const subscribe = ( - subscribable: { subscribe: (listener: TListener) => () => void }, - listener: NoInfer, -): Disposable => { - const unsubscribe = subscribable.subscribe(listener) - return { - [Symbol.dispose]: () => { - unsubscribe() - }, - } -} From 91aa4f0ae8de1e16ab6e49fb90fa7d7d5a4dab68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 15:28:50 +0000 Subject: [PATCH 09/10] fix(query-core): shrink the hydrated retry fix to query.ts A hydrated fetch now reads retry and retryDelay from the query's current options at each failure, falling back to the options it started with. This drops the changes to infiniteQueryBehavior.ts, queryClient.ts and utils.ts, and replaces the hydration tests with two that fail without the fix. Co-authored-by: alexander --- .changeset/hydrated-rejection-retry.md | 2 +- .../src/__tests__/hydration.test.tsx | 414 ++---------------- .../query-core/src/__tests__/query.test.tsx | 25 -- .../query-core/src/infiniteQueryBehavior.ts | 13 +- packages/query-core/src/query.ts | 22 +- packages/query-core/src/queryClient.ts | 2 - packages/query-core/src/utils.ts | 37 -- 7 files changed, 53 insertions(+), 462 deletions(-) diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md index 6d4d225fe86..ec1d264ce79 100644 --- a/.changeset/hydrated-rejection-retry.md +++ b/.changeset/hydrated-rejection-retry.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -When a query hydrated while pending rejects, it now retries with the `retry` and `retryDelay` an observer set, if one mounted before the promise settled. Options the observer doesn't set itself, including client defaults, keep the values the query was hydrated with. Once an observer has mounted, a hydrated infinite query's retries run its `queryFn` from the first page, instead of replaying the rejected promise. +When a query hydrated while pending rejects, it now retries with its current `retry` and `retryDelay` options, so an observer that mounted before the promise settled decides whether and when it retries. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index a5faf906d91..f733eff0ee1 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -5,7 +5,6 @@ import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' import { dehydrateQuery } from '../index' import { MutationCache } from '../mutationCache' -import { InfiniteQueryObserver } from '../infiniteQueryObserver' import { QueryObserver } from '../queryObserver' import { noop } from '../utils' import { executeMutation, mockOnlineManagerIsOnline } from './utils' @@ -1098,392 +1097,65 @@ describe('dehydration and rehydration', () => { }) }) - describe('a hydrated promise that rejects', () => { - function startRejectingQuery(key: Array) { - const serverClient = new QueryClient({ - defaultOptions: { - dehydrate: { - shouldDehydrateQuery: () => true, - shouldRedactErrors: () => false, - }, - }, - }) - void serverClient - .query({ - queryKey: key, - queryFn: () => - sleep(10).then(() => Promise.reject(new Error('server error'))), - retry: false, - }) - .catch(noop) - return serverClient - } - - function startRejectingInfiniteQuery(key: Array) { - const serverClient = new QueryClient({ - defaultOptions: { - dehydrate: { - shouldDehydrateQuery: () => true, - shouldRedactErrors: () => false, - }, - }, - }) - void serverClient - .infiniteQuery({ - queryKey: key, - queryFn: () => - sleep(10).then(() => Promise.reject(new Error('server error'))), - initialPageParam: 0, - getNextPageParam: () => undefined, - retry: false, - }) - .catch(noop) - return serverClient - } - - it('should retry with the retry options of an observer that mounted before it rejected', async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient() - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(10) - expect(queryFn).not.toHaveBeenCalled() - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'pending', - fetchFailureCount: 1, - }) - - await vi.advanceTimersByTimeAsync(5) - expect(queryFn).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'success', - data: 'client data', - fetchStatus: 'idle', - }) - - unsubscribe() - queryClient.clear() - serverClient.clear() - }) - - it("should resolve a fetch that joins the hydrated promise with the data of the observer's retry", async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient() - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }) - const unsubscribe = observer.subscribe(noop) - const joined = queryClient.query({ queryKey: key, queryFn }) - - await vi.advanceTimersByTimeAsync(25) - - await expect(joined).resolves.toBe('client data') - expect(queryFn).toHaveBeenCalledTimes(1) - - unsubscribe() - queryClient.clear() - serverClient.clear() + it("should use the observer's retryDelay when a hydrated query rejects", async () => { + const key = createKey() + const serverClient = new QueryClient({ + defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, }) - - it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient() - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver(queryClient, { + void serverClient + .query({ queryKey: key, - queryFn, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), retry: false, }) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'error', - error: new Error('server error'), - fetchStatus: 'idle', - }) - expect(queryFn).not.toHaveBeenCalled() - - unsubscribe() - queryClient.clear() - serverClient.clear() - }) - - it('should keep the hydration retry options that an observer does not set', async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }) - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - }) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(15) - expect(queryFn).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'success', - data: 'client data', - }) - - unsubscribe() - queryClient.clear() - serverClient.clear() - }) - - it("should keep the hydration retry options over the client's query defaults", async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: 2, retryDelay: 5 }, - hydrate: { queries: { retry: 0 } }, - }, - }) - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - }) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'error', - fetchFailureCount: 1, - }) - expect(queryFn).not.toHaveBeenCalled() - - unsubscribe() - queryClient.clear() - serverClient.clear() - }) - - it('should keep the hydration retry options over setQueryDefaults', async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { hydrate: { queries: { retry: 0 } } }, - }) - queryClient.setQueryDefaults(key, { retry: 2, retryDelay: 5 }) - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver(queryClient, { - queryKey: key, - queryFn, - }) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'error', - fetchFailureCount: 1, - }) - expect(queryFn).not.toHaveBeenCalled() - - unsubscribe() - queryClient.clear() - serverClient.clear() - }) - - it("should retry with the retry options an observer sets itself over the client's query defaults", async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: 0 }, - hydrate: { queries: { retry: 0 } }, - }, - }) - hydrate(queryClient, dehydrated) - const queryFn = vi.fn<() => Promise>(() => - sleep(10).then(() => 'client data'), - ) - const observer = new QueryObserver( - queryClient, - queryClient.defaultQueryOptions({ - queryKey: key, - queryFn, - retry: 1, - retryDelay: 5, - }), - ) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(15) - expect(queryFn).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'success', - data: 'client data', - }) + .catch(noop) + const queryClient = new QueryClient() + hydrate(queryClient, dehydrate(serverClient)) - unsubscribe() - queryClient.clear() - serverClient.clear() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + retry: 1, + retryDelay: 10, }) + const unsubscribe = observer.subscribe(noop) - it('should retry with the hydration options when no observer has mounted', async () => { - const key = createKey() - const serverClient = startRejectingQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }) - hydrate(queryClient, dehydrated) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'pending', - fetchFailureCount: 1, - }) + await vi.advanceTimersByTimeAsync(30) + expect(queryClient.getQueryData(key)).toBe('data') - await vi.advanceTimersByTimeAsync(5) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'error', - error: new Error('server error'), - fetchFailureCount: 2, - fetchStatus: 'idle', - }) + unsubscribe() + queryClient.clear() + serverClient.clear() + }) - queryClient.clear() - serverClient.clear() + it("should use the observer's retry when a hydrated query rejects", async () => { + const key = createKey() + const serverClient = new QueryClient({ + defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, }) - - it('should retry a hydrated infinite query with the queryFn of an observer that mounted before it rejected', async () => { - const key = createKey() - const serverClient = startRejectingInfiniteQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { hydrate: { queries: { retry: 0 } } }, - }) - hydrate(queryClient, dehydrated) - const queryFn = vi.fn< - (context: { pageParam: number }) => Promise - >(({ pageParam }) => sleep(10).then(() => `page ${pageParam}`)) - const observer = new InfiniteQueryObserver(queryClient, { + void serverClient + .query({ queryKey: key, - queryFn, - initialPageParam: 0, - getNextPageParam: () => undefined, - retry: 1, - retryDelay: 5, - }) - const unsubscribe = observer.subscribe(noop) - - await vi.advanceTimersByTimeAsync(10) - expect(queryFn).not.toHaveBeenCalled() - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'pending', - fetchFailureCount: 1, - }) - - await vi.advanceTimersByTimeAsync(5) - expect(queryFn).toHaveBeenCalledTimes(1) - expect(queryFn).toHaveBeenCalledWith( - expect.objectContaining({ pageParam: 0 }), - ) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'success', - data: { pages: ['page 0'], pageParams: [0] }, - fetchStatus: 'idle', + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), + retry: false, }) + .catch(noop) + const queryClient = new QueryClient() + hydrate(queryClient, dehydrate(serverClient)) - unsubscribe() - queryClient.clear() - serverClient.clear() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + retry: false, }) + const unsubscribe = observer.subscribe(noop) - it('should retry a hydrated infinite query with the hydration options when no observer has mounted', async () => { - const key = createKey() - const serverClient = startRejectingInfiniteQuery(key) - const dehydrated = dehydrate(serverClient) - - const queryClient = new QueryClient({ - defaultOptions: { - hydrate: { queries: { retry: 1, retryDelay: 5 } }, - }, - }) - hydrate(queryClient, dehydrated) - - await vi.advanceTimersByTimeAsync(10) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'pending', - fetchFailureCount: 1, - }) - - await vi.advanceTimersByTimeAsync(5) - expect(queryClient.getQueryState(key)).toMatchObject({ - status: 'error', - error: new Error('server error'), - fetchFailureCount: 2, - fetchStatus: 'idle', - }) + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)?.status).toBe('error') - queryClient.clear() - serverClient.clear() - }) + unsubscribe() + queryClient.clear() + serverClient.clear() }) it('should transform promise result', async () => { diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index df596c15f4b..4e1fc8dccdf 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -114,31 +114,6 @@ describe('query', () => { expect(result).toBe('data3') }) - it('should keep the retry options a fetch started with when the options change before a retry', async () => { - const key = createKey() - const queryFn = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('error')) - .mockResolvedValue('data') - - void queryClient - .query({ queryKey: key, queryFn, retry: 1, retryDelay: 50 }) - .catch(noop) - await vi.advanceTimersByTimeAsync(0) - expect(queryFn).toHaveBeenCalledTimes(1) - - queryCache - .find({ queryKey: key })! - .setOptions({ queryKey: key, queryFn, retry: 1, retryDelay: 5 }) - - await vi.advanceTimersByTimeAsync(5) - expect(queryFn).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(45) - expect(queryFn).toHaveBeenCalledTimes(2) - expect(queryClient.getQueryData(key)).toBe('data') - }) - it('should continue retry after reconnect and resolve all promises', async () => { const key = createKey() diff --git a/packages/query-core/src/infiniteQueryBehavior.ts b/packages/query-core/src/infiniteQueryBehavior.ts index 944793de156..20bc95ff225 100644 --- a/packages/query-core/src/infiniteQueryBehavior.ts +++ b/packages/query-core/src/infiniteQueryBehavior.ts @@ -28,6 +28,7 @@ export function infiniteQueryBehavior( ): QueryBehavior> { return { onFetch: (context, query) => { + const options = context.options as InfiniteQueryPageParamsOptions const direction = context.fetchOptions?.meta?.fetchMore?.direction const oldPages = context.state.data?.pages || [] const oldPageParams = context.state.data?.pageParams || [] @@ -35,12 +36,6 @@ export function infiniteQueryBehavior( let currentPage = 0 const fetchFn = async () => { - // A fetch that resumes a hydrated promise only calls this to retry, and - // an observer may have set the query's options since the fetch started - const queryOptions = ( - context.fetchOptions?.initialPromise ? query.options : context.options - ) as typeof context.options - const options = queryOptions as InfiniteQueryPageParamsOptions let cancelled = false const signalContext: FetchSignalContext = context const addSignalProperty = (object: object) => { @@ -52,7 +47,7 @@ export function infiniteQueryBehavior( ) } - const queryFn = ensureQueryFn(queryOptions, context.fetchOptions) + const queryFn = ensureQueryFn(context.options, context.fetchOptions) // Create function to fetch a page const fetchPage = async ( @@ -77,7 +72,7 @@ export function infiniteQueryBehavior( queryKey: context.queryKey, pageParam: param, direction: previous ? 'backward' : 'forward', - meta: queryOptions.meta, + meta: context.options.meta, } addSignalProperty(queryFnContext) return queryFnContext as QueryFunctionContext @@ -87,7 +82,7 @@ export function infiniteQueryBehavior( const page = await queryFn(queryFnContext) - const { maxPages } = queryOptions + const { maxPages } = context.options const addTo = previous ? addToStart : addToEnd return { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 544da6d1498..587b7f42c53 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -1,7 +1,6 @@ import { ensureQueryFn, fetchSignal, - getExplicitRetryOptions, noop, replaceData, resolveQueryValue, @@ -830,22 +829,11 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) - // A hydrated promise starts fetching before any observer mounts. At each - // failure, retry options an observer set itself win over those from - // hydration, and client defaults don't count as set - const getRetryOption = ( - key: TKey, - ): QueryOptions[TKey] => { - if (fetchOptions?.initialPromise) { - for (const observer of this.observers) { - const value = getExplicitRetryOptions(observer.options)?.[key] - if (value !== undefined) { - return value - } - } - } - return context.options[key] - } + // A hydrated promise starts fetching before any observer mounts, so its + // retries use the options observers have set since, if any + const getRetryOption = (key: TKey) => + (fetchOptions?.initialPromise ? this.options[key] : undefined) ?? + context.options[key] // Store state in case the current fetch needs to be reverted this.#revertState = this.state diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 390494bc0aa..faa0b130ab2 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -5,7 +5,6 @@ import { noop, partialMatchKey, resolveQueryValue, - setExplicitRetryOptions, skipToken, } from './utils' import { QueryCache } from './queryCache' @@ -1103,7 +1102,6 @@ export class QueryClient { ...options, _defaulted: true, } - setExplicitRetryOptions(defaultedOptions, options) if (!defaultedOptions.queryHash) { defaultedOptions.queryHash = hashQueryKeyByOptions( diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index f9252df56e0..64e6a664ab5 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -629,43 +629,6 @@ export const skipToken = Symbol() */ export type SkipToken = typeof skipToken -type RetryOptions = Pick< - QueryOptions, - 'retry' | 'retryDelay' -> - -const explicitRetryOptions = new WeakMap() - -/** - * Remembers the `retry` and `retryDelay` a caller set itself, before client defaults filled them in. - * @internal - * @param defaultedOptions - The options `defaultQueryOptions` returns. - * @param options - The options the caller passed in. - */ -export function setExplicitRetryOptions( - defaultedOptions: object, - options: RetryOptions, -): void { - if (options.retry !== undefined || options.retryDelay !== undefined) { - explicitRetryOptions.set(defaultedOptions, { - retry: options.retry, - retryDelay: options.retryDelay, - }) - } -} - -/** - * Returns the `retry` and `retryDelay` a caller set itself on options from `defaultQueryOptions`. - * @internal - * @param defaultedOptions - The options `defaultQueryOptions` returned. - * @returns The options the caller set, or `undefined` if it set neither. - */ -export function getExplicitRetryOptions( - defaultedOptions: object, -): RetryOptions | undefined { - return explicitRetryOptions.get(defaultedOptions) -} - /** * Returns the function to call for a fetch. If there is no `queryFn` but the fetch was started from * an `initialPromise`, that promise is returned instead. If there is no usable `queryFn` (missing or From c351a2954c48e5e6be716f0eed31669d73ba8a5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 3 Nov 2026 15:35:18 +0000 Subject: [PATCH 10/10] fix(query-core): put hydration retry options over client defaults A hydrated query's retries take retry and retryDelay from the options an observer set itself, then the hydration options, then the client's defaults. defaultQueryOptions records the options it was called with, so the query can tell an observer's own options from defaults. Co-authored-by: alexander --- .changeset/hydrated-rejection-retry.md | 2 +- .../src/__tests__/hydration.test.tsx | 33 +++++++++++++++++++ packages/query-core/src/query.ts | 18 +++++++--- packages/query-core/src/queryClient.ts | 2 ++ packages/query-core/src/utils.ts | 9 +++++ 5 files changed, 58 insertions(+), 6 deletions(-) diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md index ec1d264ce79..b4cd974cc81 100644 --- a/.changeset/hydrated-rejection-retry.md +++ b/.changeset/hydrated-rejection-retry.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -When a query hydrated while pending rejects, it now retries with its current `retry` and `retryDelay` options, so an observer that mounted before the promise settled decides whether and when it retries. +When a query hydrated while pending rejects, its retries now take `retry` and `retryDelay` from the options an observer set itself, if one mounted before the promise settled. Otherwise they use the hydration options, and then the client's defaults, as before. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index f733eff0ee1..eb48fd484db 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1158,6 +1158,39 @@ describe('dehydration and rehydration', () => { serverClient.clear() }) + it("should use the hydrate retry over the client's default retry when a hydrated query rejects", async () => { + const key = createKey() + const serverClient = new QueryClient({ + defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } }, + }) + void serverClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), + retry: false, + }) + .catch(noop) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 1, retryDelay: 10 } }, + }) + hydrate(queryClient, dehydrate(serverClient), { + defaultOptions: { queries: { retry: false } }, + }) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)?.status).toBe('error') + + unsubscribe() + queryClient.clear() + serverClient.clear() + }) + it('should transform promise result', async () => { const key = createKey() const queryClient = new QueryClient({ diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 587b7f42c53..dfac1131433 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -2,6 +2,7 @@ import { ensureQueryFn, fetchSignal, noop, + ownQueryOptions, replaceData, resolveQueryValue, skipToken, @@ -829,11 +830,18 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) - // A hydrated promise starts fetching before any observer mounts, so its - // retries use the options observers have set since, if any - const getRetryOption = (key: TKey) => - (fetchOptions?.initialPromise ? this.options[key] : undefined) ?? - context.options[key] + // A hydrated promise starts fetching before any observer mounts. Its + // retries use the options an observer set itself, then the options the + // fetch started with, which put hydration options over client defaults + const getRetryOption = (key: TKey) => { + if (fetchOptions?.initialPromise) { + for (const observer of this.observers) { + const value = ownQueryOptions.get(observer.options)?.[key] + if (value !== undefined) return value + } + } + return context.options[key] + } // Store state in case the current fetch needs to be reverted this.#revertState = this.state diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index faa0b130ab2..c3b0d6772af 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -3,6 +3,7 @@ import { hashKey, hashQueryKeyByOptions, noop, + ownQueryOptions, partialMatchKey, resolveQueryValue, skipToken, @@ -1102,6 +1103,7 @@ export class QueryClient { ...options, _defaulted: true, } + ownQueryOptions.set(defaultedOptions, options) if (!defaultedOptions.queryHash) { defaultedOptions.queryHash = hashQueryKeyByOptions( diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 64e6a664ab5..0fb321d8dff 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -629,6 +629,15 @@ export const skipToken = Symbol() */ export type SkipToken = typeof skipToken +/** + * The options each `defaultQueryOptions` result was made from, before client defaults filled them in. + * @internal + */ +export const ownQueryOptions = new WeakMap< + object, + Pick, 'retry' | 'retryDelay'> +>() + /** * Returns the function to call for a fetch. If there is no `queryFn` but the fetch was started from * an `initialPromise`, that promise is returned instead. If there is no usable `queryFn` (missing or