Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-feet-wink.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/framework/react/plugins/broadcastQueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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', () => {
Expand Down
11 changes: 8 additions & 3 deletions packages/query-broadcast-client-experimental/src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
}
Expand Down