From 2da48f28d46bd0d66a568a500a3585a191e811a2 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Thu, 22 Oct 2026 02:20:18 +0000 Subject: [PATCH] fix(query-broadcast-client): preserve local state on empty added events --- .changeset/thirty-feet-wink.md | 5 + .../react/plugins/broadcastQueryClient.md | 2 + .../src/__tests__/index.test.ts | 109 +++++++++++++++++- .../src/index.ts | 11 +- 4 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 .changeset/thirty-feet-wink.md diff --git a/.changeset/thirty-feet-wink.md b/.changeset/thirty-feet-wink.md new file mode 100644 index 00000000000..dc4a8f0b3a9 --- /dev/null +++ b/.changeset/thirty-feet-wink.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-broadcast-client-experimental": patch +--- + +Preserve local query errors and active fetches when another tab broadcasts an empty added query state. diff --git a/docs/framework/react/plugins/broadcastQueryClient.md b/docs/framework/react/plugins/broadcastQueryClient.md index d67b3422a21..59444d504ad 100644 --- a/docs/framework/react/plugins/broadcastQueryClient.md +++ b/docs/framework/react/plugins/broadcastQueryClient.md @@ -26,6 +26,8 @@ broadcastQueryClient({ }) ``` +Creating a query in another tab does not overwrite an existing local query with an empty initial state. This preserves local errors and active fetches. If the local query has no data, it can still adopt an added query's state when that state has a positive `dataUpdatedAt` or `errorUpdatedAt`. + ## API ### `broadcastQueryClient` diff --git a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts index bd3d3fcdc6d..1bbb3084f18 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -164,6 +164,80 @@ describe('broadcastQueryClient', () => { expect(queryClient.getQueryState(existingKey)?.status).toBe('success') }) + it('should preserve an existing query error when an empty "added" message arrives', async () => { + const existingKey = queryKey() + const error = new Error('down') + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + }) + + const existingQuery = queryCache.build(queryClient, { + queryKey: existingKey, + }) + const emptyState = existingQuery.state + + await expect( + queryClient.query({ + queryKey: existingKey, + queryFn: () => Promise.reject(error), + retry: false, + }), + ).rejects.toBe(error) + const errorState = existingQuery.state + expect(errorState.errorUpdatedAt).toBeGreaterThan(0) + + lastCreatedChannel.onmessage?.({ + type: 'added', + queryHash: existingQuery.queryHash, + queryKey: existingKey, + state: emptyState, + }) + + expect(existingQuery.state).toBe(errorState) + expect(existingQuery.state).toMatchObject({ + status: 'error', + fetchStatus: 'idle', + data: undefined, + error, + }) + }) + + it('should preserve an existing fetch when an empty "added" message arrives', async () => { + const existingKey = queryKey() + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + }) + + const existingQuery = queryCache.build(queryClient, { + queryKey: existingKey, + }) + const emptyState = existingQuery.state + const promise = queryClient.query({ + queryKey: existingKey, + queryFn: async () => { + await sleep(10) + return 'local data' + }, + }) + const fetchingState = existingQuery.state + expect(fetchingState.fetchStatus).toBe('fetching') + + lastCreatedChannel.onmessage?.({ + type: 'added', + queryHash: existingQuery.queryHash, + queryKey: existingKey, + state: emptyState, + }) + + expect(existingQuery.state).toBe(fetchingState) + expect(existingQuery.state.fetchStatus).toBe('fetching') + await expect(promise).resolves.toBe('local data') + }) + it('should adopt an "added" message\'s state for an existing query that has no data yet', () => { const existingKey = queryKey() @@ -184,12 +258,45 @@ describe('broadcastQueryClient', () => { type: 'added', queryHash: existingQuery.queryHash, queryKey: existingKey, - state: { status: 'success', data: { value: 'from other tab' } }, + state: { + status: 'success', + data: { value: 'from other tab' }, + dataUpdatedAt: Date.now(), + }, }) expect(queryClient.getQueryData(existingKey)).toStrictEqual({ value: 'from other tab', }) }) + + it('should adopt a settled error from an "added" message for an existing query without data', () => { + const existingKey = queryKey() + const error = new Error('remote error') + + broadcastQueryClient({ + queryClient, + broadcastChannel: 'test_channel', + }) + + const existingQuery = queryCache.build(queryClient, { + queryKey: existingKey, + }) + const state = { + ...existingQuery.state, + status: 'error' as const, + error, + errorUpdatedAt: Date.now(), + } + + lastCreatedChannel.onmessage?.({ + type: 'added', + queryHash: existingQuery.queryHash, + queryKey: existingKey, + state, + }) + + expect(existingQuery.state).toStrictEqual(state) + }) }) describe('postMessage error handling', () => { diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index c292b72353b..b59df879cdf 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -1,6 +1,6 @@ import { BroadcastChannel } from 'broadcast-channel' import type { BroadcastChannelOptions } from 'broadcast-channel' -import type { QueryClient, QueryKey } from '@tanstack/query-core' +import type { QueryClient, QueryKey, QueryState } from '@tanstack/query-core' /** * Metadata describing a broadcast that failed to be delivered to other tabs. @@ -192,8 +192,13 @@ export function broadcastQueryClient({ } } else if (type === 'added') { if (query) { - if (query.state.data === undefined) { - query.setState(state) + const incoming = state as QueryState + // An empty added state must not erase a local error or active fetch. + if ( + query.state.data === undefined && + (incoming.dataUpdatedAt > 0 || incoming.errorUpdatedAt > 0) + ) { + query.setState(incoming) } return }