Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c45173a
feat(core): add HYDRATE action and state delta streaming helpers
cursoragent Sep 11, 2026
f6c186a
fix(react): escape serialized SSR state so API data cannot break out …
cursoragent Sep 11, 2026
ac933fa
refactor(react): share server store construction between SSR adapters
cursoragent Sep 11, 2026
b47ae64
fix(react): hydrate cache reads from the server snapshot instead of l…
cursoragent Sep 11, 2026
b5cc0af
fix(react/nextjs): stream store state per flush instead of a 10ms qui…
cursoragent Sep 11, 2026
bb3c7d8
chore(react): keep the local next/navigation declaration out of the i…
cursoragent Sep 11, 2026
a05c592
fix(react): hydrate against a snapshot overlaid on live state and cov…
cursoragent Sep 11, 2026
723188d
docs(ssr): document streamed App Router state, managers factory, nonc…
cursoragent Sep 11, 2026
36626f7
chore: changesets for streamed SSR state, managers factory and SSR es…
cursoragent Sep 11, 2026
9f78227
docs(ssr): clarify React 18 hydration behavior for streamed updates
cursoragent Sep 11, 2026
4bcb2dd
feat(react/nextjs)!: managers is a factory on the Next.js DataProvider
cursoragent Sep 11, 2026
364f268
pkg: extend @data-client/react peer ranges to ^0.19.0 and refresh loc…
cursoragent Sep 11, 2026
641f670
fix(react/nextjs): address review findings on the streamed handoff
cursoragent Sep 11, 2026
3d4a542
test(react): load hydration suites on React 17 and assert 18 honestly
cursoragent Sep 11, 2026
7ad879b
test(react): re-spec React 18 reset and mid-hydration asserts
cursoragent Sep 11, 2026
1af0bb2
test(react): inline one-off liveTodo helpers in hydration suites
cursoragent Sep 11, 2026
13f7214
feat(react): DataProvider managers accepts a factory
cursoragent Sep 11, 2026
2fcc08f
docs: managers function form is the long-term API; arrays are transit…
cursoragent Sep 11, 2026
39fbaaa
test(react): version-gate managers factory StrictMode call count
cursoragent Sep 13, 2026
66b1471
docs: align Next.js managers changeset with browser factory
cursoragent Sep 13, 2026
b839226
fix(react): address CodeQL findings on SSR script escaping
cursoragent Sep 13, 2026
4aac7ca
docs: describe incremental streamed SSR hydration
cursoragent Sep 15, 2026
f3235b6
fix: satisfy CodeQL script-tag parse and website SVG types
cursoragent Sep 15, 2026
3085fa0
docs: stop claiming unshipped SSR waiters as shipped
cursoragent Sep 15, 2026
8330f6f
docs: simplify streamed hydration diagrams; say RSC not Flight
cursoragent Sep 15, 2026
c0d4538
docs: call streamed hydration mermaid figures, not a sequence
cursoragent Sep 15, 2026
9deb972
docs: fold SSR skill into data-client-react
cursoragent Sep 15, 2026
f1f1412
docs(skills): redo SSR fold as a data-client-react reference
cursoragent Sep 15, 2026
a4c278c
docs(skills): apply SSR reference audit nits
cursoragent Sep 15, 2026
c33f812
docs(skills): trim SSR activation text in data-client-react
cursoragent Sep 15, 2026
057ca57
refactor: own streamed hydration in adapters, not the SPA store
cursoragent Sep 17, 2026
a10c9f4
style: prettier streaming adapter files
cursoragent Sep 17, 2026
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
6 changes: 5 additions & 1 deletion .agents/skills/data-client-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Minimal working examples for each use case live in [references/managers.md](refe
| Custom transport subscriptions | "Reading and Consuming Actions" in [Manager.md](references/Manager.md) | consume `SUBSCRIBE`/`UNSUBSCRIBE` without calling `next` |
| Auth: logout on 401, reset store on deauth | [LogoutManager.md](references/LogoutManager.md) | `handleLogout(controller)` + `controller.resetEntireStore()` |

Do not start channel work before `SUBSCRIBE`; do not treat `SUBSCRIBE` as SSR hydration (see `references/ssr.md` in skill "data-client-react").

## References

For detailed API documentation, see the [references](references/) directory:
Expand Down Expand Up @@ -158,11 +160,13 @@ export default class CustomSubsManager implements Manager {
import { DataProvider, getDefaultManagers } from '@data-client/react';
import ReactDOM from 'react-dom';

const managers = [...getDefaultManagers(), new MyManager()];
const managers = () => [...getDefaultManagers(), new MyManager()];

ReactDOM.createRoot(document.body).render(
<DataProvider managers={managers}>
<App />
</DataProvider>,
);
```

Pass `managers` as a function: it runs once per provider (and once per request with `@data-client/react/nextjs`). Arrays are still accepted by the browser `DataProvider` but are transitional and will be removed; migrate existing `managers={[...]}` to `managers={() => [...]}`.
5 changes: 4 additions & 1 deletion .agents/skills/data-client-react/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: data-client-react
description: Use @data-client/react hooks for data fetching, mutations, and rendering - useSuspense, useFetch, useQuery, useCache, useLive, useDLE, useSubscription, useController, DataProvider, AsyncBoundary, useLoading, useDebounce. Use when reading/rendering remote data, triggering mutations, doing optimistic updates, real-time subscriptions, or wiring Suspense/error boundaries in React.
description: Use @data-client/react hooks for data fetching, mutations, and rendering - useSuspense, useFetch, useQuery, useCache, useLive, useDLE, useSubscription, useController, DataProvider, AsyncBoundary, useLoading, useDebounce. Use when reading/rendering remote data, triggering mutations, doing optimistic updates, real-time subscriptions, wiring Suspense/error boundaries, or SSR/Next.js hydration in React.
license: Apache 2.0
---
## Rendering
Expand Down Expand Up @@ -30,6 +30,8 @@ const comments = use(commentsPromise);

For API definitions (like TodoResource), apply the skill "data-client-rest".

Under SSR the hooks are identical; only the provider entry and how the store is seeded differ. Read [ssr](references/ssr.md) when wiring server rendering or debugging hydration.

## Mutations

```ts
Expand Down Expand Up @@ -151,6 +153,7 @@ For detailed API documentation, see the [references](references/) directory:
- [useLoading](references/useLoading.md);[_useLoading.md](references/_useLoading.md) - Track async mutation state
- [useDebounce](references/useDebounce.md) - Debounce values
- [DataProvider](references/DataProvider.md) - Root provider
- [ssr](references/ssr.md) - SSR entries and hydration (Next.js, `renderToPipeableStream`)
- [data-dependency](references/data-dependency.md) - Rendering guide
- [mutations](references/mutations.md);[_VoteDemo.md](references/_VoteDemo.md) - Mutations guide
- [Actions](references/Actions.md) - Store action types (FETCH, SET, etc.)
Expand Down
122 changes: 122 additions & 0 deletions .agents/skills/data-client-react/references/ssr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# SSR and streamed hydration

Only what differs from a browser-only app. Components, hooks, Managers, and mutations are unchanged: `useSuspense` / `useLive` stay in the component that renders the data. The difference is how the store is seeded.

## Contents

- [Which entry](#which-entry)
- [Next.js App Router](#nextjs-app-router)
- [renderToPipeableStream (Express, Anansi)](#rendertopipeablestream-express-anansi)
- [Next.js Pages Router](#nextjs-pages-router)
- [Not shipped](#not-shipped)

## Which entry

| Host | Import | How the store is seeded |
| --- | --- | --- |
| Browser SPA | `@data-client/react` | Public `DataProvider`; hooks read `StateContext` |
| Next.js App Router | `@data-client/react/nextjs` | Adapter-owned streamed snapshot: inert baseline in the shell, then a `StateDelta` per committed server revision |
| `renderToPipeableStream` (Express, Anansi) | `@data-client/react/ssr` | One-shot snapshot passed as `initialState`. Unstable `__INTERNAL__.StreamingDataProvider` if the host already owns a snapshot store |
| Next.js Pages Router | `@data-client/ssr/nextjs` | One-shot snapshot |

Do not dispatch a root `HYDRATE` action. Stream publication is adapter-internal.

## Next.js App Router

```tsx title="app/layout.tsx"
import { DataProvider } from '@data-client/react/nextjs';
import { AsyncBoundary } from '@data-client/react';

export default function RootLayout({ children }) {
return (
<html>
<body>
<DataProvider>
<AsyncBoundary>{children}</AsyncBoundary>
</DataProvider>
</body>
</html>
);
}
```

Differences from the browser `DataProvider`:

- No `initialState` prop. The store is seeded from the stream; do not pass or replace state per delta.
- `managers` must be a **function**. The server builds a store per request, so an array is rejected. Server-side `cleanup()` is not run per request; do not hold resources in server managers. A function cannot cross the RSC boundary, so custom managers go in a `'use client'` wrapper:

```tsx title="app/Provider.tsx"
'use client';
import { getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';

const managers = () => [...getDefaultManagers(), new MyManager()];

export default function Provider({ children }: { children: React.ReactNode }) {
return <DataProvider managers={managers}>{children}</DataProvider>;
}
```

- `nonce` prop: state is streamed in inline `<script>` tags; pass your CSP nonce through.
- One `DataProvider` per document. Nested or sibling providers share the same streamed state.
- `gcPolicy` applies in the browser only.

How hydration flows:

1. The shell carries an inert baseline; first paint does not wait for Data Client.
2. Each later committed server revision emits a `StateDelta` via `useServerInsertedHTML()`. The Next.js adapter folds queued deltas into its snapshot and publishes them into the live store from a layout effect.
3. If that fold already happened when a Client Component renders, `useSuspense` hits. If RSC starts the island first, a miss fetches like any client render. `useServerInsertedHTML` writes to the HTML stream; it does not order RSC.
4. `SUBSCRIBE` from `useLive` / `useSubscription` is not proof the server delta arrived.

Scope:

- Only the initial document is transferred. Client-side navigation and `router.refresh()` fetch in the browser.
- Partial Prerendering: the static shell's state is transferred; data fetched while resuming dynamic holes is fetched again in the browser.
- Server Components may `await TodoResource.getList(params)` directly. That result is rendered on the server and is not placed in the store.
- Production builds mangle class names: set `static key = 'User'` on every `Entity` (skill "data-client-schema").

## renderToPipeableStream (Express, Anansi)

One-shot snapshot. The server fetches into a per-request store, then serializes it once the tree is ready.

```tsx
import { renderToPipeableStream } from 'react-dom/server';
import { createPersistedStore, createServerDataComponent } from '@data-client/react/ssr';

const [ServerDataProvider, useReadyCacheState, controller] = createPersistedStore();
const ServerDataComponent = createServerDataComponent(useReadyCacheState);

controller.fetch(NeededForPage, { id: 5 });
renderToPipeableStream(
<Document scripts={[<ServerDataComponent key="server-data" />]}>
<ServerDataProvider>{children}</ServerDataProvider>
</Document>,
{ onCompleteShell() { pipe(res); } },
);
```

```tsx
import { awaitInitialData } from '@data-client/react/ssr';

awaitInitialData().then(initialState => {
hydrateRoot(root, <DataProvider initialState={initialState}>{children}</DataProvider>);
});
```

A stable streamed baseline + deltas API is not available on this entry. Hosts that already own a snapshot store can compose `__INTERNAL__.StreamingDataProvider` from `@data-client/react/ssr`; that namespace is unstable and is not a user-facing component.

## Next.js Pages Router

`pages/_document.tsx` exports `DataClientDocument`; `pages/_app.tsx` wraps in `AppDataProvider`, both from `@data-client/ssr/nextjs`. One-shot snapshot. Override `DataClientDocument.getNonce(ctx)` for CSP. Pages reading `useRouter().query` need `getServerSideProps` (even `() => ({ props: {} })`) or the query is empty on first render.

## Not shipped

Do not describe these as current behavior:

- Per-key `useSuspense` waiters (suspend on a pending stream key instead of dispatching `FETCH`).
- Fold-on-script-arrival independent of the receiver layout effect.
- A render-pure revision-visibility protocol: progressively revealed islands still cannot read their server revision through `StateContext` without a live store identity change.
- Schema-aware live merge of streamed slots (`writeDelta` is whole-slot skip/replace) and reset-aware snapshot overlay (`lastReset`).
- A stable streamed baseline + deltas export on `@data-client/react/ssr`.

Consequence today: an RSC-first miss fetches like any client render. Per-delta live publication remains `flushSync` from the receiver. Missing-baseline still suspends while the document is loading. Do not work around these by replacing `initialState`, buffering the shell until all endpoints are known, adding endpoint/schema options for streaming, or dispatching a root hydrate action. A document-wide `DOMContentLoaded` wait is an acceptable interim, not the contract.
23 changes: 23 additions & 0 deletions .changeset/dataprovider-managers-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@data-client/react': minor
---

Add `managers` factory support to `DataProvider`; arrays are now transitional

`managers` accepts a function that creates the managers. It is called once when the provider
mounts, so the same definition works for the Next.js provider (which only accepts a function) and
each `DataProvider` on a page gets its own instances instead of sharing one hoisted array.

```tsx
// Before:
const managers = [...getDefaultManagers(), new MyManager()];
// After:
const managers = () => [...getDefaultManagers(), new MyManager()];

<DataProvider managers={managers}>
<App />
</DataProvider>;
```

Passing an array still works, but the function form is the long-term API and arrays will be
removed in a future release. Migrate when you can.
42 changes: 42 additions & 0 deletions .changeset/nextjs-managers-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@data-client/react': minor
---

BREAKING CHANGE: `managers` of the Next.js `DataProvider` is now a function

The server renders every request with its own store, so manager instances cannot be shared
between requests. Passing an array only ever applied to the browser; the server silently kept
its defaults, so a custom `Manager` ran on one side but not the other. `@data-client/react/nextjs`
now takes a function that is called once per request on the server and once in the browser, and
throws with a migration hint when given an array.

The browser `DataProvider` from `@data-client/react` accepts the same factory;
arrays still work there as a transitional API.

#### Before

```tsx title="app/Provider.tsx"
'use client';
import { getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';

const managers = [...getDefaultManagers(), new MyManager()];

export default function Provider({ children }) {
return <DataProvider managers={managers}>{children}</DataProvider>;
}
```

#### After

```tsx title="app/Provider.tsx"
'use client';
import { getDefaultManagers } from '@data-client/react';
import { DataProvider } from '@data-client/react/nextjs';

const managers = () => [...getDefaultManagers(), new MyManager()];

export default function Provider({ children }) {
return <DataProvider managers={managers}>{children}</DataProvider>;
}
```
6 changes: 6 additions & 0 deletions .changeset/peerdeps-019.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@data-client/img': patch
'@data-client/test': patch
---

Bump `@data-client/react` peer dependency range to include `^0.19.0`.
10 changes: 10 additions & 0 deletions .changeset/ssr-escape-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@data-client/react': patch
---

Fix serialized SSR state breaking out of its `<script>` tag

`ServerData` (used by `createServerDataComponent` and the Next.js provider) wrote the store as-is
into a `<script type="application/json">`. An API response containing `</script>` ended the tag
early, corrupting the page and allowing markup injection. `<`, `>`, `&` and the U+2028/U+2029 line
terminators are now escaped; the payload stays valid JSON.
24 changes: 24 additions & 0 deletions .changeset/streamed-ssr-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@data-client/core': minor
'@data-client/react': minor
'@data-client/vue': patch
---

Stream SSR store state incrementally through adapter-owned composition.

The shell carries an inert baseline. Each later committed server revision emits a
StateDelta. Next.js App Router folds that piece into an adapter-owned snapshot
and publishes it from the receiver layout effect. Several components in one
flush share one delta; a nested child can become readable before its parent; a
later delta may overlap an entity already in the store (three-way merge).
SUBSCRIBE after commit starts live updates and does not gate REST.

Public DataProvider keeps the master store and StateContext reads. Streaming
reducers and snapshot stores stay in ./nextjs and an unstable
`@data-client/react/ssr` `__INTERNAL__` composition seam. HTML insertion order
is not an RSC clock; a Client Component may start before its delta script runs,
and a miss then fetches like any client render. Per-key waiters, revision
visibility, schema-aware streamed merge, and reset-aware overlay are not in this
release.

New exports: StateDelta, StateBaseline, NextDataProviderProps.
2 changes: 1 addition & 1 deletion docs/core/api/Controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ import { unAuth } from '../authentication';

const testKey = (key: string) => key.startsWith(`GET ${myDomain}`);

const managers = [
const managers = () => [
new LogoutManager({
handleLogout(controller) {
// call custom unAuth function we defined
Expand Down
30 changes: 27 additions & 3 deletions docs/core/api/DataProvider.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ in the React tree.
```typescript
interface ProviderProps {
children: ReactNode;
managers?: Manager[];
managers?: Manager[] | (() => Manager[]);
initialState?: State<unknown>;
Controller?: typeof Controller;
devButton?:
Expand Down Expand Up @@ -71,12 +71,36 @@ export interface State<T> {
Instead of starting with an empty cache, you can provide your own initial state. This can
be useful for testing, or rehydrating the cache state when using server side rendering.

### managers?: Manager[] {#managers}
Hooks read that state through `StateContext`. The Next.js App Router provider from
`@data-client/react/nextjs` fills this in from the stream; see the
[SSR guide](../guides/ssr.md#nextjs) for its props.

List of [Manager](./Manager.md)s use. This is the main extensibility point of the provider.
### managers?: Manager[] | (() => Manager[]) {#managers}

List of [Manager](./Manager.md)s to use, or a function that creates them. This is the main
extensibility point of the provider.

[getDefaultManagers()](./getDefaultManagers.md) can be used to extend the default managers.

Both forms are resolved once when the provider mounts. Prefer the function: the same definition
works with the [Next.js provider](../guides/ssr.md#managers), which only accepts a function, and
each `DataProvider` on a page gets its own instances (managers keep a reference to the store they
were attached to).

:::warning Arrays are transitional

Passing `Manager[]` keeps working for now but will be removed in a future release. Migrate to the
function form when you can:

```tsx
// Before:
const managers = [...getDefaultManagers(), new MyManager()];
// After:
const managers = () => [...getDefaultManagers(), new MyManager()];
```

:::

Default Production:

```typescript
Expand Down
11 changes: 6 additions & 5 deletions docs/core/api/DevToolsManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ import {
} from '@data-client/react';
import ReactDOM from 'react-dom';

const managers = getDefaultManagers({
// highlight-next-line
devToolsManager: { trace: true },
});
const managers = () =>
getDefaultManagers({
// highlight-next-line
devToolsManager: { trace: true },
});

ReactDOM.createRoot(document.body).render(
<DataProvider managers={managers}>
Expand All @@ -65,7 +66,7 @@ import {
} from '@data-client/react';
import ReactDOM from 'react-dom';

const managers =
const managers = () =>
process.env.NODE_ENV !== 'production'
? [
// highlight-start
Expand Down
Loading
Loading