From 131504a8908b391abb009e2c475835d5ca472d21 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 19 Aug 2026 17:36:23 -0700 Subject: [PATCH] fix(query-core): Keep observer notifications stable Removing an observer in place can shift the active notification loop and skip the next observer. Iterate over a shallow copy so synchronous unsubscriptions keep the old behavior without giving up the faster removal path from #11214. Co-Authored-By: Codex --- .changeset/stable-observers-notify.md | 5 +++++ .../query-core/src/__tests__/query.test.tsx | 21 +++++++++++++++++++ packages/query-core/src/query.ts | 4 +++- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .changeset/stable-observers-notify.md diff --git a/.changeset/stable-observers-notify.md b/.changeset/stable-observers-notify.md new file mode 100644 index 0000000000..d53bbf22b3 --- /dev/null +++ b/.changeset/stable-observers-notify.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Notify every query observer when another observer unsubscribes synchronously during the same query update. diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index f4edc0d0c1..bc637fcc0f 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -824,6 +824,27 @@ describe('query', () => { notifySpy.mockRestore() }) + it('should notify remaining observers when one unsubscribes during an update', () => { + const key = queryKey() + const options = { queryKey: key, enabled: false } + const firstObserver = new QueryObserver(queryClient, options) + const secondObserver = new QueryObserver(queryClient, options) + const secondListener = vi.fn() + + let unsubscribeFirst: () => void = () => undefined + unsubscribeFirst = firstObserver.subscribe(() => { + unsubscribeFirst() + }) + const unsubscribeSecond = secondObserver.subscribe(secondListener) + + queryClient.setQueryData(key, 'data') + + expect(secondListener).toHaveBeenCalledTimes(1) + expect(secondObserver.getCurrentResult().data).toBe('data') + + unsubscribeSecond() + }) + it('should not change state on invalidate() if already invalidated', async () => { const key = queryKey() diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 53811c8ebb..245ec28378 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -704,7 +704,9 @@ export class Query< this.state = reducer(this.state) notifyManager.batch(() => { - this.observers.forEach((observer) => { + // Keep the current iteration stable if an observer unsubscribes + // synchronously while it is being notified. + this.observers.slice().forEach((observer) => { observer.onQueryUpdate() })