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)