Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/broadcast-client-transaction-stuck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-broadcast-client-experimental': patch
---

fix(broadcast-client): recover from errors thrown while applying an incoming cross-tab message instead of permanently disabling this tab's own broadcasts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { QueryCache } from '@tanstack/query-core'

const mockPostMessage = vi.fn().mockResolvedValue(undefined)
const mockClose = vi.fn()
let lastCreatedChannel: { onmessage: ((action: any) => void) | null }

vi.mock('broadcast-channel', async (importOriginal) => {
const actual = await importOriginal()
Expand All @@ -16,6 +17,9 @@ vi.mock('broadcast-channel', async (importOriginal) => {
onmessage = null
postMessage = mockPostMessage
close = mockClose
constructor() {
lastCreatedChannel = this
}
},
}
})
Expand Down Expand Up @@ -48,6 +52,40 @@ describe('broadcastQueryClient', () => {
expect(queryCache.hasListeners()).toBe(false)
})

describe('incoming message handling', () => {
it('should keep broadcasting local changes after applying an incoming message throws', () => {
broadcastQueryClient({
queryClient,
broadcastChannel: 'test_channel',
})

// Simulate some other part of the app (e.g. another cache subscriber)
// throwing synchronously while an incoming cross-tab message is being
// applied locally.
const explodingUnsubscribe = queryCache.subscribe(() => {
throw new Error('boom from an unrelated cache listener')
})

expect(() => {
lastCreatedChannel.onmessage?.({
type: 'added',
queryHash: '["remote"]',
queryKey: ['remote'],
state: { data: 1 },
})
}).toThrow('boom')

explodingUnsubscribe()
mockPostMessage.mockClear()

// A later local change must still be broadcast to other tabs, instead
// of being silently swallowed because the transaction flag got stuck.
queryClient.setQueryData(['local'], { value: 1 })

expect(mockPostMessage).toHaveBeenCalled()
})
})

Comment thread
coderabbitai[bot] marked this conversation as resolved.
describe('postMessage error handling', () => {
let originalEnv: string | undefined

Expand Down
12 changes: 10 additions & 2 deletions packages/query-broadcast-client-experimental/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,16 @@ export function broadcastQueryClient({
let transaction = false
const tx = (cb: () => void) => {
transaction = true
cb()
transaction = false
try {
cb()
} finally {
// Guard against `cb` throwing (e.g. `query.setState`/`queryCache.build`
// triggering a listener that throws while applying an incoming
// cross-tab message). Without this, `transaction` would stay `true`
// forever, silently disabling this tab's own broadcasts to other tabs
// for the rest of the session.
transaction = false
}
}

const channel = new BroadcastChannel(broadcastChannel, {
Expand Down