Skip to content
Draft
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
18 changes: 18 additions & 0 deletions .changeset/modern-dbs-hydrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@tanstack/db': minor
'@tanstack/react-db': minor
'@tanstack/electric-db-collection': minor
'@tanstack/query-db-collection': patch
'@tanstack/powersync-db-collection': patch
'@tanstack/rxdb-db-collection': patch
'@tanstack/trailbase-db-collection': patch
'@tanstack/db-sqlite-persistence-core': patch
---

Add collection-row SSR through request-scoped `DbClient` instances, collection
descriptors, holistic and incremental hydration, adapter sync metadata, and
React descriptor resolution.

React live queries now derive identity from structured query IR. Opaque queries
can provide `queryKey`; legacy dependency arrays and unkeyed opaque queries keep
working with development warnings until 1.0.
61 changes: 61 additions & 0 deletions .github/SSR_RELEASE_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# TanStack DB SSR Release Plan

## Release Goal

Ship TanStack DB SSR as a single coherent story:

- collection-row hydration through `DbClient`
- React provider and descriptor resolution
- derived live query identity with `queryKey` only when necessary
- backwards-compatible dependency arrays with dev warnings until 1.0
- a working TanStack Start demo and E2E proof

## Pre-release Validation

- Run `pnpm --filter @tanstack/db test`.
- Run `pnpm --filter @tanstack/react-db test`.
- Run `pnpm --filter @tanstack/query-db-collection test`.
- Run `pnpm --filter @tanstack/db-sqlite-persistence-core test`.
- Run `pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e`.
- Run `pnpm test:docs`.
- Run `pnpm test:sherif`.
- Run `pnpm build`.

## Demo

- Live URL: https://tanstack-db-ssr-demo.netlify.app/ssr-db
- Deploy `examples/react/start-ssr-e2e` to an SSR-capable host.
- Verify the deployed `/ssr-db` route serves SSR HTML with hydrated rows.
- Verify browser hydration succeeds without console/page errors.
- Verify the streamed collection chunk updates the live query.
- Run `PLAYWRIGHT_BASE_URL=https://tanstack-db-ssr-demo.netlify.app pnpm --filter @tanstack/db-example-react-start-ssr-e2e test:e2e:hosted`.
- Add the live URL to the PR description and release notes.

## Docs

- Publish the [SSR and Hydration guide](../docs/guides/ssr.md).
- Link the guide from overview, quick start, live queries, and React overview.
- Regenerate API reference docs in a dedicated docs-maintenance pass if broad
TypeDoc output churn is acceptable.
- Confirm docs explain when `queryKey` is necessary and when it should be
omitted.
- Confirm docs say dependency arrays warn now and are removed in 1.0.

## Migration Messaging

- Lead with: SSR hydration is collection-row based.
- Emphasize that existing apps keep working.
- State that `createCollection(...)` remains available, but SSR apps should use
`collectionOptions(...)` plus `DbClient`.
- Explain that React dependency arrays are deprecated with a 1.0 removal path.
- Show `queryKey` only for opaque functional query logic or hot render paths.

## Announcement Checklist

- PR description includes high-level summary, migration cheat sheet, and test
commands.
- Release notes include a "No removals in this release" compatibility section.
- Discord announcement links the SSR guide and live demo.
- Example migration diff is available from the Start SSR demo.
- Follow-up issues are filed for non-React framework parity and API reference
generation if they are not part of the shipping PR.
11 changes: 11 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jobs:
run: |
pnpm --filter @tanstack/db-ivm build
pnpm --filter @tanstack/db build
pnpm --filter @tanstack/react-db build
pnpm --filter @tanstack/electric-db-collection build
pnpm --filter @tanstack/offline-transactions build
pnpm --filter @tanstack/query-db-collection build
Expand All @@ -68,6 +69,16 @@ jobs:
env:
ELECTRIC_URL: http://localhost:3000

- name: Install Playwright browsers
run: |
cd examples/react/start-ssr-e2e
pnpm exec playwright install --with-deps chromium

- name: Run React Start SSR E2E tests
run: |
cd examples/react/start-ssr-e2e
pnpm test:e2e

