Skip to content
Closed
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
203 changes: 203 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1399,4 +1399,207 @@ describe('dehydration and rehydration', () => {
// error and test will fail
await originalPromise
})

test('should preserve infinite query type when hydrating failed promise', async () => {
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
},
},
})

const promise = queryClient
.prefetchInfiniteQuery({
queryKey: ['infinite', 'failed'],
queryFn: () => Promise.reject(new Error('fetch failed')),
initialPageParam: 0,
getNextPageParam: () => 1,
retry: false,
})
.catch(() => {})

const dehydrated = dehydrate(queryClient)

const hydrationClient = new QueryClient()
hydrate(hydrationClient, dehydrated)

const hydratedQuery = hydrationClient.getQueryCache().find({
queryKey: ['infinite', 'failed'],
})

expect(hydratedQuery?.isInfiniteQuery).toBe(true)

await promise
})

test('should mark infinite queries with isInfiniteQuery flag during dehydration', async () => {
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: { shouldDehydrateQuery: () => true },
},
})

await queryClient.prefetchInfiniteQuery({
queryKey: ['infinite'],
queryFn: ({ pageParam = 0 }) => Promise.resolve(`page-${pageParam}`),
initialPageParam: 0,
getNextPageParam: (_lastPage: any, pages: any) => pages.length,
retry: false,
})

await queryClient.prefetchQuery({
queryKey: ['regular'],
queryFn: () => Promise.resolve('data'),
})

const dehydrated = dehydrate(queryClient)

const infiniteQuery = dehydrated.queries.find(
(q) => q.queryKey[0] === 'infinite',
)
expect(infiniteQuery?.isInfiniteQuery).toBe(true)

const regularQuery = dehydrated.queries.find(
(q) => q.queryKey[0] === 'regular',
)
expect(regularQuery?.isInfiniteQuery).toBeUndefined()
})

test('should preserve isInfiniteQuery flag through hydration', async () => {
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: { shouldDehydrateQuery: () => true },
},
})

await queryClient
.prefetchInfiniteQuery({
queryKey: ['infinite'],
queryFn: () => Promise.reject(new Error('Failed')),
initialPageParam: 0,
getNextPageParam: () => 1,
retry: false,
})
.catch(() => {})

const dehydrated = dehydrate(queryClient)

expect(dehydrated.queries[0]?.isInfiniteQuery).toBe(true)

const newClient = new QueryClient()
hydrate(newClient, dehydrated)

const hydratedQuery = newClient.getQueryCache().find({
queryKey: ['infinite'],
})

expect(hydratedQuery?.isInfiniteQuery).toBe(true)
})

test('should handle JSON serialization of dehydrated infinite queries', async () => {
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: { shouldDehydrateQuery: () => true },
},
})

await queryClient
.prefetchInfiniteQuery({
queryKey: ['infinite'],
queryFn: () => Promise.reject(new Error('Failed')),
initialPageParam: 0,
getNextPageParam: () => 1,
retry: false,
})
.catch(() => {})

const dehydrated = dehydrate(queryClient)

const serialized = JSON.stringify(dehydrated)
const deserialized = JSON.parse(serialized)

expect(deserialized.queries[0]?.isInfiniteQuery).toBe(true)

const newClient = new QueryClient()
hydrate(newClient, deserialized)

const hydratedQuery = newClient.getQueryCache().find({
queryKey: ['infinite'],
})

expect(hydratedQuery?.isInfiniteQuery).toBe(true)
})

test('should not affect regular query hydration', async () => {
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: { shouldDehydrateQuery: () => true },
},
})

await Promise.all([
queryClient.prefetchQuery({
queryKey: ['regular1'],
queryFn: () => Promise.resolve('data1'),
}),
queryClient.prefetchInfiniteQuery({
queryKey: ['infinite1'],
queryFn: () => Promise.resolve('page1'),
initialPageParam: 0,
getNextPageParam: () => 1,
}),
queryClient
.prefetchQuery({
queryKey: ['regular2'],
queryFn: () => Promise.reject(new Error('Failed')),
retry: false,
})
.catch(() => {}),
])

const dehydrated = dehydrate(queryClient)
const newClient = new QueryClient()
hydrate(newClient, dehydrated)

const regular1 = newClient.getQueryCache().find({ queryKey: ['regular1'] })
const infinite1 = newClient
.getQueryCache()
.find({ queryKey: ['infinite1'] })
const regular2 = newClient.getQueryCache().find({ queryKey: ['regular2'] })

expect(regular1?.isInfiniteQuery).toBeUndefined()
expect(infinite1?.isInfiniteQuery).toBe(true)
expect(regular2?.isInfiniteQuery).toBeUndefined()

