From 969cdeb4ff926c33986f90b0f47f05fca901ef7d Mon Sep 17 00:00:00 2001 From: Shahid Hassan Ansari <41151902+iamshahid1997@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:45:04 +0530 Subject: [PATCH] fix(query-core): re-attach MutationObserver to its in-flight mutation on resubscribe When the last listener unsubscribes, onUnsubscribe() removes the observer from its current mutation, but resubscribing never added it back. If React tore down and re-established the subscription while a mutation was in flight (StrictMode, hide/show, re-suspending boundaries), the mutation settled against an empty observer list and the observer kept serving its frozen 'pending' snapshot forever. Mirror QueryObserver.onSubscribe(): when the first listener subscribes, re-attach to the current mutation and refresh the result in case the mutation settled while detached. Fixes #11171 --- .../src/__tests__/mutationObserver.test.tsx | 50 +++++++++++++++++++ packages/query-core/src/mutationObserver.ts | 10 ++++ 2 files changed, 60 insertions(+) diff --git a/packages/query-core/src/__tests__/mutationObserver.test.tsx b/packages/query-core/src/__tests__/mutationObserver.test.tsx index 7c72e21e40c..a339d91c838 100644 --- a/packages/query-core/src/__tests__/mutationObserver.test.tsx +++ b/packages/query-core/src/__tests__/mutationObserver.test.tsx @@ -499,4 +499,54 @@ describe('mutationObserver', () => { unsubscribe() }) + + it('should track an in-flight mutation again after unsubscribing and resubscribing', async () => { + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => sleep(20).then(() => text), + }) + + const unsubscribe = mutationObserver.subscribe(() => undefined) + mutationObserver.mutate('input') + await vi.advanceTimersByTimeAsync(0) + + // React tears down and re-establishes subscriptions while keeping the + // component state (StrictMode, , re-suspending boundaries) + unsubscribe() + const subscriptionHandler = vi.fn() + mutationObserver.subscribe(subscriptionHandler) + + await vi.advanceTimersByTimeAsync(20) + + expect(mutationObserver.getCurrentResult()).toMatchObject({ + status: 'success', + data: 'input', + }) + expect(subscriptionHandler).toHaveBeenCalledTimes(1) + expect(subscriptionHandler).toHaveBeenCalledWith( + expect.objectContaining({ status: 'success', data: 'input' }), + ) + }) + + it('should report the final state of a mutation that settled while unsubscribed', async () => { + const mutationObserver = new MutationObserver(queryClient, { + mutationFn: (text: string) => sleep(20).then(() => text), + }) + + const unsubscribe = mutationObserver.subscribe(() => undefined) + mutationObserver.mutate('input') + await vi.advanceTimersByTimeAsync(0) + + unsubscribe() + + // mutation settles while no one is subscribed + await vi.advanceTimersByTimeAsync(20) + expect(mutationObserver.getCurrentResult().status).toBe('pending') + + mutationObserver.subscribe(() => undefined) + + expect(mutationObserver.getCurrentResult()).toMatchObject({ + status: 'success', + data: 'input', + }) + }) }) diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 21523963ceb..78d4810078d 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -93,6 +93,16 @@ export class MutationObserver< } } + protected onSubscribe(): void { + if (this.listeners.size === 1 && this.#currentMutation) { + // re-attach to the mutation the first listener unsubscribing detached us + // from, and refresh the result in case the mutation settled while we + // were not watching it + this.#currentMutation.addObserver(this) + this.#updateResult() + } + } + protected onUnsubscribe(): void { if (!this.hasListeners()) { this.#currentMutation?.removeObserver(this)