From 59011131f7f418c6c08f82526d072999aef04790 Mon Sep 17 00:00:00 2001 From: Hector Vega Date: Fri, 4 Sep 2026 20:55:53 -0600 Subject: [PATCH 1/2] fix(broadcast-client): restore cache before initial fetch Add opt-in cross-tab cache bootstrap with restore gating, per-query hydration, bounded timeouts, and framework integrations while preserving the existing synchronous broadcast API --- .../add-broadcast-query-client-restore.md | 13 + .../react/plugins/broadcastQueryClient.md | 72 + .../angular-query-experimental/src/index.ts | 1 + .../src/providers.ts | 16 +- .../angular-query-persist-client/package.json | 1 + .../with-broadcast-query-client.test.ts | 117 ++ .../angular-query-persist-client/src/index.ts | 1 + .../src/with-broadcast-query-client.ts | 60 + .../preact-query-persist-client/package.json | 1 + .../src/BroadcastQueryClientProvider.tsx | 59 + .../BroadcastQueryClientProvider.test.tsx | 101 ++ .../preact-query-persist-client/src/index.ts | 1 + .../src/__tests__/index.test.ts | 1250 ++++++++++++++++- .../src/index.ts | 441 +++++- .../react-query-persist-client/package.json | 1 + .../src/BroadcastQueryClientProvider.tsx | 57 + .../BroadcastQueryClientProvider.test.tsx | 184 +++ .../react-query-persist-client/src/index.ts | 1 + .../solid-query-persist-client/package.json | 1 + .../src/BroadcastQueryClientProvider.tsx | 51 + .../BroadcastQueryClientProvider.test.tsx | 94 ++ .../solid-query-persist-client/src/index.ts | 1 + .../svelte-query-persist-client/package.json | 1 + .../src/BroadcastQueryClientProvider.svelte | 57 + .../svelte-query-persist-client/src/index.ts | 1 + .../BroadcastQueryClientProvider/Page.svelte | 19 + .../Provider.svelte | 22 + .../PersistQueryClientProvider.svelte.test.ts | 65 + pnpm-lock.yaml | 15 + 29 files changed, 2663 insertions(+), 41 deletions(-) create mode 100644 .changeset/add-broadcast-query-client-restore.md create mode 100644 packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts create mode 100644 packages/angular-query-persist-client/src/with-broadcast-query-client.ts create mode 100644 packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx create mode 100644 packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx create mode 100644 packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx create mode 100644 packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx create mode 100644 packages/solid-query-persist-client/src/BroadcastQueryClientProvider.tsx create mode 100644 packages/solid-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx create mode 100644 packages/svelte-query-persist-client/src/BroadcastQueryClientProvider.svelte create mode 100644 packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Page.svelte create mode 100644 packages/svelte-query-persist-client/tests/BroadcastQueryClientProvider/Provider.svelte diff --git a/.changeset/add-broadcast-query-client-restore.md b/.changeset/add-broadcast-query-client-restore.md new file mode 100644 index 00000000000..8fd6979c009 --- /dev/null +++ b/.changeset/add-broadcast-query-client-restore.md @@ -0,0 +1,13 @@ +--- +'@tanstack/query-broadcast-client-experimental': minor +'@tanstack/react-query-persist-client': minor +'@tanstack/preact-query-persist-client': minor +'@tanstack/solid-query-persist-client': minor +'@tanstack/svelte-query-persist-client': minor +'@tanstack/angular-query-experimental': minor +'@tanstack/angular-query-persist-client': minor +--- + +Add opt-in cross-tab QueryClient bootstrap and restore-gate integrations to +prevent duplicate initial requests when a fresh tab opens. Existing +broadcastQueryClient usage remains synchronous and live-sync-only. diff --git a/docs/framework/react/plugins/broadcastQueryClient.md b/docs/framework/react/plugins/broadcastQueryClient.md index d67b3422a21..28c844a7cae 100644 --- a/docs/framework/react/plugins/broadcastQueryClient.md +++ b/docs/framework/react/plugins/broadcastQueryClient.md @@ -28,6 +28,51 @@ broadcastQueryClient({ ## API +### broadcastQueryClientRestore + +Use the additive restore API when a new tab must bootstrap its cache before +queries are allowed to fetch: + +```tsx +import { broadcastQueryClientRestore } from '@tanstack/query-broadcast-client-experimental' + +const [cleanup, restored] = broadcastQueryClientRestore({ + queryClient, + broadcastChannel: 'my-app', + timeout: 1000, +}) + +await restored +renderApplication() +``` + +The restore API starts normal live synchronization on the same channel before +requesting snapshots. Do not call both APIs for the same QueryClient and +channel; the restore API already owns the live session. + +The response window accepts snapshots from every available tab. Query state is +merged with dataUpdatedAt, so the newest state for each query wins. Bootstrap +includes successful queries by default and excludes mutations. Each query is +sent independently so a structured-clone failure does not discard all valid +queries. + +Framework applications should use the existing restore-aware integration: + +```tsx +import { BroadcastQueryClientProvider } from '@tanstack/react-query-persist-client' +; + + +``` + +The corresponding Preact, Solid, Svelte, and Angular adapters use their native +restore mechanisms. Vue applications can pass broadcastQueryClientRestore +through the Vue plugin's clientPersister option. Lit currently has no restore +gate; await the returned promise before creating query controllers. + ### `broadcastQueryClient` Pass this function a `QueryClient` instance and optionally, a `broadcastChannel`. @@ -101,3 +146,30 @@ broadcastQueryClient({ }, }) ``` + +The restore API additionally accepts timeout, query dehydration filtering, +hydrate options, and onBroadcastRestoreError. Restore errors are reported +separately from the existing live-sync onBroadcastError callback so existing +callbacks retain their current type and behavior. + +The default timeout is 1000ms. A longer window improves the chance of receiving +the freshest state from another tab but increases cold-start latency when no +peer exists. A timeout of 0 disables the normal bootstrap wait. + +For bootstrap failures, use onBroadcastRestoreError: + +```tsx +broadcastQueryClientRestore({ + queryClient, + broadcastChannel: 'my-app', + onBroadcastRestoreError: (error, event) => { + Sentry.captureException(error, { + tags: { broadcastEvent: event.type }, + extra: { + requestId: event.requestId, + queryHash: event.queryHash, + }, + }) + }, +}) +``` diff --git a/packages/angular-query-experimental/src/index.ts b/packages/angular-query-experimental/src/index.ts index fc033224e51..a74f599914e 100644 --- a/packages/angular-query-experimental/src/index.ts +++ b/packages/angular-query-experimental/src/index.ts @@ -47,6 +47,7 @@ export { injectQuery } from './inject-query' export { injectQueryClient } from './inject-query-client' export type { + BroadcastQueryClientFeature, DevtoolsFeature, PersistQueryClientFeature, QueryFeature, diff --git a/packages/angular-query-experimental/src/providers.ts b/packages/angular-query-experimental/src/providers.ts index 9f52c11690f..123a16fe6be 100644 --- a/packages/angular-query-experimental/src/providers.ts +++ b/packages/angular-query-experimental/src/providers.ts @@ -122,7 +122,11 @@ export function provideAngularQuery(queryClient: QueryClient): Array { return provideTanStackQuery(queryClient) } -const queryFeatures = ['Devtools', 'PersistQueryClient'] as const +const queryFeatures = [ + 'Devtools', + 'PersistQueryClient', + 'BroadcastQueryClient', +] as const type QueryFeatureKind = (typeof queryFeatures)[number] @@ -160,6 +164,11 @@ export type DevtoolsFeature = QueryFeature<'Devtools'> */ export type PersistQueryClientFeature = QueryFeature<'PersistQueryClient'> +/** + * A type alias that represents a feature which enables broadcast bootstrap. + */ +export type BroadcastQueryClientFeature = QueryFeature<'BroadcastQueryClient'> + /** * A type alias that represents all Query features available for use with `provideTanStackQuery`. * Features can be enabled by adding special functions to the `provideTanStackQuery` call. @@ -167,4 +176,7 @@ export type PersistQueryClientFeature = QueryFeature<'PersistQueryClient'> * documentation on how to use those functions. * @see {@link provideTanStackQuery} */ -export type QueryFeatures = DevtoolsFeature | PersistQueryClientFeature +export type QueryFeatures = + | DevtoolsFeature + | PersistQueryClientFeature + | BroadcastQueryClientFeature diff --git a/packages/angular-query-persist-client/package.json b/packages/angular-query-persist-client/package.json index b4c73b63932..656354c8dbd 100644 --- a/packages/angular-query-persist-client/package.json +++ b/packages/angular-query-persist-client/package.json @@ -51,6 +51,7 @@ "!src/__tests__" ], "dependencies": { + "@tanstack/query-broadcast-client-experimental": "workspace:*", "@tanstack/query-persist-client-core": "workspace:*" }, "devDependencies": { diff --git a/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts b/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts new file mode 100644 index 00000000000..deae4d57a08 --- /dev/null +++ b/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Component, provideZonelessChangeDetection } from '@angular/core' +import { render } from '@testing-library/angular' +import { + QueryClient, + injectQuery, + provideTanStackQuery, +} from '@tanstack/angular-query-experimental' +import { withBroadcastQueryClient } from '../with-broadcast-query-client' + +const mockState = vi.hoisted(() => { + let resolveRestore: (() => void) | undefined + const cleanup = vi.fn() + + return { + resolveRestore: () => resolveRestore?.(), + cleanup, + restore: vi.fn(() => { + const restorePromise = new Promise((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() + }) +}) 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..bf4568bca4a --- /dev/null +++ b/packages/angular-query-persist-client/src/with-broadcast-query-client.ts @@ -0,0 +1,60 @@ +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))) 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..cdc3ab057a2 --- /dev/null +++ b/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx @@ -0,0 +1,59 @@ +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 +} + +export const BroadcastQueryClientProvider = ({ + children, + broadcastOptions, + ...props +}: BroadcastQueryClientProviderProps): VNode => { + const parentIsRestoring = useIsRestoring() + const [isRestoring, setIsRestoring] = useState(true) + const optionsRef = useRef(broadcastOptions) + + useEffect(() => { + optionsRef.current = broadcastOptions + }) + + 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..ceae8fa30a5 --- /dev/null +++ b/packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx @@ -0,0 +1,101 @@ +/** @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 } = {} + 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 (preact)', () => { + beforeEach(() => { + mockState.cleanupSession.mockReset() + mockState.restore.mockClear() + mockState.state.resolveRestore = undefined + }) + + 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() + }) +}) 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__/index.test.ts b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts index 73a8b0c914b..e2b21c2812a 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,1209 @@ 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 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 answer bootstrap requests from the existing live-sync API', () => { + const key = queryKey() + const peerQueryClient = new QueryClient() + peerQueryClient.setQueryData(key, { from: 'peer' }) + const peerCleanup = broadcastQueryClient({ + queryClient: peerQueryClient, + broadcastChannel: 'test_channel', + }) + + const peerChannel = createdChannels[0]! + peerChannel.onmessage?.({ + type: 'cache-request', + requestId: 'request-1', + }) + + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'cache-response', + requestId: 'request-1', + query: expect.objectContaining({ + queryKey: key, + }), + }), + ) + + peerCleanup() + }) + + it('should resolve restore after a timeout with no peer response', async () => { + vi.useFakeTimers() + try { + const [cleanup, restored] = broadcastQueryClientRestore({ + queryClient, + broadcastChannel: 'test_channel', + timeout: 100, + }) + let completed = false + void restored.then(() => { + completed = true + }) + + expect(completed).toBe(false) + await vi.advanceTimersByTimeAsync(100) + expect(completed).toBe(true) + cleanup() + } finally { + 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 + + 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 peerQueryClient = new QueryClient() + 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', + }) + + mockPostMessage.mockClear() + lastCreatedChannel.onmessage?.({ + type: 'cache-request', + requestId: 'request-1', + }) + + expect(mockPostMessage).not.toHaveBeenCalled() + 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 +1517,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..82d34dd213a 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] -interface BroadcastQueryClientOptions { +type CacheResponse = { + type: 'cache-response' + requestId: string + responderId: string + responseId: string + query: DehydratedQuery +} + +type BroadcastMessage = LiveBroadcastMessage | CacheRequest | CacheResponse + +export interface BroadcastQueryClientOptions { /** The QueryClient to sync. */ queryClient: QueryClient /** @@ -26,18 +68,17 @@ 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?: ( @@ -46,36 +87,130 @@ interface BroadcastQueryClientOptions { ) => void | Promise } -export function broadcastQueryClient({ +export interface BroadcastQueryClientRestoreOptions extends BroadcastQueryClientOptions { + /** + * Maximum time to wait for bootstrap responses. + * @default 1000 + */ + timeout?: number + /** + * Query dehydration options. Mutations are never included in broadcast + * bootstrap. + */ + dehydrateOptions?: Pick + /** Options used when applying each incoming query snapshot. */ + hydrateOptions?: HydrateOptions + /** + * Called when a bootstrap request, response, or hydration operation fails. + * Failures are isolated so valid query snapshots can still be restored. + */ + onBroadcastRestoreError?: ( + error: unknown, + event: BroadcastRestoreErrorEvent, + ) => void | Promise +} + +type InternalOptions = BroadcastQueryClientOptions & { + restoreOptions?: BroadcastQueryClientRestoreOptions +} + +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 { + 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 +221,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 +240,175 @@ 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 (!restoreOptions?.onBroadcastRestoreError) { + warnRestoreError(error, event) + return + } + + let result: void | Promise + try { + result = restoreOptions.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) + } + } + + const respondToCacheRequest = (request: CacheRequest) => { + let snapshot: DehydratedState + try { + snapshot = dehydrate(queryClient, { + ...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 +444,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 +481,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 +501,65 @@ 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 } +} + +export function broadcastQueryClient( + options: BroadcastQueryClientOptions, +): () => void { + return createBroadcastSession(options).cleanup +} + +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..8adf8b8ade5 --- /dev/null +++ b/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx @@ -0,0 +1,57 @@ +'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 +} + +export const BroadcastQueryClientProvider = ({ + children, + broadcastOptions, + ...props +}: BroadcastQueryClientProviderProps): React.JSX.Element => { + const parentIsRestoring = useIsRestoring() + const [isRestoring, setIsRestoring] = React.useState(true) + const optionsRef = React.useRef(broadcastOptions) + + React.useEffect(() => { + optionsRef.current = broadcastOptions + }) + + 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..6dc937ab962 --- /dev/null +++ b/packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx @@ -0,0 +1,184 @@ +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 } = {} + const cleanup = vi.fn() + const restore = vi.fn(() => { + const restorePromise = new Promise((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 + }) + + 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() + }) +}) 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 From a26f24d9622e5fa92437af869f842723c6b7079b Mon Sep 17 00:00:00 2001 From: Hector Vega Date: Sat, 5 Sep 2026 21:13:20 -0600 Subject: [PATCH 2/2] fix(broadcast): add cross-tab QueryClient restore - add bounded cache bootstrap with automatic restore responders - support opt-in responders for live-sync sessions - gate React, Preact, and Angular queries during restore - add API, runtime, documentation, and type regressions --- .../add-broadcast-query-client-restore.md | 8 +- .../react/plugins/broadcastQueryClient.md | 69 +++- .../with-broadcast-query-client.test.ts | 38 ++- .../src/with-broadcast-query-client.ts | 5 +- .../src/BroadcastQueryClientProvider.tsx | 15 +- .../BroadcastQueryClientProvider.test.tsx | 147 +++++++- .../src/__tests__/api.test-d.ts | 121 +++++++ .../src/__tests__/index.test.ts | 322 ++++++++++++++++-- .../src/index.ts | 65 +++- .../src/BroadcastQueryClientProvider.tsx | 15 +- .../BroadcastQueryClientProvider.test.tsx | 147 +++++++- 11 files changed, 897 insertions(+), 55 deletions(-) create mode 100644 packages/query-broadcast-client-experimental/src/__tests__/api.test-d.ts diff --git a/.changeset/add-broadcast-query-client-restore.md b/.changeset/add-broadcast-query-client-restore.md index 8fd6979c009..6a8d1cde828 100644 --- a/.changeset/add-broadcast-query-client-restore.md +++ b/.changeset/add-broadcast-query-client-restore.md @@ -8,6 +8,8 @@ '@tanstack/angular-query-persist-client': minor --- -Add opt-in cross-tab QueryClient bootstrap and restore-gate integrations to -prevent duplicate initial requests when a fresh tab opens. Existing -broadcastQueryClient usage remains synchronous and live-sync-only. +Add cross-tab QueryClient bootstrap and restore-gate integrations to prevent +duplicate initial requests when a fresh tab opens. `broadcastQueryClientRestore` +automatically requests and responds to cache snapshots, while existing +`broadcastQueryClient` usage remains live-sync-only unless +`respondToCacheRequests` is explicitly enabled. diff --git a/docs/framework/react/plugins/broadcastQueryClient.md b/docs/framework/react/plugins/broadcastQueryClient.md index 28c844a7cae..c52a4f0b622 100644 --- a/docs/framework/react/plugins/broadcastQueryClient.md +++ b/docs/framework/react/plugins/broadcastQueryClient.md @@ -44,17 +44,26 @@ const [cleanup, restored] = broadcastQueryClientRestore({ await restored renderApplication() + +// Later, when this QueryClient or broadcast session is disposed or replaced: +cleanup() ``` +Keep `cleanup` for the lifetime of the QueryClient. Do not call it immediately +after `restored`, because the restore session also owns live synchronization. +Call it when the QueryClient or session is disposed or replaced so the previous +channel and query-cache listeners do not remain active. + The restore API starts normal live synchronization on the same channel before -requesting snapshots. Do not call both APIs for the same QueryClient and -channel; the restore API already owns the live session. +requesting snapshots, and automatically responds to restore requests. Do not +call both APIs for the same QueryClient and channel; the restore API already +owns the live session. -The response window accepts snapshots from every available tab. Query state is -merged with dataUpdatedAt, so the newest state for each query wins. Bootstrap -includes successful queries by default and excludes mutations. Each query is -sent independently so a structured-clone failure does not discard all valid -queries. +The response window accepts snapshots from configured responder sessions. Query +state is merged with dataUpdatedAt, so the newest state for each query wins. +Bootstrap includes successful queries by default and excludes mutations. Each +query is sent independently so a structured-clone failure does not discard all +valid queries. Framework applications should use the existing restore-aware integration: @@ -108,6 +117,15 @@ interface BroadcastQueryClientOptions { error: unknown, event: BroadcastErrorEvent, ) => void | Promise + /** Whether a live-sync session may answer cache bootstrap requests. */ + respondToCacheRequests?: boolean + /** Used when this session answers cache bootstrap requests. */ + dehydrateOptions?: Pick + /** Called for bootstrap request, response, or hydration failures. */ + onBroadcastRestoreError?: ( + error: unknown, + event: BroadcastRestoreErrorEvent, + ) => void | Promise } interface BroadcastErrorEvent { @@ -115,6 +133,39 @@ interface BroadcastErrorEvent { queryHash: string queryKey: QueryKey } + +interface BroadcastRestoreErrorEvent { + type: 'request' | 'response' | 'hydrate' + requestId: string + responderId?: string + responseId?: string + queryHash?: string + queryKey?: QueryKey +} + +interface BroadcastQueryClientRestoreOptions extends Omit< + BroadcastQueryClientOptions, + 'respondToCacheRequests' +> { + timeout?: number + hydrateOptions?: HydrateOptions +} +``` + +Existing `broadcastQueryClient` sessions do not answer bootstrap requests by +default. A live-sync session can explicitly opt in when it should provide +snapshots to restoring tabs. `broadcastQueryClientRestore` is already a +responder and does not accept `respondToCacheRequests`: + +```tsx +broadcastQueryClient({ + queryClient, + broadcastChannel: 'my-app', + respondToCacheRequests: true, + dehydrateOptions: { + shouldDehydrateQuery: (query) => query.queryKey[0] !== 'private', + }, +}) ``` The default options are: @@ -153,8 +204,8 @@ separately from the existing live-sync onBroadcastError callback so existing callbacks retain their current type and behavior. The default timeout is 1000ms. A longer window improves the chance of receiving -the freshest state from another tab but increases cold-start latency when no -peer exists. A timeout of 0 disables the normal bootstrap wait. +the freshest state from an available responder session but increases cold-start +latency when no responder exists. A timeout of 0 does not wait for responses. For bootstrap failures, use onBroadcastRestoreError: diff --git a/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts b/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts index deae4d57a08..875f4460c39 100644 --- a/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts +++ b/packages/angular-query-persist-client/src/__tests__/with-broadcast-query-client.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Component, provideZonelessChangeDetection } from '@angular/core' +import { + Component, + PLATFORM_ID, + provideZonelessChangeDetection, +} from '@angular/core' import { render } from '@testing-library/angular' import { QueryClient, @@ -114,4 +118,36 @@ describe('withBroadcastQueryClient', () => { 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/with-broadcast-query-client.ts b/packages/angular-query-persist-client/src/with-broadcast-query-client.ts index bf4568bca4a..7be8b4a8af9 100644 --- a/packages/angular-query-persist-client/src/with-broadcast-query-client.ts +++ b/packages/angular-query-persist-client/src/with-broadcast-query-client.ts @@ -39,7 +39,10 @@ export function withBroadcastQueryClient( provide: ENVIRONMENT_INITIALIZER, multi: true, useValue: () => { - if (!isPlatformBrowser(inject(PLATFORM_ID))) return + if (!isPlatformBrowser(inject(PLATFORM_ID))) { + isRestoring.set(false) + return + } const destroyRef = inject(DestroyRef) const queryClient = inject(QueryClient) diff --git a/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx b/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx index cdc3ab057a2..85d90d5fb99 100644 --- a/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx +++ b/packages/preact-query-persist-client/src/BroadcastQueryClientProvider.tsx @@ -16,6 +16,11 @@ 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, @@ -24,11 +29,17 @@ export const BroadcastQueryClientProvider = ({ 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 @@ -51,7 +62,9 @@ export const BroadcastQueryClientProvider = ({ 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 index ceae8fa30a5..74e764c5bab 100644 --- a/packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx +++ b/packages/preact-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx @@ -6,10 +6,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BroadcastQueryClientProvider } from '../BroadcastQueryClientProvider' const mockState = vi.hoisted(() => { - const state: { resolveRestore?: () => void } = {} + 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 @@ -26,6 +30,7 @@ describe('BroadcastQueryClientProvider (preact)', () => { mockState.cleanupSession.mockReset() mockState.restore.mockClear() mockState.state.resolveRestore = undefined + mockState.state.restoreResolvers.length = 0 }) afterEach(() => { @@ -98,4 +103,144 @@ describe('BroadcastQueryClientProvider (preact)', () => { 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/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 e2b21c2812a..828c419516e 100644 --- a/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts +++ b/packages/query-broadcast-client-experimental/src/__tests__/index.test.ts @@ -179,6 +179,230 @@ describe('broadcastQueryClient', () => { } }) + 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 { @@ -971,7 +1195,7 @@ describe('broadcastQueryClient', () => { process.env['NODE_ENV'] = previousNodeEnv }) - it('should answer bootstrap requests from the existing live-sync API', () => { + it('should not answer bootstrap requests from a plain live-sync session', () => { const key = queryKey() const peerQueryClient = new QueryClient() peerQueryClient.setQueryData(key, { from: 'peer' }) @@ -980,33 +1204,62 @@ describe('broadcastQueryClient', () => { broadcastChannel: 'test_channel', }) - const peerChannel = createdChannels[0]! - peerChannel.onmessage?.({ - type: 'cache-request', - requestId: 'request-1', + 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', + }, }) - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'cache-response', - requestId: 'request-1', - query: expect.objectContaining({ - queryKey: key, - }), - }), - ) + try { + const peerChannel = createdChannels[0]! + peerChannel.onmessage?.({ + type: 'cache-request', + requestId: 'filtered-request', + }) - peerCleanup() + 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 [cleanup, restored] = broadcastQueryClientRestore({ + const [restoreCleanup, restored] = broadcastQueryClientRestore({ queryClient, broadcastChannel: 'test_channel', timeout: 100, }) + cleanup = restoreCleanup let completed = false void restored.then(() => { completed = true @@ -1015,8 +1268,9 @@ describe('broadcastQueryClient', () => { expect(completed).toBe(false) await vi.advanceTimersByTimeAsync(100) expect(completed).toBe(true) - cleanup() + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) } finally { + cleanup?.() vi.useRealTimers() } }) @@ -1034,6 +1288,7 @@ describe('broadcastQueryClient', () => { cleanup() await restored + expect(lastCreatedChannel.onmessage).toBeNull() lastCreatedChannel.onmessage?.({ type: 'cache-response', @@ -1109,7 +1364,9 @@ describe('broadcastQueryClient', () => { }) 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, { @@ -1131,16 +1388,33 @@ describe('broadcastQueryClient', () => { const peerCleanup = broadcastQueryClient({ queryClient: peerQueryClient, broadcastChannel: 'test_channel', + respondToCacheRequests: true, }) - mockPostMessage.mockClear() - lastCreatedChannel.onmessage?.({ - type: 'cache-request', - requestId: 'request-1', - }) + try { + mockPostMessage.mockClear() + lastCreatedChannel.onmessage?.({ + type: 'cache-request', + requestId: 'request-1', + }) - expect(mockPostMessage).not.toHaveBeenCalled() - peerCleanup() + 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', () => { diff --git a/packages/query-broadcast-client-experimental/src/index.ts b/packages/query-broadcast-client-experimental/src/index.ts index 82d34dd213a..da8a0d53812 100644 --- a/packages/query-broadcast-client-experimental/src/index.ts +++ b/packages/query-broadcast-client-experimental/src/index.ts @@ -85,24 +85,23 @@ export interface BroadcastQueryClientOptions { error: unknown, event: BroadcastErrorEvent, ) => void | Promise -} - -export interface BroadcastQueryClientRestoreOptions extends BroadcastQueryClientOptions { /** - * Maximum time to wait for bootstrap responses. - * @default 1000 + * 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 */ - timeout?: number + respondToCacheRequests?: boolean /** - * Query dehydration options. Mutations are never included in broadcast - * bootstrap. + * 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 - /** Options used when applying each incoming query snapshot. */ - hydrateOptions?: HydrateOptions /** * Called when a bootstrap request, response, or hydration operation fails. - * Failures are isolated so valid query snapshots can still be restored. + * Live synchronization failures continue to use `onBroadcastError`. */ onBroadcastRestoreError?: ( error: unknown, @@ -110,6 +109,23 @@ export interface BroadcastQueryClientRestoreOptions extends BroadcastQueryClient ) => 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 } @@ -182,6 +198,9 @@ function createBroadcastSession({ broadcastChannel = 'tanstack-query', options, onBroadcastError, + respondToCacheRequests, + dehydrateOptions, + onBroadcastRestoreError, restoreOptions, }: InternalOptions): Session { const timeout = restoreOptions?.timeout ?? DEFAULT_RESTORE_TIMEOUT @@ -292,14 +311,14 @@ function createBroadcastSession({ error: unknown, event: BroadcastRestoreErrorEvent, ) => { - if (!restoreOptions?.onBroadcastRestoreError) { + if (!onBroadcastRestoreError) { warnRestoreError(error, event) return } let result: void | Promise try { - result = restoreOptions.onBroadcastRestoreError(error, event) + result = onBroadcastRestoreError(error, event) } catch (callbackError) { warnRestoreError(callbackError, event) return @@ -335,10 +354,19 @@ function createBroadcastSession({ } } + /** + * 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, }) @@ -547,12 +575,23 @@ function createBroadcastSession({ 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] { diff --git a/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx b/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx index 8adf8b8ade5..c699fbbe608 100644 --- a/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx +++ b/packages/react-query-persist-client/src/BroadcastQueryClientProvider.tsx @@ -14,6 +14,11 @@ 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, @@ -22,11 +27,17 @@ export const BroadcastQueryClientProvider = ({ 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 @@ -49,7 +60,9 @@ export const BroadcastQueryClientProvider = ({ 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 index 6dc937ab962..a961adc46ef 100644 --- a/packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx +++ b/packages/react-query-persist-client/src/__tests__/BroadcastQueryClientProvider.test.tsx @@ -8,10 +8,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BroadcastQueryClientProvider } from '../BroadcastQueryClientProvider' const mockState = vi.hoisted(() => { - const state: { resolveRestore?: () => void } = {} + 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 @@ -29,6 +33,7 @@ describe('BroadcastQueryClientProvider', () => { mockState.cleanup.mockReset() mockState.restore.mockClear() mockState.state.resolveRestore = undefined + mockState.state.restoreResolvers.length = 0 }) afterEach(() => { @@ -181,4 +186,144 @@ describe('BroadcastQueryClientProvider', () => { 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() + }) })