From ac3136ac567200da168ff0590ae205839548b7ec Mon Sep 17 00:00:00 2001 From: "Leonardo A. Guimaraes" Date: Sun, 26 Jul 2026 11:41:36 -0300 Subject: [PATCH 1/2] fix(query-core): reconnect MutationObserver on resubscribe (StrictMode fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add onSubscribe() to MutationObserver to reconnect the observer to an in-progress mutation when the component resubscribes (e.g. React's StrictMode double-mount cycle). Without this, the observer is removed from the mutation's observers list on cleanup and never reconnects, causing isPending to stay true indefinitely. QueryObserver already has the same pattern — this makes MutationObserver consistent with it. --- packages/query-core/src/mutationObserver.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 21523963ceb..5fbf8059f25 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -99,6 +99,14 @@ export class MutationObserver< } } + protected onSubscribe(): void { + if (this.#currentMutation) { + this.#currentMutation.addObserver(this) + this.#updateResult() + this.#notify() + } + } + onMutationUpdate( action: Action, ): void { From b849bce4b42661f8785e2069ab350246339e0c32 Mon Sep 17 00:00:00 2001 From: "Leonardo A. Guimaraes" Date: Sun, 9 Aug 2026 20:25:53 -0300 Subject: [PATCH 2/2] fix(query-core): guard MutationObserver onSubscribe reattach with size===1 Apply Miller Marru's review suggestion: only reattach the observer to the current mutation when the first listener subscribes (listeners.size === 1), matching QueryObserver.onSubscribe's pattern exactly. Add regression test proving the observer reconnects to an in-flight mutation on resubscribe (StrictMode double-mount cycle). Verified RED on main (test fails without onSubscribe) and GREEN with the fix. --- .changeset/mutation-observer-resubscribe.md | 7 ++++ .../src/__tests__/mutationObserver.test.tsx | 32 +++++++++++++++++++ packages/query-core/src/mutationObserver.ts | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .changeset/mutation-observer-resubscribe.md diff --git a/.changeset/mutation-observer-resubscribe.md b/.changeset/mutation-observer-resubscribe.md new file mode 100644 index 00000000000..582750e1d69 --- /dev/null +++ b/.changeset/mutation-observer-resubscribe.md @@ -0,0 +1,7 @@ +--- +'@tanstack/query-core': patch +--- + +fix(query-core): reconnect MutationObserver to the current mutation on resubscribe (StrictMode fix) + +Previously, when a MutationObserver unsubscribed (e.g. React StrictMode's unmount cycle) while a mutation was in flight, it was removed from the mutation's observers list and never reattached on resubscribe, leaving `isPending` stuck as `true` indefinitely. `MutationObserver` now implements `onSubscribe()` to reattach to the current mutation — the same pattern `QueryObserver` already relies on. diff --git a/packages/query-core/src/__tests__/mutationObserver.test.tsx b/packages/query-core/src/__tests__/mutationObserver.test.tsx index 7c72e21e40c..cd4e322bda5 100644 --- a/packages/query-core/src/__tests__/mutationObserver.test.tsx +++ b/packages/query-core/src/__tests__/mutationObserver.test.tsx @@ -41,6 +41,38 @@ describe('mutationObserver', () => { unsubscribe2() }) + it('should reconnect the observer to the current mutation on resubscribe', async () => { + const mutation = new MutationObserver(queryClient, { + mutationFn: (text: string) => sleep(20).then(() => text), + }) + + const subscriptionHandler = vi.fn() + const unsubscribe = mutation.subscribe(subscriptionHandler) + + mutation.mutate('input') + expect(subscriptionHandler).toHaveBeenCalledTimes(1) + expect(mutation.getCurrentResult()).toMatchObject({ status: 'pending' }) + + // Simulates React StrictMode's unmount: the observer is removed from the mutation + unsubscribe() + + // Simulates StrictMode's remount: the observer should reattach to the in-flight mutation + mutation.subscribe(subscriptionHandler) + + await vi.advanceTimersByTimeAsync(20) + + expect(subscriptionHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'success', + data: 'input', + }), + ) + expect(mutation.getCurrentResult()).toMatchObject({ + status: 'success', + data: 'input', + }) + }) + it('unsubscribe should remove observer to trigger GC', async () => { const mutation = new MutationObserver(queryClient, { mutationFn: (text: string) => sleep(5).then(() => text), diff --git a/packages/query-core/src/mutationObserver.ts b/packages/query-core/src/mutationObserver.ts index 5fbf8059f25..350c8436cf7 100644 --- a/packages/query-core/src/mutationObserver.ts +++ b/packages/query-core/src/mutationObserver.ts @@ -100,7 +100,7 @@ export class MutationObserver< } protected onSubscribe(): void { - if (this.#currentMutation) { + if (this.listeners.size === 1 && this.#currentMutation) { this.#currentMutation.addObserver(this) this.#updateResult() this.#notify()