expect(regular1?.state.data).toBe('data1')
})

test('should handle nested infinite query keys correctly', async () => {
const queryClient = new QueryClient({
defaultOptions: {
dehydrate: { shouldDehydrateQuery: () => true },
},
})

await queryClient
.prefetchInfiniteQuery({
queryKey: ['posts', { userId: 1, filter: 'active' }],
queryFn: () => Promise.reject(new Error('Failed')),
initialPageParam: 0,
getNextPageParam: () => 1,
retry: false,
})
.catch(() => {})

const dehydrated = dehydrate(queryClient)
const newClient = new QueryClient()
hydrate(newClient, dehydrated)

const hydratedQuery = newClient.getQueryCache().find({
queryKey: ['posts', { userId: 1, filter: 'active' }],
})

expect(hydratedQuery?.isInfiniteQuery).toBe(true)
})
})
16 changes: 15 additions & 1 deletion packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface DehydratedQuery {
// without it which we need to handle for backwards compatibility.
// This should be changed to required in the future.
dehydratedAt?: number
isInfiniteQuery?: boolean
}

export interface DehydratedState {
Expand Down Expand Up @@ -104,6 +105,7 @@ function dehydrateQuery(
}),
}),
...(query.meta && { meta: query.meta }),
...(query.options.behavior && { isInfiniteQuery: true }),
}
}

Expand Down Expand Up @@ -196,7 +198,15 @@ export function hydrate(
})

queries.forEach(
({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => {
({
queryKey,
state,
queryHash,
meta,
promise,
dehydratedAt,
isInfiniteQuery,
}) => {
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data = rawData === undefined ? rawData : deserializeData(rawData)
Comment on lines +201 to 212
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Also set isInfiniteQuery on pre-existing queries during hydration

Right now, setIsInfiniteQuery(true) is only called when a new Query is built. If a Query already exists in the cache (e.g., user code created it before hydrate()), the flag isn’t applied, and the retry/cancel logic in Query.fetch won’t kick in for that edge case.

Apply the flag for both new and existing queries:

       ) 
-        )
-      
-        if (isInfiniteQuery) {
-          query.setIsInfiniteQuery(true)
-        }
-      }
+        )
+      }
+
+      if (isInfiniteQuery) {
+        query.setIsInfiniteQuery(true)
+      }

Also applies to: 259-262

🤖 Prompt for AI Agents
In packages/query-core/src/hydration.ts around lines 201-212 (and likewise
adjust lines 259-262), the hydration path only calls setIsInfiniteQuery(true)
when building a new Query, leaving pre-existing queries without the flag; update
the logic so that after resolving or obtaining the Query instance (both in the
new-query branch and the existing-query branch) you call
query.setIsInfiniteQuery(Boolean(isInfiniteQuery)) (or
query.setIsInfiniteQuery(isInfiniteQuery) if already boolean) so the flag is
applied regardless of whether the Query was created during hydration or already
existed in the cache.

Expand Down Expand Up @@ -245,6 +255,10 @@ export function hydrate(
status: data !== undefined ? 'success' : state.status,
},
)

if (isInfiniteQuery) {
query.setIsInfiniteQuery(true)
}
}

if (
Expand Down
21 changes: 20 additions & 1 deletion packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export class Query<
observers: Array<QueryObserver<any, any, any, any, any>>
#defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
#abortSignalConsumed: boolean
#isInfiniteQuery?: boolean

constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
super()
Expand All @@ -199,6 +200,10 @@ export class Query<
return this.#retryer?.promise
}

get isInfiniteQuery(): boolean | undefined {
return this.#isInfiniteQuery
}

setOptions(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): void {
Expand Down Expand Up @@ -249,6 +254,10 @@ export class Query<
this.#dispatch({ type: 'setState', state, setStateOptions })
}

setIsInfiniteQuery(value: boolean): void {
this.#isInfiniteQuery = value
}

cancel(options?: CancelOptions): Promise<void> {
const promise = this.#retryer?.promise
this.#retryer?.cancel(options)
Expand Down Expand Up @@ -395,7 +404,17 @@ export class Query<
// pending state when that happens
this.#retryer?.status() !== 'rejected'
) {
if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
const isStuckHydratedInfinite =
this.#isInfiniteQuery &&
this.state.data === undefined &&
this.#retryer?.status() === 'pending' &&
options?.behavior &&
fetchOptions?.cancelRefetch

if (isStuckHydratedInfinite) {
// Silently cancel current fetch if the user wants to cancel refetch
this.cancel({ silent: true })
} else if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
// Silently cancel current fetch if the user wants to cancel refetch
this.cancel({ silent: true })
} else if (this.#retryer) {
Expand Down
Loading
Loading