Skip to content
Draft
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.
95 changes: 95 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,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({
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 @@ -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 = <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 @@ -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,
}))
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)

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