diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md new file mode 100644 index 00000000000..b4cd974cc81 --- /dev/null +++ b/.changeset/hydrated-rejection-retry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +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 ee037a11afe..eb48fd484db 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,100 @@ describe('dehydration and rehydration', () => { }) }) + it("should use the observer's retryDelay 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() + hydrate(queryClient, dehydrate(serverClient)) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + retry: 1, + retryDelay: 10, + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(30) + expect(queryClient.getQueryData(key)).toBe('data') + + unsubscribe() + 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 } }, + }) + void serverClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), + retry: false, + }) + .catch(noop) + const queryClient = new QueryClient() + hydrate(queryClient, dehydrate(serverClient)) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + retry: false, + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)?.status).toBe('error') + + unsubscribe() + queryClient.clear() + 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 af9a81baebd..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,6 +830,19 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) + // 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 @@ -864,8 +878,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, })) 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