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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/add-broadcast-query-client-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@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 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.
123 changes: 123 additions & 0 deletions docs/framework/react/plugins/broadcastQueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,60 @@ 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({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
queryClient,
broadcastChannel: 'my-app',
timeout: 1000,
})

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, 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 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:

```tsx
import { BroadcastQueryClientProvider } from '@tanstack/react-query-persist-client'
;<BroadcastQueryClientProvider
client={queryClient}
broadcastOptions={{ broadcastChannel: 'my-app', timeout: 1000 }}
>
<App />
</BroadcastQueryClientProvider>
```

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`.
Expand Down Expand Up @@ -63,13 +117,55 @@ interface BroadcastQueryClientOptions {
error: unknown,
event: BroadcastErrorEvent,
) => void | Promise<void>
/** Whether a live-sync session may answer cache bootstrap requests. */
respondToCacheRequests?: boolean
/** Used when this session answers cache bootstrap requests. */
dehydrateOptions?: Pick<DehydrateOptions, 'shouldDehydrateQuery'>
/** Called for bootstrap request, response, or hydration failures. */
onBroadcastRestoreError?: (
error: unknown,
event: BroadcastRestoreErrorEvent,
) => void | Promise<void>
}

interface BroadcastErrorEvent {
type: 'updated' | 'removed' | 'added'
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:
Expand Down Expand Up @@ -101,3 +197,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 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:

```tsx
broadcastQueryClientRestore({
queryClient,
broadcastChannel: 'my-app',
onBroadcastRestoreError: (error, event) => {
Sentry.captureException(error, {
tags: { broadcastEvent: event.type },
extra: {
requestId: event.requestId,
queryHash: event.queryHash,
},
})
},
})
```
1 change: 1 addition & 0 deletions packages/angular-query-experimental/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export { injectQuery } from './inject-query'
export { injectQueryClient } from './inject-query-client'

export type {
BroadcastQueryClientFeature,
DevtoolsFeature,
PersistQueryClientFeature,
QueryFeature,
Expand Down
16 changes: 14 additions & 2 deletions packages/angular-query-experimental/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ export function provideAngularQuery(queryClient: QueryClient): Array<Provider> {
return provideTanStackQuery(queryClient)
}

const queryFeatures = ['Devtools', 'PersistQueryClient'] as const
const queryFeatures = [
'Devtools',
'PersistQueryClient',
'BroadcastQueryClient',
] as const

type QueryFeatureKind = (typeof queryFeatures)[number]

Expand Down Expand Up @@ -160,11 +164,19 @@ 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.
* See documentation for each symbol to find corresponding function name. See also `provideTanStackQuery`
* documentation on how to use those functions.
* @see {@link provideTanStackQuery}
*/
export type QueryFeatures = DevtoolsFeature | PersistQueryClientFeature
export type QueryFeatures =
| DevtoolsFeature
| PersistQueryClientFeature
| BroadcastQueryClientFeature
1 change: 1 addition & 0 deletions packages/angular-query-persist-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"!src/__tests__"
],
"dependencies": {
"@tanstack/query-broadcast-client-experimental": "workspace:*",
"@tanstack/query-persist-client-core": "workspace:*"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
Component,
PLATFORM_ID,
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<void>((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: '<div>{{ state.data() ?? state.fetchStatus() }}</div>',
})
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: '<div>{{ state.data() ?? state.fetchStatus() }}</div>',
})
class Page {
state = injectQuery(() => ({
queryKey,
queryFn,
staleTime: 0,
}))
}

const rendered = await render(Page, {
providers: [
provideZonelessChangeDetection(),
provideTanStackQuery(
queryClient,
withBroadcastQueryClient({ broadcastChannel: 'test-channel' }),
),
],
})

expect(queryFn).not.toHaveBeenCalled()
mockState.resolveRestore()
await Promise.resolve()
rendered.fixture.detectChanges()
await rendered.fixture.whenStable()
rendered.fixture.detectChanges()

expect(queryFn).toHaveBeenCalledOnce()
rendered.fixture.destroy()
})

it('does not leave server queries behind the restore gate', async () => {
const queryFn = vi.fn().mockResolvedValue('server')
const queryClient = new QueryClient()

@Component({
template: '<div>{{ state.data() ?? state.fetchStatus() }}</div>',
})
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()
})
})
1 change: 1 addition & 0 deletions packages/angular-query-persist-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from '@tanstack/query-persist-client-core'

export * from './with-persist-query-client'
export * from './with-broadcast-query-client'
Loading