From 9f6bf20893d6fbd2f8f802f17f18901a3bfbee67 Mon Sep 17 00:00:00 2001 From: Mauro Garcia Date: Thu, 17 Sep 2026 15:03:51 -0300 Subject: [PATCH] fix(query-core): preserve invalidation intent during an initial fetch invalidateQueries() marks the query invalid and then refetches through Query.fetch(), which can only cancel and restart an in-flight fetch when the query already has data. During an initial fetch there is no data, so the refetch piggybacks on the fetch that was already running, and the success reducer then clears isInvalidated. The invalidation is lost and data read from a pre-invalidation server state stays cached. Remember when an invalidation lands while a fetch is in flight: that fetch started earlier, so its result cannot satisfy the invalidation. The next refetch then lets it settle for a first result and fetches once more. The flag is cleared as soon as any fetch starts, so a fetch that begins after the invalidation still satisfies it and concurrent invalidations coalesce onto a single trailing fetch. Co-authored-by: Cursor --- .changeset/invalidate-during-initial-fetch.md | 5 ++ .../src/__tests__/queryClient.test.tsx | 87 +++++++++++++++++++ packages/query-core/src/query.ts | 22 +++++ 3 files changed, 114 insertions(+) create mode 100644 .changeset/invalidate-during-initial-fetch.md diff --git a/.changeset/invalidate-during-initial-fetch.md b/.changeset/invalidate-during-initial-fetch.md new file mode 100644 index 0000000000..b542131370 --- /dev/null +++ b/.changeset/invalidate-during-initial-fetch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Refetch once more when a query is invalidated while its initial fetch is in flight, so the result of a fetch that started before the invalidation no longer satisfies it. diff --git a/packages/query-core/src/__tests__/queryClient.test.tsx b/packages/query-core/src/__tests__/queryClient.test.tsx index d76bd14da4..04340dbdc8 100644 --- a/packages/query-core/src/__tests__/queryClient.test.tsx +++ b/packages/query-core/src/__tests__/queryClient.test.tsx @@ -2592,6 +2592,93 @@ describe('queryClient', () => { expect(queryFn).toHaveBeenCalledTimes(1) unsubscribe() }) + + it('should refetch once more when invalidated during the initial fetch', async () => { + const key = queryKey() + let serverState = 'before' + const queryFn = vi.fn(() => sleep(10).then(() => serverState)) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) + + // the initial fetch is still in flight and knows nothing about this data + serverState = 'after' + const invalidated = queryClient.invalidateQueries({ queryKey: key }) + + await vi.advanceTimersByTimeAsync(20) + await invalidated + + expect(queryFn).toHaveBeenCalledTimes(2) + expect(queryClient.getQueryData(key)).toBe('after') + unsubscribe() + }) + + it('should coalesce invalidations during the initial fetch into one refetch', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) + + const invalidated = Promise.all([ + queryClient.invalidateQueries({ queryKey: key }), + queryClient.invalidateQueries({ queryKey: key }), + queryClient.invalidateQueries({ queryKey: key }), + ]) + + await vi.advanceTimersByTimeAsync(20) + await invalidated + + expect(queryFn).toHaveBeenCalledTimes(2) + unsubscribe() + }) + + it('should not refetch after the initial fetch when "refetchType" is "none"', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) + + await queryClient.invalidateQueries({ + queryKey: key, + refetchType: 'none', + }) + await vi.advanceTimersByTimeAsync(20) + + expect(queryFn).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('should not refetch once more when the invalidation starts the fetch', async () => { + const key = queryKey() + const queryFn = vi.fn(() => sleep(10).then(() => 'data')) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + const unsubscribe = observer.subscribe(noop) + + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).toHaveBeenCalledTimes(1) + + const invalidated = queryClient.invalidateQueries({ queryKey: key }) + await vi.advanceTimersByTimeAsync(20) + await invalidated + + expect(queryFn).toHaveBeenCalledTimes(2) + unsubscribe() + }) }) describe('resetQueries', () => { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index bc35f0fceb..9ba1a3dbd6 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -242,11 +242,13 @@ export class Query< observers: Array> #defaultOptions?: QueryOptions #abortSignalConsumed: boolean + #invalidatedDuringFetch: boolean constructor(config: QueryConfig) { super() this.#abortSignalConsumed = false + this.#invalidatedDuringFetch = false this.#defaultOptions = config.defaultOptions this.setOptions(config.options) this.observers = [] @@ -566,12 +568,20 @@ export class Query< * updates `state.isInvalidated` and notifies observers, but does not by * itself trigger a refetch. * + * A fetch that is already in flight started before this invalidation, so its + * result cannot satisfy it. That is remembered here so the next refetch runs + * once more after the in-flight fetch settles instead of just reusing it. + * * @example * ```ts * query.invalidate() * ``` */ invalidate(): void { + if (this.state.fetchStatus !== 'idle') { + this.#invalidatedDuringFetch = true + } + if (!this.state.isInvalidated) { this.#dispatch({ type: 'invalidate' }) } @@ -604,11 +614,23 @@ export class Query< } else if (this.#retryer) { // make sure that retries that were potentially cancelled due to unmounts can continue this.#retryer.continueRetry() + if (this.#invalidatedDuringFetch) { + // The in-flight fetch predates the invalidation, so its result would + // drop the refetch intent. Let it settle to provide a first result, + // then fetch once more. Further callers coalesce onto that fetch, + // because starting it clears the flag below. + return this.#retryer.promise.then(() => + this.fetch(options, { ...fetchOptions, cancelRefetch: false }), + ) + } // Return current promise if we are already fetching return this.#retryer.promise } } + // A fetch starting now runs after any invalidation, so it can satisfy it + this.#invalidatedDuringFetch = false + // Update config if passed, otherwise the config from the last execution is used if (options) { this.setOptions(options)