From 3dda21ea8f1a9b02ecb300ea2865397e8269530d Mon Sep 17 00:00:00 2001 From: Brennan Butler Date: Fri, 18 Sep 2026 10:50:02 +0700 Subject: [PATCH] Reset signal consumption for infinite queries Reset signal consumption before query behavior replaces the fetch function. Earlier signal consumption must not cancel a later fetch that never reads its signal when the last observer unmounts. Cover refetching, next-page fetching, and React unmounts with and without Strict Mode. --- .changeset/fix-infinite-query-signal-reset.md | 5 +++ .../__tests__/infiniteQueryBehavior.test.tsx | 35 +++++++++++++++ packages/query-core/src/query.ts | 2 + .../src/__tests__/useInfiniteQuery.test.tsx | 43 ++++++++++++++++++- 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-infinite-query-signal-reset.md diff --git a/.changeset/fix-infinite-query-signal-reset.md b/.changeset/fix-infinite-query-signal-reset.md new file mode 100644 index 00000000000..d8e92b4a065 --- /dev/null +++ b/.changeset/fix-infinite-query-signal-reset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Reset signal consumption for each new infinite-query fetch. A previous fetch that read the signal no longer causes a later fetch that ignores it to be cancelled on unmount. diff --git a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx index 1a4bf1bee30..fe8a7ce0899 100644 --- a/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx +++ b/packages/query-core/src/__tests__/infiniteQueryBehavior.test.tsx @@ -20,6 +20,41 @@ describe('InfiniteQueryBehavior', () => { vi.useRealTimers() }) + it.each(['refetch', 'fetchNextPage'])( + 'should not cancel an unconsumed %s because a previous fetch consumed the signal', + async (method) => { + const key = queryKey() + let consumeSignal = true + const observer = new InfiniteQueryObserver(queryClient, { + queryKey: key, + queryFn: (context) => { + if (consumeSignal) { + void context.signal + return Promise.resolve('initial') + } + return sleep(10).then(() => 'fresh') + }, + initialPageParam: 0, + getNextPageParam: (_lastPage, _pages, lastPageParam) => + lastPageParam + 1, + }) + const unsubscribe = observer.subscribe(vi.fn()) + await vi.advanceTimersByTimeAsync(0) + consumeSignal = false + + const result = + method === 'refetch' ? observer.refetch() : observer.fetchNextPage() + unsubscribe() + await vi.advanceTimersByTimeAsync(10) + await result + + expect(queryClient.getQueryData(key)).toEqual({ + pages: method === 'refetch' ? ['fresh'] : ['initial', 'fresh'], + pageParams: method === 'refetch' ? [0] : [0, 1], + }) + }, + ) + it('should throw an error if the queryFn is not defined', async () => { const key = queryKey() diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index bc35f0fceb2..bd0fd4a35d9 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -632,6 +632,8 @@ export class Query< } const abortController = new AbortController() + // Behaviors can replace fetchFn, so reset consumption for every new fetch here too. + this.#abortSignalConsumed = false // Adds an enumerable signal property to the object that // which sets abortSignalConsumed to true when the signal diff --git a/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx b/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx index a2ba5211c17..2deb2b837c4 100644 --- a/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx +++ b/packages/react-query/src/__tests__/useInfiniteQuery.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render } from '@testing-library/react' +import { act, fireEvent, render, renderHook } from '@testing-library/react' import * as React from 'react' import { createRenderStream } from '@testing-library/react-render-stream' import { queryKey, sleep } from '@tanstack/query-test-utils' @@ -45,6 +45,47 @@ describe('useInfiniteQuery', () => { vi.useRealTimers() }) + it.each([false, true])( + 'should cache an unconsumed refetch after unmount when an earlier fetch consumed the signal (StrictMode: %s)', + async (strict) => { + const key = queryKey() + let consumeSignal = true + const view = renderHook( + () => + useInfiniteQuery( + { + queryKey: key, + queryFn: (context) => { + if (consumeSignal) { + void context.signal + return Promise.resolve('initial') + } + return sleep(10).then(() => 'fresh') + }, + initialPageParam: 0, + getNextPageParam: () => undefined, + }, + queryClient, + ), + { wrapper: strict ? React.StrictMode : undefined }, + ) + await act(() => vi.advanceTimersByTimeAsync(0)) + consumeSignal = false + let result: ReturnType | undefined + act(() => { + result = view.result.current.refetch() + }) + view.unmount() + await vi.advanceTimersByTimeAsync(10) + await result + + expect(queryClient.getQueryData(key)).toEqual({ + pages: ['fresh'], + pageParams: [0], + }) + }, + ) + it('should return the correct states for a successful query', async () => { const key = queryKey() const states: Array>> = []