diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index febe71cf4c..3344e91bc9 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1192,4 +1192,91 @@ describe('query', () => { expect(initialDataFn).toHaveBeenCalledTimes(1) expect(query.state.data).toBe('initial data') }) + + test('should not override fetching state when revert happens after new observer subscribes', async () => { + const key = queryKey() + + const queryFn = vi.fn(async ({ signal: _signal }) => { + // Destructure `signal` to intentionally consume it so observer-removal uses revert-cancel path + await sleep(50) + return 'data' + }) + + const query = new Query({ + client: queryClient, + queryKey: key, + queryHash: hashQueryKeyByOptions(key), + options: { queryFn }, + }) + + const observer1 = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + + query.addObserver(observer1) + const promise1 = query.fetch() + + await vi.advanceTimersByTimeAsync(10) + + query.removeObserver(observer1) + + const observer2 = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + + query.addObserver(observer2) + + query.fetch() + + await expect(promise1).rejects.toBeInstanceOf(CancelledError) + await vi.waitFor(() => expect(query.state.fetchStatus).toBe('fetching')) + + expect(query.state.fetchStatus).toBe('fetching') + }) + + test('should throw CancelledError when revert happens with no data after observer removal', async () => { + const key = queryKey() + + const queryFn = vi.fn(async ({ signal: _signal }) => { + // Destructure `signal` to intentionally consume it so observer-removal uses revert-cancel path + await sleep(50) + return 'data' + }) + + const query = new Query({ + client: queryClient, + queryKey: key, + queryHash: hashQueryKeyByOptions(key), + options: { queryFn }, + }) + + const observer1 = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + + query.addObserver(observer1) + const promise1 = query.fetch() + + await vi.advanceTimersByTimeAsync(5) + + query.removeObserver(observer1) + + const observer2 = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + }) + + query.addObserver(observer2) + query.fetch() + + await expect(promise1).rejects.toThrow(CancelledError) + + expect(query.state.fetchStatus).toBe('fetching') + + await vi.advanceTimersByTimeAsync(50) + expect(query.state.data).toBe('data') + }) }) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index df5b7c030e..57745542ef 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -349,7 +349,7 @@ export class Query< // we'll let the query continue so the result can be cached if (this.#retryer) { if (this.#abortSignalConsumed) { - this.#retryer.cancel({ revert: true }) + this.#retryer.cancel({ revert: true, isObserverRemoval: true }) } else { this.#retryer.cancelRetry() } @@ -553,16 +553,25 @@ export class Query< // so we hatch onto that promise return this.#retryer.promise } else if (error.revert) { + // If cancellation was caused by observer removal and there are active observers again, + // do not revert to idle: a new fetch may already be in flight, and reverting would + // incorrectly flip isLoading/isFetching to false under StrictMode remounts. + if (error.isObserverRemoval && this.isActive()) { + if (this.state.data === undefined) { + throw error + } + return this.state.data + } + this.setState({ ...this.#revertState, fetchStatus: 'idle' as const, }) - // transform error into reverted state data - // if the initial fetch was cancelled, we have no data, so we have - // to get reject with a CancelledError + if (this.state.data === undefined) { throw error } + return this.state.data } } diff --git a/packages/query-core/src/retryer.ts b/packages/query-core/src/retryer.ts index add23dcd05..cc03e09e3c 100644 --- a/packages/query-core/src/retryer.ts +++ b/packages/query-core/src/retryer.ts @@ -58,10 +58,12 @@ export function canFetch(networkMode: NetworkMode | undefined): boolean { export class CancelledError extends Error { revert?: boolean silent?: boolean + isObserverRemoval?: boolean constructor(options?: CancelOptions) { super('CancelledError') this.revert = options?.revert this.silent = options?.silent + this.isObserverRemoval = options?.isObserverRemoval } } diff --git a/packages/query-core/src/types.ts b/packages/query-core/src/types.ts index df6ea8c173..0e5c654a74 100644 --- a/packages/query-core/src/types.ts +++ b/packages/query-core/src/types.ts @@ -1329,6 +1329,7 @@ export interface DefaultOptions { export interface CancelOptions { revert?: boolean silent?: boolean + isObserverRemoval?: boolean } export interface SetDataOptions {