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 21523963ceb..350c8436cf7 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.listeners.size === 1 && this.#currentMutation) { + this.#currentMutation.addObserver(this) + this.#updateResult() + this.#notify() + } + } + onMutationUpdate( action: Action, ): void {