Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hydrated-rejection-retry.md
Original file line number Diff line number Diff line change
@@ -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.
134 changes: 134 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down
22 changes: 20 additions & 2 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ensureQueryFn,
fetchSignal,
noop,
ownQueryOptions,
replaceData,
resolveQueryValue,
skipToken,
Expand Down Expand Up @@ -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 = <TKey extends 'retry' | 'retryDelay'>(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

Expand Down Expand Up @@ -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,
}))
Expand Down
2 changes: 2 additions & 0 deletions packages/query-core/src/queryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
hashKey,
hashQueryKeyByOptions,
noop,
ownQueryOptions,
partialMatchKey,
resolveQueryValue,
skipToken,
Expand Down Expand Up @@ -1102,6 +1103,7 @@ export class QueryClient {
...options,
_defaulted: true,
}
ownQueryOptions.set(defaultedOptions, options)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (!defaultedOptions.queryHash) {
defaultedOptions.queryHash = hashQueryKeyByOptions(
Expand Down
9 changes: 9 additions & 0 deletions packages/query-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryOptions<any, any, any, any>, '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
Expand Down