From e5c9fca7cd02e2ef8859be45f5809cf9a517d904 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Nov 2026 22:30:16 +0000 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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()