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..856658fd5ff 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,139 @@ describe('dehydration and rehydration', () => { }) }) + it("should retry a hydrated query that rejects with the observer's retry and retryDelay", async () => { + const key = createKey() + // 🖥️ Server: still pending at dehydrate, rejects later + const serverClient = new QueryClient() + void serverClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), + retry: false, + }) + .catch(noop) + // 🌐 Browser: retries off by default, so only the reader can retry + const browserClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + hydrate( + browserClient, + dehydrate(serverClient, { shouldDehydrateQuery: () => true }), + ) + + // 👀 Reader asks for 1 retry after 10ms + const observer = new QueryObserver(browserClient, { + queryKey: key, + queryFn: () => 'browser data', + retry: 1, + retryDelay: 10, + }) + const unsubscribe = observer.subscribe(noop) + + // ✅ 0ms: pending while the server's promise is in flight + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + failureCount: 0, + }) + + // ✅ 10ms: the promise rejected, and the query stays pending for the retry + await vi.advanceTimersByTimeAsync(10) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + fetchStatus: 'fetching', + failureCount: 1, + failureReason: new Error('redacted'), + }) + + // ✅ 20ms: the retry succeeded + await vi.advanceTimersByTimeAsync(10) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + fetchStatus: 'idle', + failureCount: 0, + data: 'browser data', + }) + + unsubscribe() + browserClient.clear() + serverClient.clear() + }) + + it('should not retry a hydrated query that rejects when the observer sets retry: false', async () => { + const key = createKey() + // 🖥️ Server: still pending at dehydrate, rejects later + const serverClient = new QueryClient() + void serverClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), + retry: false, + }) + .catch(noop) + // 🌐 Browser: retries once by default, so the reader must turn them off + const browserClient = new QueryClient({ + defaultOptions: { queries: { retry: 1 } }, + }) + hydrate( + browserClient, + dehydrate(serverClient, { shouldDehydrateQuery: () => true }), + ) + + // 👀 Reader asks for no retries + const observer = new QueryObserver(browserClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'browser data'), + retry: false, + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + // ✅ Fails when the server's promise rejects, without a retry + expect(browserClient.getQueryState(key)?.status).toBe('error') + + unsubscribe() + browserClient.clear() + serverClient.clear() + }) + + it("should use the hydrate retry over the client's default retry when a hydrated query rejects", async () => { + const key = createKey() + // 🖥️ Server: still pending at dehydrate, rejects later + const serverClient = new QueryClient() + void serverClient + .query({ + queryKey: key, + queryFn: () => sleep(10).then(() => Promise.reject(new Error('error'))), + retry: false, + }) + .catch(noop) + // 🌐 Browser: retries once by default, but hydrate turns retries off + const browserClient = new QueryClient({ + defaultOptions: { queries: { retry: 1, retryDelay: 10 } }, + }) + hydrate( + browserClient, + dehydrate(serverClient, { shouldDehydrateQuery: () => true }), + { defaultOptions: { queries: { retry: false } } }, + ) + + // 👀 Reader sets no retry options, so hydrate's apply + const observer = new QueryObserver(browserClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => 'browser data'), + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + // ✅ Fails when the server's promise rejects, without the client's retry + expect(browserClient.getQueryState(key)?.status).toBe('error') + + unsubscribe() + browserClient.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 c5e97c49388..e1805929a1a 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, @@ -833,6 +834,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 @@ -868,8 +882,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