((resolve) => {
+ resolveRestore = resolve
+ })
+ return [cleanup, restorePromise] as const
+ }),
+ }
+})
+
+vi.mock('@tanstack/query-broadcast-client-experimental', () => ({
+ broadcastQueryClientRestore: mockState.restore,
+}))
+
+afterEach(() => {
+ mockState.restore.mockClear()
+ mockState.cleanup.mockReset()
+})
+
+describe('withBroadcastQueryClient', () => {
+ it('holds an initially cached query until bootstrap completes', async () => {
+ const queryKey = ['broadcast-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'from-cache')
+
+ @Component({
+ template: '{{ state.data() ?? state.fetchStatus() }}
',
+ })
+ class Page {
+ state = injectQuery(() => ({
+ queryKey,
+ queryFn,
+ staleTime: Infinity,
+ }))
+ }
+
+ const rendered = await render(Page, {
+ providers: [
+ provideZonelessChangeDetection(),
+ provideTanStackQuery(
+ queryClient,
+ withBroadcastQueryClient({ broadcastChannel: 'test-channel' }),
+ ),
+ ],
+ })
+
+ expect(rendered.getByText('from-cache')).toBeInTheDocument()
+ expect(queryFn).not.toHaveBeenCalled()
+ expect(mockState.restore).toHaveBeenCalledWith({
+ broadcastChannel: 'test-channel',
+ queryClient,
+ })
+
+ mockState.resolveRestore()
+ await rendered.fixture.whenStable()
+
+ expect(rendered.getByText('from-cache')).toBeInTheDocument()
+ expect(queryFn).not.toHaveBeenCalled()
+
+ rendered.fixture.destroy()
+ })
+
+ it('allows a stale query to fetch after bootstrap completes', async () => {
+ const queryKey = ['broadcast-stale-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'stale', { updatedAt: 0 })
+
+ @Component({
+ template: '{{ state.data() ?? state.fetchStatus() }}
',
+ })
+ class Page {
+ state = injectQuery(() => ({
+ queryKey,
+ queryFn,
+ staleTime: 0,
+ }))
+ }
+
+ const rendered = await render(Page, {
+ providers: [
+ provideZonelessChangeDetection(),
+ provideTanStackQuery(
+ queryClient,
+ withBroadcastQueryClient({ broadcastChannel: 'test-channel' }),
+ ),
+ ],
+ })
+
+ expect(queryFn).not.toHaveBeenCalled()
+ mockState.resolveRestore()
+ await Promise.resolve()
+ rendered.fixture.detectChanges()
+ await rendered.fixture.whenStable()
+ rendered.fixture.detectChanges()
+
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.fixture.destroy()
+ })
+
+ it('does not leave server queries behind the restore gate', async () => {
+ const queryFn = vi.fn().mockResolvedValue('server')
+ const queryClient = new QueryClient()
+
+ @Component({
+ template: '{{ state.data() ?? state.fetchStatus() }}
',
+ })
+ class Page {
+ state = injectQuery(() => ({
+ queryKey: ['server-bootstrap'],
+ queryFn,
+ }))
+ }
+
+ const rendered = await render(Page, {
+ providers: [
+ { provide: PLATFORM_ID, useValue: 'server' },
+ provideZonelessChangeDetection(),
+ provideTanStackQuery(
+ queryClient,
+ withBroadcastQueryClient({ broadcastChannel: 'test-channel' }),
+ ),
+ ],
+ })
+
+ await rendered.fixture.whenStable()
+
+ expect(mockState.restore).not.toHaveBeenCalled()
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.fixture.destroy()
+ })
+})
diff --git a/packages/angular-query-persist-client/src/index.ts b/packages/angular-query-persist-client/src/index.ts
index 2f7546d1967..e5f97687128 100644
--- a/packages/angular-query-persist-client/src/index.ts
+++ b/packages/angular-query-persist-client/src/index.ts
@@ -2,3 +2,4 @@
export * from '@tanstack/query-persist-client-core'
export * from './with-persist-query-client'
+export * from './with-broadcast-query-client'
diff --git a/packages/angular-query-persist-client/src/with-broadcast-query-client.ts b/packages/angular-query-persist-client/src/with-broadcast-query-client.ts
new file mode 100644
index 00000000000..7be8b4a8af9
--- /dev/null
+++ b/packages/angular-query-persist-client/src/with-broadcast-query-client.ts
@@ -0,0 +1,63 @@
+import {
+ DestroyRef,
+ ENVIRONMENT_INITIALIZER,
+ PLATFORM_ID,
+ inject,
+ signal,
+} from '@angular/core'
+import { isPlatformBrowser } from '@angular/common'
+import { broadcastQueryClientRestore } from '@tanstack/query-broadcast-client-experimental'
+import {
+ QueryClient,
+ provideIsRestoring,
+ queryFeature,
+} from '@tanstack/angular-query-experimental'
+import type { BroadcastQueryClientRestoreOptions } from '@tanstack/query-broadcast-client-experimental'
+import type { BroadcastQueryClientFeature } from '@tanstack/angular-query-experimental'
+
+export type BroadcastQueryClientOptions = Omit<
+ BroadcastQueryClientRestoreOptions,
+ 'queryClient'
+>
+
+/**
+ * Enables cross-tab bootstrap and live synchronization.
+ *
+ * The restore signal remains active until the bounded bootstrap promise
+ * settles, preventing injectQuery and injectQueries from fetching an empty
+ * cache during initialization.
+ * @param broadcastOptions - Options for the broadcast restore session.
+ * @returns A feature for use with provideTanStackQuery.
+ */
+export function withBroadcastQueryClient(
+ broadcastOptions: BroadcastQueryClientOptions,
+): BroadcastQueryClientFeature {
+ const isRestoring = signal(true)
+ const providers = [
+ provideIsRestoring(isRestoring.asReadonly()),
+ {
+ provide: ENVIRONMENT_INITIALIZER,
+ multi: true,
+ useValue: () => {
+ if (!isPlatformBrowser(inject(PLATFORM_ID))) {
+ isRestoring.set(false)
+ return
+ }
+
+ const destroyRef = inject(DestroyRef)
+ const queryClient = inject(QueryClient)
+ const [cleanup, restorePromise] = broadcastQueryClientRestore({
+ ...broadcastOptions,
+ queryClient,
+ })
+
+ restorePromise.then(() => {
+ isRestoring.set(false)
+ })
+ destroyRef.onDestroy(cleanup)
+ },
+ },
+ ]
+
+ return queryFeature('BroadcastQueryClient', providers)
+}
diff --git a/packages/preact-query-persist-client/package.json b/packages/preact-query-persist-client/package.json
index ef66591379a..b0cdfbb32ae 100644
--- a/packages/preact-query-persist-client/package.json
+++ b/packages/preact-query-persist-client/package.json
@@ -55,6 +55,7 @@
"!src/__tests__"
],
"dependencies": {
+ "@tanstack/query-broadcast-client-experimental": "workspace:*",
"@tanstack/query-persist-client-core": "workspace:*"
},
"devDependencies": {
diff --git a/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx b/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx
new file mode 100644
index 00000000000..85d90d5fb99
--- /dev/null
+++ b/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx
@@ -0,0 +1,72 @@
+import { broadcastQueryClientRestore } from '@tanstack/query-broadcast-client-experimental'
+import {
+ IsRestoringProvider,
+ QueryClientProvider,
+ useIsRestoring,
+} from '@tanstack/preact-query'
+import { useEffect, useRef, useState } from 'preact/hooks'
+import type { VNode } from 'preact'
+import type { BroadcastQueryClientRestoreOptions } from '@tanstack/query-broadcast-client-experimental'
+import type {
+ OmitKeyof,
+ QueryClientProviderProps,
+} from '@tanstack/preact-query'
+
+export type BroadcastQueryClientProviderProps = QueryClientProviderProps & {
+ broadcastOptions: OmitKeyof
+}
+
+/**
+ * Provides a QueryClient while gating descendant queries during cross-tab
+ * cache bootstrap. The session is cleaned up when the client changes or the
+ * provider unmounts.
+ */
+export const BroadcastQueryClientProvider = ({
+ children,
+ broadcastOptions,
+ ...props
+}: BroadcastQueryClientProviderProps): VNode => {
+ const parentIsRestoring = useIsRestoring()
+ const [isRestoring, setIsRestoring] = useState(true)
+ const optionsRef = useRef(broadcastOptions)
+ const [previousClient, setPreviousClient] = useState(props.client)
+ const clientChanged = previousClient !== props.client
+
+ useEffect(() => {
+ optionsRef.current = broadcastOptions
+ })
+
+ useEffect(() => {
+ setPreviousClient(props.client)
+ }, [props.client])
+
+ useEffect(() => {
+ setIsRestoring(true)
+ let mounted = true
+ const [cleanup, restorePromise] = broadcastQueryClientRestore({
+ ...optionsRef.current,
+ queryClient: props.client,
+ })
+
+ restorePromise.then(() => {
+ if (mounted) {
+ setIsRestoring(false)
+ }
+ })
+
+ return () => {
+ mounted = false
+ cleanup()
+ }
+ }, [props.client])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx b/packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx
new file mode 100644
index 00000000000..74e764c5bab
--- /dev/null
+++ b/packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx
@@ -0,0 +1,246 @@
+/** @jsxRuntime automatic */
+/** @jsxImportSource preact */
+import { act, cleanup, render } from '@testing-library/preact'
+import { QueryClient, useQuery } from '@tanstack/preact-query'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BroadcastQueryClientProvider } from '../BroadcastQueryClientProvider'
+
+const mockState = vi.hoisted(() => {
+ const state: {
+ resolveRestore?: () => void
+ restoreResolvers: Array<() => void>
+ } = { restoreResolvers: [] }
+ const cleanupSession = vi.fn()
+ const restore = vi.fn(() => {
+ const restorePromise = new Promise((resolve) => {
+ state.restoreResolvers.push(resolve)
+ state.resolveRestore = resolve
+ })
+ return [cleanupSession, restorePromise] as const
+ })
+ return { cleanupSession, restore, state }
+})
+
+vi.mock('@tanstack/query-broadcast-client-experimental', () => ({
+ broadcastQueryClientRestore: mockState.restore,
+}))
+
+describe('BroadcastQueryClientProvider (preact)', () => {
+ beforeEach(() => {
+ mockState.cleanupSession.mockReset()
+ mockState.restore.mockClear()
+ mockState.state.resolveRestore = undefined
+ mockState.state.restoreResolvers.length = 0
+ })
+
+ afterEach(() => {
+ cleanup()
+ })
+
+ it('holds query observers until bootstrap completes', async () => {
+ const queryKey = ['preact-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'cache', { updatedAt: Date.now() })
+
+ function Page() {
+ const query = useQuery({
+ queryKey,
+ queryFn,
+ staleTime: Infinity,
+ })
+ return {query.data ?? query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ expect(rendered.getByText('cache')).toBeInTheDocument()
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+
+ expect(queryFn).not.toHaveBeenCalled()
+ rendered.unmount()
+ expect(mockState.cleanupSession).toHaveBeenCalledOnce()
+ })
+
+ it('refetches a stale query after bootstrap completes', async () => {
+ const queryKey = ['preact-stale-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'stale', { updatedAt: 0 })
+
+ function Page() {
+ const query = useQuery({ queryKey, queryFn, staleTime: 0 })
+ return {query.data ?? query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+
+ it('gates a replacement QueryClient before its restore effect runs', async () => {
+ const queryKey = ['replacement-client-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const firstClient = new QueryClient()
+ const replacementClient = new QueryClient()
+ firstClient.setQueryData(queryKey, 'first-cache', {
+ updatedAt: Date.now(),
+ })
+
+ function Page() {
+ const query = useQuery({ queryKey, queryFn, staleTime: Infinity })
+ return {query.data ?? query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ expect(queryFn).not.toHaveBeenCalled()
+ expect(mockState.restore).toHaveBeenCalledTimes(2)
+ expect(mockState.cleanupSession).toHaveBeenCalledOnce()
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ expect(mockState.cleanupSession).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not let old restore promises open a rapidly replaced client gate', async () => {
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const firstClient = new QueryClient()
+ const replacementClient = new QueryClient()
+ const finalClient = new QueryClient()
+
+ function Page() {
+ const query = useQuery({
+ queryKey: ['stale-restore-promise'],
+ queryFn,
+ })
+ return {query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ const firstRestore = mockState.state.restoreResolvers[0]
+ const replacementRestore = mockState.state.restoreResolvers[1]
+ const finalRestore = mockState.state.restoreResolvers[2]
+ expect(firstRestore).toEqual(expect.any(Function))
+ expect(replacementRestore).toEqual(expect.any(Function))
+ expect(finalRestore).toEqual(expect.any(Function))
+
+ await act(async () => {
+ firstRestore?.()
+ await Promise.resolve()
+ })
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await act(async () => {
+ replacementRestore?.()
+ await Promise.resolve()
+ })
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await act(async () => {
+ finalRestore?.()
+ await Promise.resolve()
+ })
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+
+ it('does not restart restore when the QueryClient is unchanged', () => {
+ const queryClient = new QueryClient()
+ const rendered = render(
+ ,
+ )
+
+ rendered.rerender(
+ ,
+ )
+
+ expect(mockState.restore).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+})
diff --git a/packages/preact-query-persist-client/src/index.ts b/packages/preact-query-persist-client/src/index.ts
index cd94f0dee94..ca82fe95cc3 100644
--- a/packages/preact-query-persist-client/src/index.ts
+++ b/packages/preact-query-persist-client/src/index.ts
@@ -2,3 +2,4 @@
export * from '@tanstack/query-persist-client-core'
export * from './PersistQueryClientProvider'
+export * from './BroadcastQueryClientProvider'
diff --git a/packages/query-broadcast-client-experimental/src/__tests__/api.test-d.ts b/packages/query-broadcast-client-experimental/src/__tests__/api.test-d.ts
new file mode 100644
index 00000000000..9f4e7e0c1cc
--- /dev/null
+++ b/packages/query-broadcast-client-experimental/src/__tests__/api.test-d.ts
@@ -0,0 +1,121 @@
+import { QueryClient } from '@tanstack/query-core'
+import { broadcastQueryClient, broadcastQueryClientRestore } from '..'
+import type {
+ BroadcastErrorEvent,
+ BroadcastQueryClientOptions,
+ BroadcastQueryClientRestoreOptions,
+ BroadcastRestoreErrorEvent,
+} from '..'
+import type { QueryKey } from '@tanstack/query-core'
+
+const queryClient = new QueryClient()
+
+broadcastQueryClient({ queryClient })
+broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test-channel',
+ respondToCacheRequests: true,
+})
+broadcastQueryClient({
+ queryClient,
+ respondToCacheRequests: false,
+})
+
+const shouldDehydrateQuery: NonNullable<
+ BroadcastQueryClientOptions['dehydrateOptions']
+>['shouldDehydrateQuery'] = (query) => {
+ const queryHash: string = query.queryHash
+ const queryKey: QueryKey = query.queryKey
+ return queryHash.length > 0 && queryKey.length > 0
+}
+
+const onBroadcastError: NonNullable<
+ BroadcastQueryClientOptions['onBroadcastError']
+> = (error, event) => {
+ const eventType: BroadcastErrorEvent['type'] = event.type
+ const queryHash: string = event.queryHash
+ const queryKey: QueryKey = event.queryKey
+
+ void error
+ void eventType
+ void queryHash
+ void queryKey
+
+ return Promise.resolve()
+}
+
+const onBroadcastRestoreError: NonNullable<
+ BroadcastQueryClientRestoreOptions['onBroadcastRestoreError']
+> = (error, event) => {
+ const eventType: BroadcastRestoreErrorEvent['type'] = event.type
+ const requestId: string = event.requestId
+ const responderId: string | undefined = event.responderId
+ const responseId: string | undefined = event.responseId
+ const queryHash: string | undefined = event.queryHash
+ const queryKey: QueryKey | undefined = event.queryKey
+
+ void error
+ void eventType
+ void requestId
+ void responderId
+ void responseId
+ void queryHash
+ void queryKey
+
+ return Promise.resolve()
+}
+
+broadcastQueryClient({
+ queryClient,
+ respondToCacheRequests: true,
+ dehydrateOptions: { shouldDehydrateQuery },
+ onBroadcastError,
+ onBroadcastRestoreError,
+})
+
+broadcastQueryClientRestore({
+ queryClient,
+ timeout: 250,
+ dehydrateOptions: { shouldDehydrateQuery },
+ hydrateOptions: {
+ defaultOptions: {
+ deserializeData: (data) => data,
+ },
+ },
+ onBroadcastError,
+ onBroadcastRestoreError,
+})
+
+broadcastQueryClientRestore({ queryClient })
+
+broadcastQueryClientRestore({
+ queryClient,
+ // @ts-expect-error Restore sessions respond to cache requests automatically.
+ respondToCacheRequests: false,
+})
+
+// @ts-expect-error queryClient is required by the public API.
+broadcastQueryClient({})
+
+// @ts-expect-error queryClient is required by the public API.
+broadcastQueryClientRestore({})
+
+broadcastQueryClient({
+ queryClient,
+ // @ts-expect-error hydrateOptions only applies while restoring a cache.
+ hydrateOptions: {},
+})
+
+broadcastQueryClientRestore({
+ queryClient,
+ // @ts-expect-error Restore sessions do not accept the live responder flag.
+ respondToCacheRequests: true,
+})
+
+broadcastQueryClientRestore({
+ queryClient,
+ dehydrateOptions: {
+ // @ts-expect-error Mutations are intentionally excluded from bootstrap snapshots.
+ shouldDehydrateMutation: () => true,
+ },
+})
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 73a8b0c914b..828c419516e 100644
--- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
+++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts
@@ -1,13 +1,24 @@
-import { QueryClient } from '@tanstack/query-core'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { QueryClient, QueryObserver } from '@tanstack/query-core'
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ expectTypeOf,
+ it,
+ vi,
+} from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
-import { broadcastQueryClient } from '..'
+import { broadcastQueryClient, broadcastQueryClientRestore } from '..'
import type { BroadcastErrorEvent } from '..'
import type { QueryCache } from '@tanstack/query-core'
const mockPostMessage = vi.fn().mockResolvedValue(undefined)
const mockClose = vi.fn()
let lastCreatedChannel: { onmessage: ((action: any) => void) | null }
+const createdChannels: Array<{
+ onmessage: ((action: any) => void) | null
+}> = []
vi.mock('broadcast-channel', async (importOriginal) => {
const actual = await importOriginal()
@@ -19,6 +30,7 @@ vi.mock('broadcast-channel', async (importOriginal) => {
close = mockClose
constructor() {
lastCreatedChannel = this
+ createdChannels.push(this)
}
},
}
@@ -32,7 +44,9 @@ describe('broadcastQueryClient', () => {
queryClient = new QueryClient()
queryCache = queryClient.getQueryCache()
mockPostMessage.mockResolvedValue(undefined)
+ mockPostMessage.mockClear()
mockClose.mockReset()
+ createdChannels.length = 0
})
it('should subscribe to the query cache', () => {
@@ -52,6 +66,1483 @@ describe('broadcastQueryClient', () => {
expect(queryCache.hasListeners()).toBe(false)
})
+ it('should request and restore a query snapshot before completing', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ expect(request).toEqual({
+ type: 'cache-request',
+ requestId: expect.any(String),
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'response-1',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: {
+ data: { value: 1 },
+ dataUpdateCount: 1,
+ dataUpdatedAt: 10,
+ error: null,
+ errorUpdateCount: 0,
+ errorUpdatedAt: 0,
+ fetchFailureCount: 0,
+ fetchFailureReason: null,
+ fetchMeta: null,
+ isInvalidated: false,
+ status: 'success',
+ fetchStatus: 'idle',
+ },
+ },
+ })
+
+ vi.advanceTimersByTime(100)
+ await restored
+
+ expect(queryClient.getQueryData(key)).toEqual({ value: 1 })
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should merge responses from multiple tabs by query freshness', async () => {
+ vi.useFakeTimers()
+ try {
+ const firstKey = queryKey()
+ const secondKey = queryKey()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ const sendResponse = (
+ key: typeof firstKey,
+ data: unknown,
+ dataUpdatedAt: number,
+ responseId: string,
+ ) => {
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: responseId,
+ responseId,
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: {
+ data,
+ dataUpdateCount: 1,
+ dataUpdatedAt,
+ error: null,
+ errorUpdateCount: 0,
+ errorUpdatedAt: 0,
+ fetchFailureCount: 0,
+ fetchFailureReason: null,
+ fetchMeta: null,
+ isInvalidated: false,
+ status: 'success',
+ fetchStatus: 'idle',
+ },
+ },
+ })
+ }
+
+ sendResponse(firstKey, 'old first', 1, 'response-1')
+ sendResponse(firstKey, 'new first', 2, 'response-2')
+ sendResponse(secondKey, 'old second', 1, 'response-3')
+ sendResponse(secondKey, 'new second', 2, 'response-4')
+
+ vi.advanceTimersByTime(100)
+ await restored
+
+ expect(queryClient.getQueryData(firstKey)).toBe('new first')
+ expect(queryClient.getQueryData(secondKey)).toBe('new second')
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should restore queries from multiple live responder sessions', async () => {
+ vi.useFakeTimers()
+ let requesterCleanup: (() => void) | undefined
+ let responderBCleanup: (() => void) | undefined
+ let responderCCleanup: (() => void) | undefined
+ try {
+ const responderBQueryClient = new QueryClient()
+ responderBQueryClient.setQueryData(['from-b'], 'data from B')
+ const responderCQueryClient = new QueryClient()
+ responderCQueryClient.setQueryData(['from-c'], 'data from C')
+
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ requesterCleanup = cleanup
+ const requesterChannel = createdChannels[0]
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ responderBCleanup = broadcastQueryClient({
+ queryClient: responderBQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ })
+ const responderBChannel = createdChannels[1]
+ responderCCleanup = broadcastQueryClient({
+ queryClient: responderCQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ })
+ const responderCChannel = createdChannels[2]
+
+ responderBChannel?.onmessage?.(request)
+ responderCChannel?.onmessage?.(request)
+
+ const responses = mockPostMessage.mock.calls
+ .slice(1)
+ .map(([message]) => message)
+ expect(responses).toHaveLength(2)
+ expect(
+ new Set(responses.map((response) => response.responderId)).size,
+ ).toBe(2)
+ expect(responses).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ query: expect.objectContaining({
+ queryKey: ['from-b'],
+ state: expect.objectContaining({ data: 'data from B' }),
+ }),
+ }),
+ expect.objectContaining({
+ query: expect.objectContaining({
+ queryKey: ['from-c'],
+ state: expect.objectContaining({ data: 'data from C' }),
+ }),
+ }),
+ ]),
+ )
+ responses.forEach((response) => requesterChannel?.onmessage?.(response))
+
+ let completed = false
+ void restored.then(() => {
+ completed = true
+ })
+ expect(completed).toBe(false)
+
+ await vi.advanceTimersByTimeAsync(100)
+ await restored
+
+ expect(queryClient.getQueryData(['from-b'])).toBe('data from B')
+ expect(queryClient.getQueryData(['from-c'])).toBe('data from C')
+ } finally {
+ responderCCleanup?.()
+ responderBCleanup?.()
+ requesterCleanup?.()
+ vi.useRealTimers()
+ }
+ })
+
+ it('should wait for the timeout when one peer remains silent', async () => {
+ vi.useFakeTimers()
+ let requesterCleanup: (() => void) | undefined
+ let responderCleanup: (() => void) | undefined
+ let silentPeerCleanup: (() => void) | undefined
+ try {
+ const responderQueryClient = new QueryClient()
+ responderQueryClient.setQueryData(['from-responder'], 'data')
+ const silentPeerQueryClient = new QueryClient()
+ silentPeerQueryClient.setQueryData(['from-silent-peer'], 'not sent')
+
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ requesterCleanup = cleanup
+ const requesterChannel = createdChannels[0]
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ responderCleanup = broadcastQueryClient({
+ queryClient: responderQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ })
+ const responderChannel = createdChannels[1]
+ silentPeerCleanup = broadcastQueryClient({
+ queryClient: silentPeerQueryClient,
+ broadcastChannel: 'test_channel',
+ })
+ const silentPeerChannel = createdChannels[2]
+
+ responderChannel?.onmessage?.(request)
+ // The protocol cannot distinguish a missing peer from a peer that
+ // receives the request but never sends a response.
+ silentPeerChannel?.onmessage?.(request)
+
+ const responses = mockPostMessage.mock.calls
+ .slice(1)
+ .map(([message]) => message)
+ expect(responses).toHaveLength(1)
+ expect(responses[0]?.query.queryKey).toEqual(['from-responder'])
+ requesterChannel?.onmessage?.(responses[0])
+
+ let completed = false
+ void restored.then(() => {
+ completed = true
+ })
+ await vi.advanceTimersByTimeAsync(99)
+
+ expect(completed).toBe(false)
+ expect(queryClient.getQueryData(['from-responder'])).toBe('data')
+
+ await vi.advanceTimersByTimeAsync(1)
+ await restored
+ expect(completed).toBe(true)
+ } finally {
+ silentPeerCleanup?.()
+ responderCleanup?.()
+ requesterCleanup?.()
+ vi.useRealTimers()
+ }
+ })
+
+ it('should keep the newest state when responders answer the same query out of order', async () => {
+ vi.useFakeTimers()
+ let requesterCleanup: (() => void) | undefined
+ let responderBCleanup: (() => void) | undefined
+ let responderCCleanup: (() => void) | undefined
+ try {
+ const sharedKey = ['shared']
+ const responderBQueryClient = new QueryClient()
+ responderBQueryClient.setQueryData(sharedKey, 'older', { updatedAt: 10 })
+ const responderCQueryClient = new QueryClient()
+ responderCQueryClient.setQueryData(sharedKey, 'newer', { updatedAt: 20 })
+
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ requesterCleanup = cleanup
+ const requesterChannel = createdChannels[0]
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ responderBCleanup = broadcastQueryClient({
+ queryClient: responderBQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ })
+ const responderBChannel = createdChannels[1]
+ responderCCleanup = broadcastQueryClient({
+ queryClient: responderCQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ })
+ const responderCChannel = createdChannels[2]
+
+ responderBChannel?.onmessage?.(request)
+ responderCChannel?.onmessage?.(request)
+
+ const responses = mockPostMessage.mock.calls
+ .slice(1)
+ .map(([message]) => message)
+ expect(responses).toHaveLength(2)
+
+ const olderResponse = responses.find(
+ (response) => response.query.state.dataUpdatedAt === 10,
+ )
+ const newerResponse = responses.find(
+ (response) => response.query.state.dataUpdatedAt === 20,
+ )
+ expect(olderResponse).toEqual(
+ expect.objectContaining({
+ query: expect.objectContaining({
+ state: expect.objectContaining({ data: 'older' }),
+ }),
+ }),
+ )
+ expect(newerResponse).toEqual(
+ expect.objectContaining({
+ query: expect.objectContaining({
+ state: expect.objectContaining({ data: 'newer' }),
+ }),
+ }),
+ )
+
+ // The responder with the newer data answers first; the older response
+ // must not degrade the state that was already hydrated.
+ requesterChannel?.onmessage?.(newerResponse)
+ requesterChannel?.onmessage?.(olderResponse)
+
+ await vi.advanceTimersByTimeAsync(100)
+ await restored
+
+ expect(queryClient.getQueryData(sharedKey)).toBe('newer')
+ } finally {
+ responderCCleanup?.()
+ responderBCleanup?.()
+ requesterCleanup?.()
+ vi.useRealTimers()
+ }
+ })
+
+ it('should ignore unknown and late bootstrap responses', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: 'unknown-request',
+ responderId: 'peer-1',
+ responseId: 'unknown-response',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'unknown', status: 'success', dataUpdatedAt: 1 },
+ },
+ })
+
+ vi.advanceTimersByTime(100)
+ await restored
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'late-response',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'late', status: 'success', dataUpdatedAt: 2 },
+ },
+ })
+
+ expect(queryClient.getQueryData(key)).toBeUndefined()
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should ignore duplicate and malformed bootstrap responses', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+ const response = {
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'response-1',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'first', status: 'success', dataUpdatedAt: 1 },
+ },
+ }
+
+ expect(() => {
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'malformed',
+ query: { queryKey: key },
+ })
+ }).not.toThrow()
+
+ lastCreatedChannel.onmessage?.(response)
+ lastCreatedChannel.onmessage?.({
+ ...response,
+ query: {
+ ...response.query,
+ state: { data: 'duplicate', status: 'success', dataUpdatedAt: 2 },
+ },
+ })
+
+ expect(queryClient.getQueryData(key)).toBe('first')
+ await vi.advanceTimersByTimeAsync(100)
+ await restored
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should correlate simultaneous bootstrap requests independently', async () => {
+ vi.useFakeTimers()
+ try {
+ const [firstCleanup, firstRestored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const firstRequest = mockPostMessage.mock.calls[0]?.[0]
+ const [secondCleanup, secondRestored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const secondRequest = mockPostMessage.mock.calls[1]?.[0]
+
+ expect(firstRequest.requestId).not.toBe(secondRequest.requestId)
+
+ await vi.advanceTimersByTimeAsync(100)
+ await Promise.all([firstRestored, secondRestored])
+ firstCleanup()
+ secondCleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should not rebroadcast bootstrap hydration', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+ const postCountBeforeHydration = mockPostMessage.mock.calls.length
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'response-1',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: {
+ data: { value: 1 },
+ dataUpdateCount: 1,
+ dataUpdatedAt: 1,
+ error: null,
+ errorUpdateCount: 0,
+ errorUpdatedAt: 0,
+ fetchFailureCount: 0,
+ fetchFailureReason: null,
+ isInvalidated: false,
+ status: 'success',
+ fetchStatus: 'idle',
+ },
+ },
+ })
+
+ expect(mockPostMessage).toHaveBeenCalledTimes(postCountBeforeHydration)
+ vi.advanceTimersByTime(100)
+ await restored
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should continue live synchronization after bootstrap completes', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 10,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'bootstrap-response',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'bootstrapped', status: 'success', dataUpdatedAt: 1 },
+ },
+ })
+ await vi.advanceTimersByTimeAsync(10)
+ await restored
+
+ mockPostMessage.mockClear()
+ queryClient.setQueryData(key, 'live-update')
+
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'updated',
+ queryKey: key,
+ state: expect.objectContaining({ data: 'live-update' }),
+ }),
+ )
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should not let an older live update overwrite fresher bootstrap data', () => {
+ const key = queryKey()
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ queryClient.setQueryData(key, 'new', { updatedAt: 20 })
+ const currentState = queryClient.getQueryState(key)!
+
+ lastCreatedChannel.onmessage?.({
+ type: 'updated',
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: {
+ ...currentState,
+ data: 'old',
+ dataUpdatedAt: 10,
+ },
+ })
+
+ expect(queryClient.getQueryData(key)).toBe('new')
+ cleanup()
+ })
+
+ it('should preserve the synchronous legacy API and avoid bootstrap requests', () => {
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ expect(cleanup).toEqual(expect.any(Function))
+ expect(mockPostMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'cache-request' }),
+ )
+
+ queryClient.setQueryData(['legacy-live-sync'], 'live')
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'added' }),
+ )
+
+ cleanup()
+ })
+
+ it('should expose stable synchronous and asynchronous API signatures', () => {
+ expectTypeOf(broadcastQueryClient).returns.toEqualTypeOf<() => void>()
+ expectTypeOf(broadcastQueryClientRestore).returns.toEqualTypeOf<
+ [() => void, Promise]
+ >()
+ })
+
+ it('should apply incoming added, updated, and removed messages', () => {
+ const addedKey = queryKey()
+ const updatedKey = queryKey()
+ const removedKey = queryKey()
+
+ queryClient.setQueryData(updatedKey, 'before', { updatedAt: 1 })
+ queryClient.setQueryData(removedKey, 'remove-me')
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'added',
+ queryHash: JSON.stringify(addedKey),
+ queryKey: addedKey,
+ state: { data: 'added', dataUpdatedAt: 2 },
+ })
+ lastCreatedChannel.onmessage?.({
+ type: 'updated',
+ queryHash: JSON.stringify(updatedKey),
+ queryKey: updatedKey,
+ state: { data: 'updated', dataUpdatedAt: 2 },
+ })
+ lastCreatedChannel.onmessage?.({
+ type: 'removed',
+ queryHash: JSON.stringify(removedKey),
+ queryKey: removedKey,
+ })
+ lastCreatedChannel.onmessage?.({
+ type: 'removed',
+ queryHash: JSON.stringify(queryKey()),
+ queryKey: queryKey(),
+ })
+
+ expect(queryClient.getQueryData(addedKey)).toBe('added')
+ expect(queryClient.getQueryData(updatedKey)).toBe('updated')
+ expect(queryClient.getQueryData(removedKey)).toBeUndefined()
+ cleanup()
+ })
+
+ it('should broadcast local additions and successful updates only', () => {
+ const key = queryKey()
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ queryClient.setQueryData(key, 'first')
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'added' }),
+ )
+
+ mockPostMessage.mockClear()
+ queryClient.setQueryData(key, 'second')
+ expect(mockPostMessage).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: 'updated' }),
+ )
+
+ mockPostMessage.mockClear()
+ const query = queryCache.find({ queryKey: key })!
+ query.setState({
+ ...query.state,
+ status: 'error',
+ error: new Error('local error'),
+ errorUpdatedAt: 3,
+ })
+ expect(mockPostMessage).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('should broadcast removals only while a query has observers', () => {
+ const key = queryKey()
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+ queryClient.setQueryData(key, 'value')
+ mockPostMessage.mockClear()
+
+ queryCache.remove(queryCache.find({ queryKey: key })!)
+ expect(mockPostMessage).not.toHaveBeenCalled()
+
+ const observedKey = queryKey()
+ queryClient.setQueryData(observedKey, 'observed')
+ const query = queryCache.find({ queryKey: observedKey })!
+ const observer = new QueryObserver(queryClient, {
+ queryKey: observedKey,
+ queryFn: () => Promise.resolve('observed'),
+ })
+ const unsubscribe = observer.subscribe(() => {})
+ mockPostMessage.mockClear()
+ queryCache.remove(query)
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'removed',
+ queryKey: observedKey,
+ }),
+ )
+ unsubscribe()
+ cleanup()
+ })
+
+ it('should honor custom query dehydration filters', async () => {
+ const peerQueryClient = new QueryClient()
+ peerQueryClient.setQueryData(['include'], 'included')
+ peerQueryClient.setQueryData(['exclude'], 'excluded')
+ const cleanup = broadcastQueryClientRestore({
+ queryClient: peerQueryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ dehydrateOptions: {
+ shouldDehydrateQuery: (query) => query.queryKey[0] === 'include',
+ },
+ })[0]
+
+ mockPostMessage.mockClear()
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'filtered-request',
+ })
+ await sleep(0)
+
+ expect(mockPostMessage).toHaveBeenCalledTimes(1)
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'cache-response',
+ requestId: 'filtered-request',
+ query: expect.objectContaining({ queryKey: ['include'] }),
+ }),
+ )
+ cleanup()
+ })
+
+ it('should bootstrap successful queries while excluding pending and error queries', () => {
+ const peerQueryClient = new QueryClient()
+ peerQueryClient.setQueryData(['success'], 'success')
+ const pendingQuery = peerQueryClient.getQueryCache().build(
+ peerQueryClient,
+ { queryKey: ['pending'] },
+ {
+ data: undefined,
+ dataUpdateCount: 0,
+ dataUpdatedAt: 0,
+ error: null,
+ errorUpdateCount: 0,
+ errorUpdatedAt: 0,
+ fetchFailureCount: 0,
+ fetchFailureReason: null,
+ fetchMeta: null,
+ fetchStatus: 'fetching',
+ isInvalidated: false,
+ status: 'pending',
+ },
+ )
+ const errorQuery = peerQueryClient.getQueryCache().build(
+ peerQueryClient,
+ { queryKey: ['error'] },
+ {
+ data: undefined,
+ dataUpdateCount: 0,
+ dataUpdatedAt: 0,
+ error: new Error('query error'),
+ errorUpdateCount: 1,
+ errorUpdatedAt: 1,
+ fetchFailureCount: 1,
+ fetchFailureReason: new Error('query error'),
+ fetchMeta: null,
+ fetchStatus: 'idle',
+ isInvalidated: false,
+ status: 'error',
+ },
+ )
+ expect(pendingQuery.state.status).toBe('pending')
+ expect(errorQuery.state.status).toBe('error')
+
+ const cleanup = broadcastQueryClientRestore({
+ queryClient: peerQueryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ })[0]
+ mockPostMessage.mockClear()
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'successful-only-request',
+ })
+
+ const responses = mockPostMessage.mock.calls.map(([message]) => message)
+ expect(responses).toHaveLength(1)
+ expect(responses[0]).toEqual(
+ expect.objectContaining({
+ type: 'cache-response',
+ query: expect.objectContaining({ queryKey: ['success'] }),
+ }),
+ )
+ cleanup()
+ })
+
+ it('should report errors thrown while dehydrating a response', () => {
+ const error = new Error('dehydrate failed')
+ const onBroadcastRestoreError = vi.fn()
+ queryClient.setQueryData(['dehydrate-error'], 'data')
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ dehydrateOptions: {
+ shouldDehydrateQuery: () => {
+ throw error
+ },
+ },
+ onBroadcastRestoreError,
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'dehydrate-error-request',
+ })
+
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ type: 'request',
+ requestId: 'dehydrate-error-request',
+ }),
+ )
+ cleanup()
+ })
+
+ it('should preserve a newer local query when an older snapshot arrives', () => {
+ const key = queryKey()
+ queryClient.setQueryData(key, 'new-local', { updatedAt: 20 })
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'old-response',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'old-remote', status: 'success', dataUpdatedAt: 10 },
+ },
+ })
+
+ expect(queryClient.getQueryData(key)).toBe('new-local')
+ cleanup()
+ })
+
+ it('should resolve using the default timeout when no timeout is provided', async () => {
+ vi.useFakeTimers()
+ try {
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+ let didRestore = false
+ void restored.then(() => {
+ didRestore = true
+ })
+
+ await vi.advanceTimersByTimeAsync(999)
+ expect(didRestore).toBe(false)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(didRestore).toBe(true)
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should resolve a zero timeout asynchronously without waiting', async () => {
+ vi.useFakeTimers()
+ try {
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ })
+ let didRestore = false
+ void restored.then(() => {
+ didRestore = true
+ })
+
+ expect(didRestore).toBe(false)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(didRestore).toBe(true)
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should ignore malformed live messages without throwing', () => {
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ expect(() => {
+ lastCreatedChannel.onmessage?.({ type: 'updated' })
+ lastCreatedChannel.onmessage?.({
+ type: 'updated',
+ queryHash: 'not-an-array-key',
+ queryKey: 'invalid',
+ state: {},
+ })
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 123,
+ })
+ }).not.toThrow()
+ expect(queryCache.getAll()).toHaveLength(0)
+ cleanup()
+ })
+
+ it('should make cleanup idempotent and close the channel once', () => {
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ cleanup()
+ cleanup()
+
+ expect(mockClose).toHaveBeenCalledOnce()
+ expect(queryCache.hasListeners()).toBe(false)
+ })
+
+ it('should report channel close failures through the restore error callback', async () => {
+ const error = new Error('close failed')
+ const onBroadcastRestoreError = vi.fn()
+ mockClose.mockRejectedValueOnce(error)
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ onBroadcastRestoreError,
+ })
+
+ cleanup()
+ await sleep(0)
+
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({ type: 'request' }),
+ )
+ })
+
+ it('should report synchronous channel close failures without throwing from cleanup', () => {
+ const error = new Error('synchronous close failed')
+ const onBroadcastRestoreError = vi.fn()
+ mockClose.mockImplementationOnce(() => {
+ throw error
+ })
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ onBroadcastRestoreError,
+ })
+
+ expect(() => cleanup()).not.toThrow()
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({ type: 'request' }),
+ )
+ })
+
+ it('should report restore callback failures without creating unhandled errors', () => {
+ const dehydrateError = new Error('dehydrate failed')
+ const callbackError = new Error('restore callback failed')
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ queryClient.setQueryData(['callback-error'], 'data')
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ dehydrateOptions: {
+ shouldDehydrateQuery: () => {
+ throw dehydrateError
+ },
+ },
+ onBroadcastRestoreError: () => {
+ throw callbackError
+ },
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'callback-error-request',
+ })
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Bootstrap request failed'),
+ callbackError,
+ )
+ warnSpy.mockRestore()
+ cleanup()
+ })
+
+ it('should report synchronous bootstrap request post failures', () => {
+ const error = new Error('synchronous request post failed')
+ const onBroadcastRestoreError = vi.fn()
+ mockPostMessage.mockImplementationOnce(() => {
+ throw error
+ })
+
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ onBroadcastRestoreError,
+ })
+
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({ type: 'request' }),
+ )
+ cleanup()
+ })
+
+ it('should ignore messages delivered after cleanup', () => {
+ const key = queryKey()
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+ const handler = lastCreatedChannel.onmessage
+ cleanup()
+
+ handler?.({
+ type: 'added',
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'late', dataUpdatedAt: 1 },
+ })
+
+ expect(queryClient.getQueryData(key)).toBeUndefined()
+ })
+
+ it('should build a query for an incoming updated message when it is absent', () => {
+ const key = queryKey()
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'updated',
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'updated', dataUpdatedAt: 1 },
+ })
+
+ expect(queryClient.getQueryData(key)).toBe('updated')
+ cleanup()
+ })
+
+ it('should ignore an older incoming added message for an existing query', () => {
+ const key = queryKey()
+ queryClient.setQueryData(key, 'newer', { updatedAt: 20 })
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'added',
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'older', dataUpdatedAt: 10 },
+ })
+
+ expect(queryClient.getQueryData(key)).toBe('newer')
+ cleanup()
+ })
+
+ it('should apply a newer incoming added message for an existing query', () => {
+ const key = queryKey()
+ queryClient.setQueryData(key, 'older', { updatedAt: 10 })
+ const cleanup = broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'added',
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'newer', dataUpdatedAt: 20 },
+ })
+
+ expect(queryClient.getQueryData(key)).toBe('newer')
+ cleanup()
+ })
+
+ it('should warn when a restore error has no restore callback', () => {
+ const previousNodeEnv = process.env['NODE_ENV']
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ process.env['NODE_ENV'] = 'development'
+ queryClient.setQueryData(['restore-warning'], 'data')
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 0,
+ dehydrateOptions: {
+ shouldDehydrateQuery: () => {
+ throw new Error('restore warning')
+ },
+ },
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'restore-warning-request',
+ })
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Bootstrap request failed'),
+ expect.any(Error),
+ )
+ cleanup()
+ warnSpy.mockRestore()
+ process.env['NODE_ENV'] = previousNodeEnv
+ })
+
+ it('should not answer bootstrap requests from a plain live-sync session', () => {
+ const key = queryKey()
+ const peerQueryClient = new QueryClient()
+ peerQueryClient.setQueryData(key, { from: 'peer' })
+ const peerCleanup = broadcastQueryClient({
+ queryClient: peerQueryClient,
+ broadcastChannel: 'test_channel',
+ })
+
+ try {
+ const peerChannel = createdChannels[0]!
+ peerChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'request-1',
+ })
+
+ expect(mockPostMessage).not.toHaveBeenCalled()
+ } finally {
+ peerCleanup()
+ }
+ })
+
+ it('should answer filtered bootstrap requests from an opted-in live-sync session', () => {
+ const peerQueryClient = new QueryClient()
+ peerQueryClient.setQueryData(['include'], 'included')
+ peerQueryClient.setQueryData(['exclude'], 'excluded')
+ const peerCleanup = broadcastQueryClient({
+ queryClient: peerQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ dehydrateOptions: {
+ shouldDehydrateQuery: (query) => query.queryKey[0] === 'include',
+ },
+ })
+
+ try {
+ const peerChannel = createdChannels[0]!
+ peerChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'filtered-request',
+ })
+
+ expect(mockPostMessage).toHaveBeenCalledTimes(1)
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'cache-response',
+ requestId: 'filtered-request',
+ query: expect.objectContaining({ queryKey: ['include'] }),
+ }),
+ )
+ } finally {
+ peerCleanup()
+ }
+ })
+
+ it('should resolve restore after a timeout with no peer response', async () => {
+ vi.useFakeTimers()
+ let cleanup: (() => void) | undefined
+ try {
+ const [restoreCleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ cleanup = restoreCleanup
+ let completed = false
+ void restored.then(() => {
+ completed = true
+ })
+
+ expect(completed).toBe(false)
+ await vi.advanceTimersByTimeAsync(100)
+ expect(completed).toBe(true)
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
+ } finally {
+ cleanup?.()
+ vi.useRealTimers()
+ }
+ })
+
+ it('should settle restore and ignore responses after cleanup', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ cleanup()
+ await restored
+ expect(lastCreatedChannel.onmessage).toBeNull()
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'response-1',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: { data: 'late', status: 'success', dataUpdatedAt: 1 },
+ },
+ })
+
+ expect(queryClient.getQueryData(key)).toBeUndefined()
+ expect(mockClose).toHaveBeenCalledOnce()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should report hydration errors without rejecting restore', async () => {
+ vi.useFakeTimers()
+ try {
+ const key = queryKey()
+ const error = new Error('deserialize failed')
+ const onBroadcastRestoreError = vi.fn()
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ onBroadcastRestoreError,
+ hydrateOptions: {
+ defaultOptions: {
+ deserializeData: () => {
+ throw error
+ },
+ },
+ },
+ })
+ const request = mockPostMessage.mock.calls[0]?.[0]
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId: 'peer-1',
+ responseId: 'response-1',
+ query: {
+ queryHash: JSON.stringify(key),
+ queryKey: key,
+ state: {
+ data: 'value',
+ status: 'success',
+ dataUpdatedAt: 1,
+ },
+ },
+ })
+
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ type: 'hydrate',
+ requestId: request.requestId,
+ queryHash: JSON.stringify(key),
+ }),
+ )
+ await vi.advanceTimersByTimeAsync(100)
+ await restored
+ expect(queryClient.getQueryData(key)).toBeUndefined()
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should not include mutations in a bootstrap response', () => {
+ const includedQueryKey = ['included-query']
+ const peerQueryClient = new QueryClient()
+ peerQueryClient.setQueryData(includedQueryKey, 'included')
+ peerQueryClient.getMutationCache().build(
+ peerQueryClient,
+ {
+ mutationKey: ['mutation'],
+ mutationFn: () => Promise.resolve('mutation'),
+ },
+ {
+ context: undefined,
+ data: undefined,
+ error: null,
+ failureCount: 0,
+ failureReason: null,
+ isPaused: true,
+ status: 'pending',
+ variables: undefined,
+ submittedAt: 0,
+ },
+ )
+ const peerCleanup = broadcastQueryClient({
+ queryClient: peerQueryClient,
+ broadcastChannel: 'test_channel',
+ respondToCacheRequests: true,
+ })
+
+ try {
+ mockPostMessage.mockClear()
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'request-1',
+ })
+
+ expect(mockPostMessage).toHaveBeenCalledTimes(1)
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'cache-response',
+ requestId: 'request-1',
+ query: expect.objectContaining({ queryKey: includedQueryKey }),
+ }),
+ )
+ expect(mockPostMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'cache-response',
+ query: expect.objectContaining({ queryKey: ['mutation'] }),
+ }),
+ )
+ } finally {
+ peerCleanup()
+ }
+ })
+
+ it('should reject invalid restore timeout values synchronously', () => {
+ expect(() =>
+ broadcastQueryClientRestore({
+ queryClient,
+ timeout: Number.NaN,
+ }),
+ ).toThrow('timeout must be non-negative')
+
+ expect(() =>
+ broadcastQueryClientRestore({
+ queryClient,
+ timeout: -1,
+ }),
+ ).toThrow('timeout must be non-negative')
+ })
+
+ it('should report bootstrap request failures without leaving restore pending', async () => {
+ vi.useFakeTimers()
+ try {
+ const error = new Error('request failed')
+ const onBroadcastRestoreError = vi.fn()
+ mockPostMessage.mockRejectedValueOnce(error)
+
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ onBroadcastRestoreError,
+ })
+
+ await vi.advanceTimersByTimeAsync(0)
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ type: 'request',
+ }),
+ )
+
+ await vi.advanceTimersByTimeAsync(100)
+ await restored
+ cleanup()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('should isolate a non-cloneable query response from valid responses', async () => {
+ const peerQueryClient = new QueryClient()
+ peerQueryClient.setQueryData(['bad'], { value: 'bad' })
+ peerQueryClient.setQueryData(['good'], { value: 'good' })
+ const error = new DOMException('DataCloneError', 'DataCloneError')
+ const onBroadcastRestoreError = vi.fn()
+
+ mockPostMessage.mockImplementation((message) => {
+ if (
+ message.type === 'cache-response' &&
+ message.query.queryKey[0] === 'bad'
+ ) {
+ return Promise.reject(error)
+ }
+ return Promise.resolve()
+ })
+
+ const [cleanup] = broadcastQueryClientRestore({
+ queryClient: peerQueryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ onBroadcastRestoreError,
+ })
+
+ lastCreatedChannel.onmessage?.({
+ type: 'cache-request',
+ requestId: 'request-1',
+ })
+
+ await sleep(0)
+ expect(onBroadcastRestoreError).toHaveBeenCalledWith(
+ error,
+ expect.objectContaining({
+ type: 'response',
+ queryHash: JSON.stringify(['bad']),
+ }),
+ )
+ expect(mockPostMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'cache-response',
+ query: expect.objectContaining({
+ queryKey: ['good'],
+ }),
+ }),
+ )
+ cleanup()
+ })
+
+ it('should not create an unhandled rejection from restore error callbacks', async () => {
+ vi.useFakeTimers()
+ try {
+ const error = new Error('request failed')
+ const unhandledRejections: Array = []
+ const onUnhandledRejection = (reason: unknown) => {
+ unhandledRejections.push(reason)
+ }
+ process.on('unhandledRejection', onUnhandledRejection)
+ mockPostMessage.mockRejectedValueOnce(error)
+
+ try {
+ const [cleanup, restored] = broadcastQueryClientRestore({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ timeout: 100,
+ onBroadcastRestoreError: () =>
+ Promise.reject(new Error('callback failed')),
+ })
+
+ await vi.advanceTimersByTimeAsync(100)
+ await restored
+ cleanup()
+ expect(unhandledRejections).toHaveLength(0)
+ } finally {
+ process.off('unhandledRejection', onUnhandledRejection)
+ }
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
describe('incoming message handling', () => {
it('should keep broadcasting local changes after applying an incoming message throws', () => {
const remoteKey = queryKey()
@@ -300,6 +1791,33 @@ describe('broadcastQueryClient', () => {
)
})
+ it('should call onBroadcastError when postMessage throws synchronously', async () => {
+ const key = queryKey()
+ const cloneError = new DOMException('DataCloneError', 'DataCloneError')
+ mockPostMessage.mockImplementationOnce(() => {
+ throw cloneError
+ })
+
+ const onBroadcastError = vi.fn()
+ broadcastQueryClient({
+ queryClient,
+ broadcastChannel: 'test_channel',
+ onBroadcastError,
+ })
+
+ queryClient.setQueryData(key, { value: 1 })
+
+ await sleep(0)
+ expect(onBroadcastError).toHaveBeenCalledWith(
+ cloneError,
+ expect.objectContaining({
+ type: 'added',
+ queryHash: expect.any(String) as string,
+ queryKey: key,
+ }),
+ )
+ })
+
it('should warn in dev when postMessage fails and onBroadcastError is not provided', async () => {
const key = queryKey()
process.env['NODE_ENV'] = 'development'
diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts
index b1f16fb4160..da8a0d53812 100644
--- a/packages/query-broadcast-client-experimental/src/index.ts
+++ b/packages/query-broadcast-client-experimental/src/index.ts
@@ -1,11 +1,19 @@
+import { dehydrate, hydrate } from '@tanstack/query-core'
import { BroadcastChannel } from 'broadcast-channel'
+import type {
+ DehydrateOptions,
+ DehydratedState,
+ HydrateOptions,
+ QueryClient,
+ QueryKey,
+ QueryState,
+} from '@tanstack/query-core'
import type { BroadcastChannelOptions } from 'broadcast-channel'
-import type { QueryClient, QueryKey } from '@tanstack/query-core'
/**
* Metadata describing a broadcast that failed to be delivered to other tabs.
- * Passed to {@link BroadcastQueryClientOptions.onBroadcastError} so callers
- * can correlate failures with the originating query.
+ * Passed to BroadcastQueryClientOptions.onBroadcastError so callers can
+ * correlate failures with the originating query.
*/
export interface BroadcastErrorEvent {
type: 'updated' | 'removed' | 'added'
@@ -13,12 +21,46 @@ export interface BroadcastErrorEvent {
queryKey: QueryKey
}
-type BroadcastMessage =
- | { type: 'updated'; queryHash: string; queryKey: QueryKey; state: unknown }
+/**
+ * Metadata describing a bootstrap request, response, or hydration failure.
+ */
+export interface BroadcastRestoreErrorEvent {
+ type: 'request' | 'response' | 'hydrate'
+ requestId: string
+ responderId?: string
+ responseId?: string
+ queryHash?: string
+ queryKey?: QueryKey
+}
+
+type LiveBroadcastMessage =
+ | {
+ type: 'updated'
+ queryHash: string
+ queryKey: QueryKey
+ state: QueryState
+ }
| { type: 'removed'; queryHash: string; queryKey: QueryKey }
- | { type: 'added'; queryHash: string; queryKey: QueryKey; state: unknown }
+ | { type: 'added'; queryHash: string; queryKey: QueryKey; state: QueryState }
+
+type CacheRequest = {
+ type: 'cache-request'
+ requestId: string
+}
+
+type DehydratedQuery = DehydratedState['queries'][number]
+
+type CacheResponse = {
+ type: 'cache-response'
+ requestId: string
+ responderId: string
+ responseId: string
+ query: DehydratedQuery
+}
+
+type BroadcastMessage = LiveBroadcastMessage | CacheRequest | CacheResponse
-interface BroadcastQueryClientOptions {
+export interface BroadcastQueryClientOptions {
/** The QueryClient to sync. */
queryClient: QueryClient
/**
@@ -26,56 +68,168 @@ interface BroadcastQueryClientOptions {
* @default 'tanstack-query'
*/
broadcastChannel?: string
- /** Options forwarded to the underlying `BroadcastChannel`. */
+ /** Options forwarded to the underlying BroadcastChannel. */
options?: BroadcastChannelOptions
/**
* Called when a query event fails to broadcast to other tabs — most
- * commonly because the query's `state.data`, `state.error`, or `queryKey`
- * contains a value the structured-clone algorithm cannot serialize
- * (e.g. `ReadableStream`, `File`, functions, Vue `reactive` proxies).
+ * commonly because the query's state.data, state.error, or queryKey
+ * contains a value the structured-clone algorithm cannot serialize.
*
* Provide this to route failures to an error tracker. If omitted, a
- * `console.warn` is emitted in development so failures are never silent.
+ * console.warn is emitted in development so failures are never silent.
*
- * May return a `Promise`; any rejection is caught internally so it cannot
+ * May return a Promise; any rejection is caught internally so it cannot
* cause a secondary unhandled rejection.
*/
onBroadcastError?: (
error: unknown,
event: BroadcastErrorEvent,
) => void | Promise
+ /**
+ * Allows this live-sync session to respond to bootstrap requests from
+ * restore-enabled sessions. `broadcastQueryClientRestore` responds
+ * automatically and does not expose this option.
+ * @default false
+ */
+ respondToCacheRequests?: boolean
+ /**
+ * Query dehydration options used when this session responds to bootstrap
+ * requests. They have no effect on a live-only session unless
+ * `respondToCacheRequests` is enabled. Mutations are never included in
+ * bootstrap snapshots.
+ */
+ dehydrateOptions?: Pick
+ /**
+ * Called when a bootstrap request, response, or hydration operation fails.
+ * Live synchronization failures continue to use `onBroadcastError`.
+ */
+ onBroadcastRestoreError?: (
+ error: unknown,
+ event: BroadcastRestoreErrorEvent,
+ ) => void | Promise
+}
+
+/**
+ * Options for live synchronization with an initial cache bootstrap. The
+ * restore session responds to cache requests automatically.
+ */
+export interface BroadcastQueryClientRestoreOptions extends Omit<
+ BroadcastQueryClientOptions,
+ 'respondToCacheRequests'
+> {
+ /**
+ * Maximum time to wait for responses from configured responder sessions.
+ * @default 1000
+ */
+ timeout?: number
+ /** Options used when applying each incoming query snapshot. */
+ hydrateOptions?: HydrateOptions
+}
+
+type InternalOptions = BroadcastQueryClientOptions & {
+ restoreOptions?: BroadcastQueryClientRestoreOptions
}
-export function broadcastQueryClient({
+type Session = {
+ cleanup: () => void
+ restorePromise?: Promise
+}
+
+const DEFAULT_RESTORE_TIMEOUT = 1000
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function isCacheRequest(value: unknown): value is CacheRequest {
+ return (
+ isRecord(value) &&
+ value.type === 'cache-request' &&
+ typeof value.requestId === 'string'
+ )
+}
+
+function isCacheResponse(value: unknown): value is CacheResponse {
+ if (
+ !isRecord(value) ||
+ value.type !== 'cache-response' ||
+ typeof value.requestId !== 'string' ||
+ typeof value.responderId !== 'string' ||
+ typeof value.responseId !== 'string' ||
+ !isRecord(value.query)
+ ) {
+ return false
+ }
+
+ return (
+ typeof value.query.queryHash === 'string' &&
+ Array.isArray(value.query.queryKey) &&
+ isRecord(value.query.state)
+ )
+}
+
+function isLiveBroadcastMessage(value: unknown): value is LiveBroadcastMessage {
+ if (
+ !isRecord(value) ||
+ (value.type !== 'updated' &&
+ value.type !== 'removed' &&
+ value.type !== 'added') ||
+ typeof value.queryHash !== 'string' ||
+ !Array.isArray(value.queryKey)
+ ) {
+ return false
+ }
+
+ return value.type === 'removed' || isRecord(value.state)
+}
+
+function createId(prefix: string): string {
+ return (
+ prefix +
+ '-' +
+ Date.now().toString(36) +
+ '-' +
+ Math.random().toString(36).slice(2)
+ )
+}
+
+function createBroadcastSession({
queryClient,
broadcastChannel = 'tanstack-query',
options,
onBroadcastError,
-}: BroadcastQueryClientOptions): () => void {
+ respondToCacheRequests,
+ dehydrateOptions,
+ onBroadcastRestoreError,
+ restoreOptions,
+}: InternalOptions): Session {
+ const timeout = restoreOptions?.timeout ?? DEFAULT_RESTORE_TIMEOUT
+
+ if (!Number.isFinite(timeout) || timeout < 0) {
+ throw new Error('broadcastQueryClientRestore timeout must be non-negative')
+ }
+
let transaction = false
+ let cleaned = false
const tx = (cb: () => void) => {
transaction = true
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.
+ // Guard against cb throwing while applying an incoming message.
transaction = false
}
}
- const channel = new BroadcastChannel(broadcastChannel, {
+ const channel = new BroadcastChannel(broadcastChannel, {
webWorkerSupport: false,
...options,
})
const queryCache = queryClient.getQueryCache()
- const safePost = (message: BroadcastMessage): void => {
- channel.postMessage(message).catch((error: unknown) => {
+ const safePost = (message: LiveBroadcastMessage): void => {
+ const handleError = (error: unknown) => {
const event: BroadcastErrorEvent = {
type: message.type,
queryHash: message.queryHash,
@@ -86,7 +240,11 @@ export function broadcastQueryClient({
const warnCallbackError = (callbackError: unknown) => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
- `[broadcastQueryClient] onBroadcastError threw while handling "${event.type}" for query ${event.queryHash}.`,
+ '[broadcastQueryClient] onBroadcastError threw while handling "' +
+ event.type +
+ '" for query ' +
+ event.queryHash +
+ '.',
callbackError,
)
}
@@ -101,16 +259,184 @@ export function broadcastQueryClient({
result?.catch(warnCallbackError)
} else if (process.env.NODE_ENV !== 'production') {
console.warn(
- `[broadcastQueryClient] Failed to broadcast "${event.type}" event for query ${event.queryHash}. ` +
+ '[broadcastQueryClient] Failed to broadcast "' +
+ event.type +
+ '" event for query ' +
+ event.queryHash +
+ '. ' +
'The query value could not be structured-cloned; cross-tab sync for this query was skipped.',
error,
)
}
+ }
+
+ try {
+ Promise.resolve(channel.postMessage(message)).catch(handleError)
+ } catch (error) {
+ handleError(error)
+ }
+ }
+
+ let resolveRestore = () => {}
+ let restoreActive = !!restoreOptions
+ let restoreTimer: ReturnType | undefined
+ const handledResponseIds = new Set()
+ const requestId = restoreOptions ? createId('request') : undefined
+ const responderId = createId('responder')
+ let responseNumber = 0
+
+ const restorePromise = restoreOptions
+ ? new Promise((resolve) => {
+ resolveRestore = resolve
+ })
+ : undefined
+
+ const warnRestoreError = (
+ error: unknown,
+ event: BroadcastRestoreErrorEvent,
+ ) => {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(
+ '[broadcastQueryClient] Bootstrap ' +
+ event.type +
+ ' failed for request ' +
+ event.requestId +
+ '.',
+ error,
+ )
+ }
+ }
+
+ const reportRestoreError = (
+ error: unknown,
+ event: BroadcastRestoreErrorEvent,
+ ) => {
+ if (!onBroadcastRestoreError) {
+ warnRestoreError(error, event)
+ return
+ }
+
+ let result: void | Promise
+ try {
+ result = onBroadcastRestoreError(error, event)
+ } catch (callbackError) {
+ warnRestoreError(callbackError, event)
+ return
+ }
+
+ result?.catch((callbackError) => {
+ warnRestoreError(callbackError, event)
})
}
+ const completeRestore = () => {
+ if (!restoreActive) {
+ return
+ }
+ restoreActive = false
+ if (restoreTimer !== undefined) {
+ clearTimeout(restoreTimer)
+ restoreTimer = undefined
+ }
+ resolveRestore()
+ }
+
+ const safePostRestore = (
+ message: CacheResponse | CacheRequest,
+ event: BroadcastRestoreErrorEvent,
+ ) => {
+ try {
+ Promise.resolve(channel.postMessage(message)).catch((error: unknown) => {
+ reportRestoreError(error, event)
+ })
+ } catch (error) {
+ reportRestoreError(error, event)
+ }
+ }
+
+ /**
+ * Answers bootstrap requests only for restore sessions or explicitly opted-in
+ * live-sync sessions, preserving the legacy API's live-only behavior.
+ */
+ const respondToCacheRequest = (request: CacheRequest) => {
+ if (!restoreOptions && !respondToCacheRequests) {
+ return
+ }
+
+ let snapshot: DehydratedState
+ try {
+ snapshot = dehydrate(queryClient, {
+ ...dehydrateOptions,
+ ...restoreOptions?.dehydrateOptions,
+ shouldDehydrateMutation: () => false,
+ })
+ } catch (error) {
+ reportRestoreError(error, {
+ type: 'request',
+ requestId: request.requestId,
+ responderId,
+ })
+ return
+ }
+
+ for (const query of snapshot.queries) {
+ responseNumber += 1
+ const responseId = responderId + '-' + responseNumber
+ safePostRestore(
+ {
+ type: 'cache-response',
+ requestId: request.requestId,
+ responderId,
+ responseId,
+ query,
+ },
+ {
+ type: 'response',
+ requestId: request.requestId,
+ responderId,
+ responseId,
+ queryHash: query.queryHash,
+ queryKey: query.queryKey,
+ },
+ )
+ }
+ }
+
+ const applyCacheResponse = (message: CacheResponse) => {
+ if (!restoreOptions || !restoreActive || message.requestId !== requestId) {
+ return
+ }
+
+ if (handledResponseIds.has(message.responseId)) {
+ return
+ }
+ handledResponseIds.add(message.responseId)
+
+ try {
+ tx(() => {
+ hydrate(
+ queryClient,
+ {
+ mutations: [],
+ queries: [message.query],
+ },
+ restoreOptions.hydrateOptions,
+ )
+ })
+ } catch (error) {
+ reportRestoreError(error, {
+ type: 'hydrate',
+ requestId: message.requestId,
+ responderId: message.responderId,
+ responseId: message.responseId,
+ queryHash: message.query.queryHash,
+ queryKey: message.query.queryKey,
+ })
+ }
+ }
+
const unsubscribe = queryCache.subscribe((queryEvent) => {
- if (transaction) {
+ if (transaction || cleaned) {
return
}
@@ -146,18 +472,34 @@ export function broadcastQueryClient({
})
channel.onmessage = (action) => {
- if (!action?.type) {
+ if (cleaned) {
return
}
- tx(() => {
- const { type, queryHash, queryKey, state } = action
+ if (isCacheRequest(action)) {
+ respondToCacheRequest(action)
+ return
+ }
+
+ if (isCacheResponse(action)) {
+ applyCacheResponse(action)
+ return
+ }
+
+ if (!isLiveBroadcastMessage(action)) {
+ return
+ }
+ tx(() => {
+ const { type, queryHash, queryKey } = action
const query = queryCache.get(queryHash)
if (type === 'updated') {
if (query) {
- query.setState(state)
+ if (action.state.dataUpdatedAt < query.state.dataUpdatedAt) {
+ return
+ }
+ query.setState(action.state)
return
}
@@ -167,15 +509,18 @@ export function broadcastQueryClient({
queryKey,
queryHash,
},
- state,
+ action.state,
)
} else if (type === 'removed') {
if (query) {
queryCache.remove(query)
}
- } else if (type === 'added') {
+ } else {
if (query) {
- query.setState(state)
+ if (action.state.dataUpdatedAt < query.state.dataUpdatedAt) {
+ return
+ }
+ query.setState(action.state)
return
}
queryCache.build(
@@ -184,13 +529,76 @@ export function broadcastQueryClient({
queryKey,
queryHash,
},
- state,
+ action.state,
)
}
})
}
- return () => {
+
+ if (restoreOptions && requestId) {
+ restoreTimer = setTimeout(completeRestore, timeout)
+ safePostRestore(
+ {
+ type: 'cache-request',
+ requestId,
+ },
+ {
+ type: 'request',
+ requestId,
+ },
+ )
+ }
+
+ const cleanup = () => {
+ if (cleaned) {
+ return
+ }
+ cleaned = true
+ completeRestore()
unsubscribe()
- channel.close()
+ channel.onmessage = null
+ const reportCloseError = (error: unknown) => {
+ if (restoreOptions && requestId) {
+ reportRestoreError(error, {
+ type: 'request',
+ requestId,
+ })
+ }
+ }
+ try {
+ void Promise.resolve(channel.close()).catch(reportCloseError)
+ } catch (error) {
+ reportCloseError(error)
+ }
}
+
+ return { cleanup, restorePromise }
+}
+
+/**
+ * Starts live synchronization for a QueryClient without requesting an initial
+ * cache snapshot. Set `respondToCacheRequests` to opt into answering restore
+ * requests from other sessions.
+ * @returns A function that stops synchronization and closes the channel.
+ */
+export function broadcastQueryClient(
+ options: BroadcastQueryClientOptions,
+): () => void {
+ return createBroadcastSession(options).cleanup
+}
+
+/**
+ * Starts live synchronization, automatically answers restore requests, and
+ * requests an initial cache snapshot before resolving the restore promise.
+ * @returns Cleanup and a promise that settles when restore or cleanup completes.
+ */
+export function broadcastQueryClientRestore(
+ options: BroadcastQueryClientRestoreOptions,
+): [cleanup: () => void, restorePromise: Promise] {
+ const session = createBroadcastSession({
+ ...options,
+ restoreOptions: options,
+ })
+
+ return [session.cleanup, session.restorePromise!]
}
diff --git a/packages/react-query-persist-client/package.json b/packages/react-query-persist-client/package.json
index 287ce6a78bd..fb5b197c3d5 100644
--- a/packages/react-query-persist-client/package.json
+++ b/packages/react-query-persist-client/package.json
@@ -56,6 +56,7 @@
"!src/__tests__"
],
"dependencies": {
+ "@tanstack/query-broadcast-client-experimental": "workspace:*",
"@tanstack/query-persist-client-core": "workspace:*"
},
"devDependencies": {
diff --git a/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx b/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx
new file mode 100644
index 00000000000..c699fbbe608
--- /dev/null
+++ b/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx
@@ -0,0 +1,70 @@
+'use client'
+import * as React from 'react'
+
+import { broadcastQueryClientRestore } from '@tanstack/query-broadcast-client-experimental'
+import {
+ IsRestoringProvider,
+ QueryClientProvider,
+ useIsRestoring,
+} from '@tanstack/react-query'
+import type { BroadcastQueryClientRestoreOptions } from '@tanstack/query-broadcast-client-experimental'
+import type { OmitKeyof, QueryClientProviderProps } from '@tanstack/react-query'
+
+export type BroadcastQueryClientProviderProps = QueryClientProviderProps & {
+ broadcastOptions: OmitKeyof
+}
+
+/**
+ * Provides a QueryClient while gating descendant queries during cross-tab
+ * cache bootstrap. The session is cleaned up when the client changes or the
+ * provider unmounts.
+ */
+export const BroadcastQueryClientProvider = ({
+ children,
+ broadcastOptions,
+ ...props
+}: BroadcastQueryClientProviderProps): React.JSX.Element => {
+ const parentIsRestoring = useIsRestoring()
+ const [isRestoring, setIsRestoring] = React.useState(true)
+ const optionsRef = React.useRef(broadcastOptions)
+ const [previousClient, setPreviousClient] = React.useState(props.client)
+ const clientChanged = previousClient !== props.client
+
+ React.useEffect(() => {
+ optionsRef.current = broadcastOptions
+ })
+
+ React.useEffect(() => {
+ setPreviousClient(props.client)
+ }, [props.client])
+
+ React.useEffect(() => {
+ setIsRestoring(true)
+ let mounted = true
+ const [cleanup, restorePromise] = broadcastQueryClientRestore({
+ ...optionsRef.current,
+ queryClient: props.client,
+ })
+
+ restorePromise.then(() => {
+ if (mounted) {
+ setIsRestoring(false)
+ }
+ })
+
+ return () => {
+ mounted = false
+ cleanup()
+ }
+ }, [props.client])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx b/packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx
new file mode 100644
index 00000000000..a961adc46ef
--- /dev/null
+++ b/packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx
@@ -0,0 +1,329 @@
+import { act, render } from '@testing-library/react'
+import {
+ IsRestoringProvider,
+ QueryClient,
+ useQuery,
+} from '@tanstack/react-query'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BroadcastQueryClientProvider } from '../BroadcastQueryClientProvider'
+
+const mockState = vi.hoisted(() => {
+ const state: {
+ resolveRestore?: () => void
+ restoreResolvers: Array<() => void>
+ } = { restoreResolvers: [] }
+ const cleanup = vi.fn()
+ const restore = vi.fn(() => {
+ const restorePromise = new Promise((resolve) => {
+ state.restoreResolvers.push(resolve)
+ state.resolveRestore = resolve
+ })
+ return [cleanup, restorePromise] as const
+ })
+ return { cleanup, restore, state }
+})
+
+vi.mock('@tanstack/query-broadcast-client-experimental', () => ({
+ broadcastQueryClientRestore: mockState.restore,
+}))
+
+describe('BroadcastQueryClientProvider', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ mockState.cleanup.mockReset()
+ mockState.restore.mockClear()
+ mockState.state.resolveRestore = undefined
+ mockState.state.restoreResolvers.length = 0
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('prevents a fresh query from fetching before bootstrap completes', async () => {
+ const queryKey = ['bootstrap-provider']
+ const queryFn = vi.fn().mockResolvedValue('from-network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'from-cache', {
+ updatedAt: Date.now(),
+ })
+
+ function Page() {
+ const query = useQuery({
+ queryKey,
+ queryFn,
+ staleTime: Infinity,
+ })
+
+ return {query.data ?? query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ expect(rendered.getByText('from-cache')).toBeInTheDocument()
+ expect(queryFn).not.toHaveBeenCalled()
+ expect(mockState.restore).toHaveBeenCalledWith({
+ broadcastChannel: 'test-channel',
+ queryClient,
+ })
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+
+ expect(rendered.getByText('from-cache')).toBeInTheDocument()
+ expect(queryFn).not.toHaveBeenCalled()
+
+ rendered.unmount()
+ expect(mockState.cleanup).toHaveBeenCalledOnce()
+ })
+
+ it('allows a stale query to refetch after bootstrap completes', async () => {
+ const queryKey = ['stale-bootstrap-provider']
+ const queryFn = vi.fn().mockResolvedValue('fresh-data')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'stale-cache', { updatedAt: 0 })
+
+ function Page() {
+ const query = useQuery({
+ queryKey,
+ queryFn,
+ staleTime: 0,
+ })
+
+ return {query.data ?? query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+
+ await act(async () => {
+ await Promise.resolve()
+ })
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+
+ it('preserves a parent restore gate after local bootstrap completes', async () => {
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+
+ function Page() {
+ const query = useQuery({
+ queryKey: ['parent-restore-gate'],
+ queryFn,
+ })
+ return {query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+
+
+ ,
+ )
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+
+ expect(queryFn).not.toHaveBeenCalled()
+ rendered.unmount()
+ })
+
+ it('allows an empty query to fetch after bootstrap completes', async () => {
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+
+ function Page() {
+ const query = useQuery({
+ queryKey: ['empty-bootstrap'],
+ queryFn,
+ })
+ return {query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ expect(queryFn).not.toHaveBeenCalled()
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+
+ it('gates a replacement QueryClient before its restore effect runs', async () => {
+ const queryKey = ['replacement-client-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const firstClient = new QueryClient()
+ const replacementClient = new QueryClient()
+ firstClient.setQueryData(queryKey, 'first-cache', {
+ updatedAt: Date.now(),
+ })
+
+ function Page() {
+ const query = useQuery({ queryKey, queryFn, staleTime: Infinity })
+ return {query.data ?? query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ expect(queryFn).not.toHaveBeenCalled()
+ expect(mockState.restore).toHaveBeenCalledTimes(2)
+ expect(mockState.cleanup).toHaveBeenCalledOnce()
+
+ await act(() => {
+ mockState.state.resolveRestore?.()
+ })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ expect(mockState.cleanup).toHaveBeenCalledTimes(2)
+ })
+
+ it('does not let old restore promises open a rapidly replaced client gate', async () => {
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const firstClient = new QueryClient()
+ const replacementClient = new QueryClient()
+ const finalClient = new QueryClient()
+
+ function Page() {
+ const query = useQuery({
+ queryKey: ['stale-restore-promise'],
+ queryFn,
+ })
+ return {query.fetchStatus}
+ }
+
+ const rendered = render(
+
+
+ ,
+ )
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ rendered.rerender(
+
+
+ ,
+ )
+
+ const firstRestore = mockState.state.restoreResolvers[0]
+ const replacementRestore = mockState.state.restoreResolvers[1]
+ const finalRestore = mockState.state.restoreResolvers[2]
+ expect(firstRestore).toEqual(expect.any(Function))
+ expect(replacementRestore).toEqual(expect.any(Function))
+ expect(finalRestore).toEqual(expect.any(Function))
+
+ await act(async () => {
+ firstRestore?.()
+ await Promise.resolve()
+ })
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await act(async () => {
+ replacementRestore?.()
+ await Promise.resolve()
+ })
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await act(async () => {
+ finalRestore?.()
+ await Promise.resolve()
+ })
+ expect(queryFn).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+
+ it('does not restart restore when the QueryClient is unchanged', () => {
+ const queryClient = new QueryClient()
+ const rendered = render(
+ ,
+ )
+
+ rendered.rerender(
+ ,
+ )
+
+ expect(mockState.restore).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+})
diff --git a/packages/react-query-persist-client/src/index.ts b/packages/react-query-persist-client/src/index.ts
index cd94f0dee94..ca82fe95cc3 100644
--- a/packages/react-query-persist-client/src/index.ts
+++ b/packages/react-query-persist-client/src/index.ts
@@ -2,3 +2,4 @@
export * from '@tanstack/query-persist-client-core'
export * from './PersistQueryClientProvider'
+export * from './BroadcastQueryClientProvider'
diff --git a/packages/solid-query-persist-client/package.json b/packages/solid-query-persist-client/package.json
index 61207df60ab..b45319a220c 100644
--- a/packages/solid-query-persist-client/package.json
+++ b/packages/solid-query-persist-client/package.json
@@ -61,6 +61,7 @@
"!src/__tests__"
],
"dependencies": {
+ "@tanstack/query-broadcast-client-experimental": "workspace:*",
"@tanstack/query-persist-client-core": "workspace:*"
},
"devDependencies": {
diff --git a/packages/solid-query-persist-client/src/BroadcastQueryClientProvider.tsx b/packages/solid-query-persist-client/src/BroadcastQueryClientProvider.tsx
new file mode 100644
index 00000000000..30069a92ad4
--- /dev/null
+++ b/packages/solid-query-persist-client/src/BroadcastQueryClientProvider.tsx
@@ -0,0 +1,51 @@
+import { broadcastQueryClientRestore } from '@tanstack/query-broadcast-client-experimental'
+import {
+ IsRestoringProvider,
+ QueryClientProvider,
+ useIsRestoring,
+} from '@tanstack/solid-query'
+import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
+import type { BroadcastQueryClientRestoreOptions } from '@tanstack/query-broadcast-client-experimental'
+import type { JSX } from 'solid-js'
+import type { QueryClientProviderProps } from '@tanstack/solid-query'
+
+export type BroadcastQueryClientProviderProps = QueryClientProviderProps & {
+ broadcastOptions: Omit
+}
+
+export const BroadcastQueryClientProvider = (
+ props: BroadcastQueryClientProviderProps,
+): JSX.Element => {
+ const parentIsRestoring = useIsRestoring()
+ const [isRestoring, setIsRestoring] = createSignal(true)
+
+ const options = createMemo(() => ({
+ ...props.broadcastOptions,
+ queryClient: props.client,
+ }))
+
+ createEffect(() => {
+ setIsRestoring(true)
+ let mounted = true
+ const [cleanup, restorePromise] = broadcastQueryClientRestore(options())
+
+ restorePromise.then(() => {
+ if (mounted) {
+ setIsRestoring(false)
+ }
+ })
+
+ onCleanup(() => {
+ mounted = false
+ cleanup()
+ })
+ })
+
+ return (
+
+ parentIsRestoring() || isRestoring()}>
+ {props.children}
+
+
+ )
+}
diff --git a/packages/solid-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx b/packages/solid-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx
new file mode 100644
index 00000000000..5ddd84c964e
--- /dev/null
+++ b/packages/solid-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx
@@ -0,0 +1,94 @@
+import { cleanup, render, screen } from '@solidjs/testing-library'
+import { QueryClient, useQuery } from '@tanstack/solid-query'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BroadcastQueryClientProvider } from '../BroadcastQueryClientProvider'
+
+const mockState = vi.hoisted(() => {
+ const state: { resolveRestore?: () => void } = {}
+ const cleanupSession = vi.fn()
+ const restore = vi.fn(() => {
+ const restorePromise = new Promise((resolve) => {
+ state.resolveRestore = resolve
+ })
+ return [cleanupSession, restorePromise] as const
+ })
+ return { cleanupSession, restore, state }
+})
+
+vi.mock('@tanstack/query-broadcast-client-experimental', () => ({
+ broadcastQueryClientRestore: mockState.restore,
+}))
+
+describe('BroadcastQueryClientProvider (solid)', () => {
+ beforeEach(() => {
+ mockState.cleanupSession.mockReset()
+ mockState.restore.mockClear()
+ mockState.state.resolveRestore = undefined
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ })
+
+ it('holds query observers until bootstrap completes', async () => {
+ const queryKey = ['solid-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'cache', { updatedAt: Date.now() })
+
+ function Page() {
+ const query = useQuery(() => ({
+ queryKey,
+ queryFn,
+ staleTime: Infinity,
+ }))
+ return {query.data ?? query.fetchStatus}
+ }
+
+ render(() => (
+
+
+
+ ))
+
+ expect(screen.getByText('cache')).toBeInTheDocument()
+ expect(queryFn).not.toHaveBeenCalled()
+
+ await mockState.state.resolveRestore?.()
+ expect(queryFn).not.toHaveBeenCalled()
+ expect(mockState.cleanupSession).not.toHaveBeenCalled()
+ cleanup()
+ expect(mockState.cleanupSession).toHaveBeenCalledOnce()
+ })
+
+ it('refetches a stale query after bootstrap completes', async () => {
+ const queryKey = ['solid-stale-bootstrap']
+ const queryFn = vi.fn().mockResolvedValue('network')
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(queryKey, 'stale', { updatedAt: 0 })
+
+ function Page() {
+ const query = useQuery(() => ({ queryKey, queryFn, staleTime: 0 }))
+ return {query.data ?? query.fetchStatus}
+ }
+
+ render(() => (
+
+
+
+ ))
+
+ await mockState.state.resolveRestore?.()
+ await Promise.resolve()
+
+ expect(queryFn).toHaveBeenCalledOnce()
+ cleanup()
+ })
+})
diff --git a/packages/solid-query-persist-client/src/index.ts b/packages/solid-query-persist-client/src/index.ts
index cd94f0dee94..ca82fe95cc3 100644
--- a/packages/solid-query-persist-client/src/index.ts
+++ b/packages/solid-query-persist-client/src/index.ts
@@ -2,3 +2,4 @@
export * from '@tanstack/query-persist-client-core'
export * from './PersistQueryClientProvider'
+export * from './BroadcastQueryClientProvider'
diff --git a/packages/svelte-query-persist-client/package.json b/packages/svelte-query-persist-client/package.json
index 71d385c54ca..c4d576b5d3f 100644
--- a/packages/svelte-query-persist-client/package.json
+++ b/packages/svelte-query-persist-client/package.json
@@ -49,6 +49,7 @@
"!src/__tests__"
],
"dependencies": {
+ "@tanstack/query-broadcast-client-experimental": "workspace:*",
"@tanstack/query-persist-client-core": "workspace:*"
},
"devDependencies": {
diff --git a/packages/svelte-query-persist-client/src/BroadcastQueryClientProvider.svelte b/packages/svelte-query-persist-client/src/BroadcastQueryClientProvider.svelte
new file mode 100644
index 00000000000..4d32e4dc67b
--- /dev/null
+++ b/packages/svelte-query-persist-client/src/BroadcastQueryClientProvider.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {@render children?.()}
+
diff --git a/packages/svelte-query-persist-client/src/index.ts b/packages/svelte-query-persist-client/src/index.ts
index 827c68d1585..a110f909de7 100644
--- a/packages/svelte-query-persist-client/src/index.ts
+++ b/packages/svelte-query-persist-client/src/index.ts
@@ -1,3 +1,4 @@
// Re-export core
export * from '@tanstack/query-persist-client-core'
export { default as PersistQueryClientProvider } from './PersistQueryClientProvider.svelte'
+export { default as BroadcastQueryClientProvider } from './BroadcastQueryClientProvider.svelte'
diff --git a/packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Page.svelte b/packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Page.svelte
new file mode 100644
index 00000000000..36c59ac08ae
--- /dev/null
+++ b/packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Page.svelte
@@ -0,0 +1,19 @@
+
+
+{query.data ?? query.fetchStatus}
diff --git a/packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Provider.svelte b/packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Provider.svelte
new file mode 100644
index 00000000000..536a0169314
--- /dev/null
+++ b/packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Provider.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts b/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts
index 0e1e26feb19..5d08ba9d08a 100644
--- a/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts
+++ b/packages/svelte-query-persist-client/tests/PersistQueryClientProvider.svelte.test.ts
@@ -1,4 +1,5 @@
import { render } from '@testing-library/svelte'
+import { tick } from 'svelte'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { QueryClient, noop } from '@tanstack/svelte-query'
import { persistQueryClientSave } from '@tanstack/query-persist-client-core'
@@ -10,6 +11,7 @@ import InitialData from './InitialData/Provider.svelte'
import RemoveCache from './RemoveCache/Provider.svelte'
import RestoreCache from './RestoreCache/Provider.svelte'
import UseQueries from './UseQueries/Provider.svelte'
+import BroadcastQueryClientProvider from './BroadcastQueryClientProvider/Provider.svelte'
import { StatelessRef } from './utils.svelte.js'
import type {
PersistedClient,
@@ -17,6 +19,24 @@ import type {
} from '@tanstack/query-persist-client-core'
import type { StatusResult } from './utils.svelte.js'
+const mockBroadcastState = vi.hoisted(() => {
+ let resolveRestore: (() => void) | undefined
+
+ return {
+ resolveRestore: () => resolveRestore?.(),
+ restore: vi.fn(() => {
+ const restorePromise = new Promise((resolve) => {
+ resolveRestore = resolve
+ })
+ return [vi.fn(), restorePromise] as const
+ }),
+ }
+})
+
+vi.mock('@tanstack/query-broadcast-client-experimental', () => ({
+ broadcastQueryClientRestore: mockBroadcastState.restore,
+}))
+
beforeEach(() => {
vi.useFakeTimers()
})
@@ -277,6 +297,51 @@ describe('PersistQueryClientProvider', () => {
})
})
+ it('BroadcastQueryClientProvider holds a fresh query until bootstrap completes', async () => {
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(['broadcast-bootstrap'], 'from-cache')
+ const onFetch = vi.fn()
+
+ const rendered = render(BroadcastQueryClientProvider, {
+ props: { queryClient, onFetch },
+ })
+
+ expect(rendered.getByText('from-cache')).toBeInTheDocument()
+ expect(onFetch).not.toHaveBeenCalled()
+ expect(mockBroadcastState.restore).toHaveBeenCalledWith({
+ broadcastChannel: 'test-channel',
+ queryClient,
+ })
+
+ mockBroadcastState.resolveRestore()
+ await Promise.resolve()
+
+ expect(rendered.getByText('from-cache')).toBeInTheDocument()
+ expect(onFetch).not.toHaveBeenCalled()
+
+ rendered.unmount()
+ })
+
+ it('BroadcastQueryClientProvider refetches a stale query after bootstrap completes', async () => {
+ const queryClient = new QueryClient()
+ queryClient.setQueryData(['broadcast-bootstrap'], 'stale', {
+ updatedAt: 0,
+ })
+ const onFetch = vi.fn()
+
+ const rendered = render(BroadcastQueryClientProvider, {
+ props: { queryClient, onFetch, staleTime: 0 },
+ })
+
+ expect(onFetch).not.toHaveBeenCalled()
+ mockBroadcastState.resolveRestore()
+ await tick()
+ await tick()
+
+ expect(onFetch).toHaveBeenCalledOnce()
+ rendered.unmount()
+ })
+
it('should call onSuccess after successful restoring', async () => {
const queryClient = new QueryClient()
void queryClient
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff968e01c00..8c304ef3297 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2500,6 +2500,9 @@ importers:
packages/angular-query-persist-client:
dependencies:
+ '@tanstack/query-broadcast-client-experimental':
+ specifier: workspace:*
+ version: link:../query-broadcast-client-experimental
'@tanstack/query-persist-client-core':
specifier: workspace:*
version: link:../query-persist-client-core
@@ -2661,6 +2664,9 @@ importers:
packages/preact-query-persist-client:
dependencies:
+ '@tanstack/query-broadcast-client-experimental':
+ specifier: workspace:*
+ version: link:../query-broadcast-client-experimental
'@tanstack/query-persist-client-core':
specifier: workspace:*
version: link:../query-persist-client-core
@@ -2923,6 +2929,9 @@ importers:
packages/react-query-persist-client:
dependencies:
+ '@tanstack/query-broadcast-client-experimental':
+ specifier: workspace:*
+ version: link:../query-broadcast-client-experimental
'@tanstack/query-persist-client-core':
specifier: workspace:*
version: link:../query-persist-client-core
@@ -3001,6 +3010,9 @@ importers:
packages/solid-query-persist-client:
dependencies:
+ '@tanstack/query-broadcast-client-experimental':
+ specifier: workspace:*
+ version: link:../query-broadcast-client-experimental
'@tanstack/query-persist-client-core':
specifier: workspace:*
version: link:../query-persist-client-core
@@ -3100,6 +3112,9 @@ importers:
packages/svelte-query-persist-client:
dependencies:
+ '@tanstack/query-broadcast-client-experimental':
+ specifier: workspace:*
+ version: link:../query-broadcast-client-experimental
'@tanstack/query-persist-client-core':
specifier: workspace:*
version: link:../query-persist-client-core