- name: Run Node SQLite persisted collection E2E tests
run: |
cd packages/node-db-sqlite-persistence
Expand Down
20 changes: 12 additions & 8 deletions docs/collections/local-only-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,12 @@ export const modalStateCollection = createCollection(

// Use in component
function UserProfileModal() {
const { data: modals } = useLiveQuery((q) =>
q.from({ modal: modalStateCollection })
.where(({ modal }) => eq(modal.id, 'user-profile'))
)
const { data: modals } = useLiveQuery({
query: (q) =>
q
.from({ modal: modalStateCollection })
.where(({ modal }) => eq(modal.id, 'user-profile')),
})

const modalState = modals[0]

Expand Down Expand Up @@ -248,10 +250,12 @@ export const formDraftsCollection = createCollection(

// Use in component
function CreatePostForm() {
const { data: drafts } = useLiveQuery((q) =>
q.from({ draft: formDraftsCollection })
.where(({ draft }) => eq(draft.id, 'new-post'))
)
const { data: drafts } = useLiveQuery({
query: (q) =>
q
.from({ draft: formDraftsCollection })
.where(({ draft }) => eq(draft.id, 'new-post')),
})

const currentDraft = drafts[0]

Expand Down
10 changes: 6 additions & 4 deletions docs/collections/local-storage-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,12 @@ export const userPreferencesCollection = createCollection(

// Use in component
function SettingsPanel() {
const { data: prefs } = useLiveQuery((q) =>
q.from({ pref: userPreferencesCollection })
.where(({ pref }) => eq(pref.id, 'current-user'))
)
const { data: prefs } = useLiveQuery({
query: (q) =>
q
.from({ pref: userPreferencesCollection })
.where(({ pref }) => eq(pref.id, 'current-user')),
})

const currentPrefs = prefs[0]

Expand Down
133 changes: 62 additions & 71 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,26 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db

```typescript
import { QueryClient } from "@tanstack/query-core"
import { createCollection } from "@tanstack/db"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"

const queryClient = new QueryClient()
const db = new DbClient({ queryClient })

const todosCollection = createCollection(
const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json()
},
queryClient,
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (item) => item.id,
})
)

const todos = db.collection(todosCollection)
```

## Configuration Options
Expand All @@ -54,69 +58,58 @@ The `queryCollectionOptions` function accepts the following options:
- `queryClient`: TanStack Query client instance
- `getKey`: Function to extract the unique key from an item

### Creating Collection Options from a Runtime QueryClient

`queryCollectionOptions` needs a `queryClient` when the collection options are created. In SSR, TanStack Start, tests, or multi-tenant apps, that `QueryClient` is often request-local or route-local rather than module-global.
### Request-scoped QueryClient

Keep shared collection configuration in a factory function that accepts the runtime `QueryClient`:
`queryCollectionOptions` needs a `queryClient`. In SSR, TanStack Start, tests,
or multi-tenant apps, that client is request-local rather than module-global.
Put it on `DbClient`, then resolve it inside the collection descriptor factory:

```typescript
import { QueryClient } from "@tanstack/query-core"
import { createCollection } from "@tanstack/db"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"

interface Todo {
id: string
title: string
}

export function todoCollectionOptions(queryClient: QueryClient) {
return queryCollectionOptions<Todo>({
export const todoCollection = collectionOptions("todos", (client) =>
queryCollectionOptions<Todo>({
id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise<Array<Todo>>
},
queryClient,
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (todo) => todo.id,
})
}

function createTodosCollection(queryClient: QueryClient) {
return createCollection(todoCollectionOptions(queryClient))
}
```

Create the collection once for each scoped `QueryClient` and parameter set, then reuse that `Collection` instance. Creating multiple collections with the same `QueryClient` and `queryKey` gives each collection its own materialized state, lifecycle, subscriptions, and optimistic mutations.

In request-scoped environments, store the collection in request or router context. For client-side scopes, memoize by `QueryClient`:

```typescript
type TodosCollection = ReturnType<typeof createTodosCollection>

const collectionsByClient = new WeakMap<QueryClient, TodosCollection>()

export function getTodosCollection(
queryClient: QueryClient,
): TodosCollection {
let collection = collectionsByClient.get(queryClient)

if (!collection) {
collection = createTodosCollection(queryClient)
collectionsByClient.set(queryClient, collection)
}
)

return collection
export function createRequestClients() {
const queryClient = new QueryClient()
const dbClient = new DbClient({ queryClient })
return { queryClient, dbClient }
}
```

Avoid calling `createCollection(todoCollectionOptions(queryClient))` independently during render or in each consumer. Share the stable collection instance for the lifetime of that `QueryClient` scope.
`dbClient.collection(todoCollection)` memoizes one collection instance for that
descriptor and client. A second `DbClient` materializes fresh adapter state and
uses its own `QueryClient`.

This keeps SSR and request-scoped code from sharing a global `QueryClient` while keeping each collection instance stable within its scope.
Passing `queryClient` directly to `queryCollectionOptions` remains supported for
`createCollection(...)` and existing apps. When a descriptor is materialized,
an explicit `DbClient` dependency takes precedence; the configured
`queryClient` is the backwards-compatible fallback.

### Business-Scoped Collection Factories

A tenant, project, account, or route parameter can define a **business scope**: the server resource that a collection represents. Include the scope in both the Query key and `queryFn`. This extends the [runtime `QueryClient` factory pattern](#creating-collection-options-from-a-runtime-queryclient) with an explicit scope parameter:
A tenant, project, account, or route parameter can define a **business scope**:
the server resource that a collection represents. Include the scope in the
descriptor id, Query key, and `queryFn`. This extends the
[request-scoped QueryClient pattern](#request-scoped-queryclient) with an
explicit scope parameter:

```typescript
interface Todo {
Expand All @@ -130,54 +123,48 @@ async function fetchProjectTodos(projectId: string): Promise<Array<Todo>> {
return response.json()
}

export function createProjectTodosCollection(
queryClient: QueryClient,
function createProjectTodosDescriptor(
projectId: string,
) {
return createCollection(
return collectionOptions(`project:${projectId}:todos`, (client) =>
queryCollectionOptions<Todo>({
id: `project:${projectId}:todos`,
queryKey: ["projects", projectId, "todos"],
queryFn: () => fetchProjectTodos(projectId),
queryClient,
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (todo) => todo.id,
})
)
}
```

The scope is part of the collection's identity. Memoize by both the `QueryClient` and a stable scope key so consumers of the same project share one collection:
The scope is part of the descriptor identity. Memoize descriptors by a stable
scope key; `DbClient` handles collection memoization and QueryClient ownership:

```typescript
type ProjectTodosCollection = ReturnType<typeof createProjectTodosCollection>
type ProjectTodosDescriptor = ReturnType<typeof createProjectTodosDescriptor>

const projectCollections = new WeakMap<
QueryClient,
Map<string, ProjectTodosCollection>
>()
const projectDescriptors = new Map<string, ProjectTodosDescriptor>()

export function getProjectTodosCollection(
queryClient: QueryClient,
export function getProjectTodosDescriptor(
projectId: string,
): ProjectTodosCollection {
let collectionsByProject = projectCollections.get(queryClient)

if (!collectionsByProject) {
collectionsByProject = new Map()
projectCollections.set(queryClient, collectionsByProject)
): ProjectTodosDescriptor {
let descriptor = projectDescriptors.get(projectId)
if (!descriptor) {
descriptor = createProjectTodosDescriptor(projectId)
projectDescriptors.set(projectId, descriptor)
}

let collection = collectionsByProject.get(projectId)

if (!collection) {
collection = createProjectTodosCollection(queryClient, projectId)
collectionsByProject.set(projectId, collection)
}

return collection
return descriptor
}

const todos = dbClient.collection(getProjectTodosDescriptor(projectId))
```

For multiple scope values, use nested maps or a collision-safe stable key that includes every value. Do not call the factory on each render. In a long-lived client, user-selected scopes can make the map grow without bound. Remove unused entries and call `await collection.cleanup()` when your application owns their lifecycle. Request-local maps can be discarded with the request.
For multiple scope values, use nested maps or a collision-safe stable key that
includes every value. Do not create a descriptor on each render. In a
long-lived app, user-selected scopes can make the map grow without bound; remove
unused descriptors and call `await dbClient.cleanup()` when the client scope
ends. Request-local maps can be discarded with the request.

A business scope is separate from a **relational subset** requested by a live query. With `syncMode: "on-demand"`, `LoadSubsetOptions` describes predicates, ordering, limits, and offsets within one business-scoped collection. These options reach `queryFn` through `ctx.meta.loadSubsetOptions` and determine the subset Query keys. See [QueryFn and Predicate Push-Down](#queryfn-and-predicate-push-down).

Expand Down Expand Up @@ -298,11 +285,12 @@ If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tan

```typescript
import { QueryClient } from "@tanstack/query-core"
import { createCollection } from "@tanstack/db"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { queryOptions } from "@tanstack/react-query"

const queryClient = new QueryClient()
const db = new DbClient({ queryClient })

const listOptions = queryOptions({
queryKey: ["todos"],
Expand All @@ -312,14 +300,17 @@ const listOptions = queryOptions({
},
})

const todosCollection = createCollection(
const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
id: "todos",
...listOptions,
queryFn: (context) => listOptions.queryFn!(context),
queryClient,
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (item) => item.id,
}),
)

const todos = db.collection(todosCollection)
```

If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`.
Expand Down
Loading
Loading