diff --git a/.agents/skills/data-client-manager/SKILL.md b/.agents/skills/data-client-manager/SKILL.md index b5a4193d09a1..2be2a36335e4 100644 --- a/.agents/skills/data-client-manager/SKILL.md +++ b/.agents/skills/data-client-manager/SKILL.md @@ -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: @@ -158,7 +160,7 @@ 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( @@ -166,3 +168,5 @@ ReactDOM.createRoot(document.body).render( , ); ``` + +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={() => [...]}`. diff --git a/.agents/skills/data-client-react/SKILL.md b/.agents/skills/data-client-react/SKILL.md index cd689281727a..8983b4af26e5 100644 --- a/.agents/skills/data-client-react/SKILL.md +++ b/.agents/skills/data-client-react/SKILL.md @@ -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 @@ -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 @@ -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.) diff --git a/.agents/skills/data-client-react/references/ssr.md b/.agents/skills/data-client-react/references/ssr.md new file mode 100644 index 000000000000..730ac70216dc --- /dev/null +++ b/.agents/skills/data-client-react/references/ssr.md @@ -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 ( + + + + {children} + + + + ); +} +``` + +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 {children}; +} +``` + +- `nonce` prop: state is streamed in inline `` 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. diff --git a/.changeset/streamed-ssr-hydration.md b/.changeset/streamed-ssr-hydration.md new file mode 100644 index 000000000000..4a9208905029 --- /dev/null +++ b/.changeset/streamed-ssr-hydration.md @@ -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. diff --git a/docs/core/api/Controller.md b/docs/core/api/Controller.md index 76d2b7ecea0b..a04e56dbd7e1 100644 --- a/docs/core/api/Controller.md +++ b/docs/core/api/Controller.md @@ -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 diff --git a/docs/core/api/DataProvider.md b/docs/core/api/DataProvider.md index 7988ce2220d3..fd582bae90fe 100644 --- a/docs/core/api/DataProvider.md +++ b/docs/core/api/DataProvider.md @@ -21,7 +21,7 @@ in the React tree. ```typescript interface ProviderProps { children: ReactNode; - managers?: Manager[]; + managers?: Manager[] | (() => Manager[]); initialState?: State; Controller?: typeof Controller; devButton?: @@ -71,12 +71,36 @@ export interface State { 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 diff --git a/docs/core/api/DevToolsManager.md b/docs/core/api/DevToolsManager.md index 921615825e0c..8112990e81f1 100644 --- a/docs/core/api/DevToolsManager.md +++ b/docs/core/api/DevToolsManager.md @@ -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( @@ -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 diff --git a/docs/core/api/LogoutManager.md b/docs/core/api/LogoutManager.md index 7e96a1b06b84..a2f3dc0d578f 100644 --- a/docs/core/api/LogoutManager.md +++ b/docs/core/api/LogoutManager.md @@ -40,7 +40,7 @@ import { import ReactDOM from 'react-dom'; // highlight-next-line -const managers = [new LogoutManager(), ...getDefaultManagers()]; +const managers = () => [new LogoutManager(), ...getDefaultManagers()]; ReactDOM.createRoot(document.body).render( @@ -62,7 +62,7 @@ import { import { AppRegistry } from 'react-native'; // highlight-next-line -const managers = [new LogoutManager(), ...getDefaultManagers()]; +const managers = () => [new LogoutManager(), ...getDefaultManagers()]; const Root = () => ( @@ -82,7 +82,7 @@ import { LogoutManager, getDefaultManagers } from '@data-client/react'; import { DataProvider } from '@data-client/react/nextjs'; // highlight-next-line -const managers = [new LogoutManager(), ...getDefaultManagers()]; +const managers = () => [new LogoutManager(), ...getDefaultManagers()]; export default function Provider({ children, @@ -124,7 +124,7 @@ import { import { useColorScheme } from '@/hooks/useColorScheme'; // highlight-next-line -const managers = [new LogoutManager(), ...getDefaultManagers()]; +const managers = () => [new LogoutManager(), ...getDefaultManagers()]; export default function Provider({ children, @@ -175,7 +175,7 @@ export default function RootLayout() { ```ts import { unAuth } from '../authentication'; -const managers = [ +const managers = () => [ new LogoutManager({ handleLogout(controller) { // call custom unAuth function we defined @@ -198,7 +198,7 @@ import { unAuth } from '../authentication'; // highlight-next-line const testKey = (key: string) => key.startsWith(`GET ${myDomain}`); -const managers = [ +const managers = () => [ new LogoutManager({ handleLogout(controller) { // call custom unAuth function we defined diff --git a/docs/core/api/Manager.md b/docs/core/api/Manager.md index 57fa120c4878..f0f771a0eb96 100644 --- a/docs/core/api/Manager.md +++ b/docs/core/api/Manager.md @@ -65,9 +65,18 @@ Provides any cleanup of dangling resources after manager is no longer in use. ## Adding managers to Reactive Data Client {#adding} -Use the [managers](../api/DataProvider.md#managers) prop of [DataProvider](../api/DataProvider.md). Be -sure to hoist to _module level_ or wrap in a _useMemo()_ to ensure they are not recreated. Managers -have internal state, so it is important to not constantly recreate them. +Use the [managers](../api/DataProvider.md#managers) prop of [DataProvider](../api/DataProvider.md) +with a function that creates them. The provider calls it once when it mounts, so each store gets +its own instances and the same definition works for the [Next.js provider](../guides/ssr.md#managers). + +:::note Migrating from arrays + +`managers` also still accepts an array of instances. This form is transitional and will be removed +in a future release; change `managers={[...]}` to `managers={() => [...]}` when you can. Managers +have internal state, so an array must be hoisted to _module level_ or wrapped in _useMemo()_ to +avoid recreating them. + +::: [...getDefaultManagers(), new MyManager()]; ReactDOM.createRoot(document.body).render( @@ -102,7 +111,7 @@ ReactDOM.createRoot(document.body).render( import { DataProvider, getDefaultManagers } from '@data-client/react'; import { AppRegistry } from 'react-native'; -const managers = [...getDefaultManagers(), new MyManager()]; +const managers = () => [...getDefaultManagers(), new MyManager()]; const Root = () => ( @@ -116,13 +125,16 @@ AppRegistry.registerComponent('MyApp', () => Root); +The server renders every request with its own store, so the Next.js provider takes a function +instead of instances: it runs once per request on the server and once in the browser. + ```tsx title="app/Provider.tsx" 'use client'; import { getDefaultManagers } from '@data-client/react'; import { DataProvider } from '@data-client/react/nextjs'; // highlight-next-line -const managers = [...getDefaultManagers(), new MyManager()]; +const managers = () => [...getDefaultManagers(), new MyManager()]; export default function Provider({ children, @@ -160,7 +172,7 @@ import { import { useColorScheme } from '@/hooks/useColorScheme'; // highlight-next-line -const managers = [...getDefaultManagers(), new MyManager()]; +const managers = () => [...getDefaultManagers(), new MyManager()]; export default function Provider({ children, diff --git a/docs/core/api/PollingSubscription.md b/docs/core/api/PollingSubscription.md index f33adb0edf32..7790497b894f 100644 --- a/docs/core/api/PollingSubscription.md +++ b/docs/core/api/PollingSubscription.md @@ -25,7 +25,7 @@ import { } from '@data-client/react'; import ReactDOM from 'react-dom'; -const managers = [ +const managers = () => [ new NetworkManager(), new SubscriptionManager(PollingSubscription) ] diff --git a/docs/core/api/getDefaultManagers.md b/docs/core/api/getDefaultManagers.md index 28f3fdb665d0..324cc351479f 100644 --- a/docs/core/api/getDefaultManagers.md +++ b/docs/core/api/getDefaultManagers.md @@ -27,10 +27,11 @@ import { import ReactDOM from 'react-dom'; // highlight-start -const managers = getDefaultManagers({ - // set fallback expiry time to an hour - networkManager: { dataExpiryLength: 1000 * 60 * 60 }, -}); +const managers = () => + getDefaultManagers({ + // set fallback expiry time to an hour + networkManager: { dataExpiryLength: 1000 * 60 * 60 }, + }); // highlight-end ReactDOM.createRoot(document.body).render( @@ -86,7 +87,8 @@ A class that implements `SubscriptionConstructable` like [PollingSubscription](. For example, we can enable the [trace](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#trace) option to help track down where actions are dispatched from. This has a large performance impact, so it is normally disabled. ```ts -const managers = getDefaultManagers({ +const managers = () => + getDefaultManagers({ // highlight-next-line devToolsManager: { trace: true }, }); @@ -99,7 +101,8 @@ Sending manager instances allows us to customize managers using inheritance. ```ts import { IdlingNetworkManager } from '@data-client/react'; -const managers = getDefaultManagers({ +const managers = () => + getDefaultManagers({ networkManager: new IdlingNetworkManager(), }); ``` @@ -112,7 +115,8 @@ until animations are complete. This works in web using [requestIdleCallback](htt Using `null` will remove managers completely. [NetworkManager](./NetworkManager.md) cannot be removed this way. ```ts -const managers = getDefaultManagers({ +const managers = () => + getDefaultManagers({ devToolsManager: null, subscriptionManager: null, }); diff --git a/docs/core/concepts/managers.md b/docs/core/concepts/managers.md index d2f0049d9308..4489764c9004 100644 --- a/docs/core/concepts/managers.md +++ b/docs/core/concepts/managers.md @@ -258,6 +258,7 @@ export default class PersistManager implements Manager { ```tsx import { get } from 'idb-keyval'; +const managers = () => [...getDefaultManagers(), new PersistManager()]; const initialState = await get('data-client'); createRoot(document.body).render( diff --git a/docs/core/diagrams/_streamed_hydration.mdx b/docs/core/diagrams/_streamed_hydration.mdx new file mode 100644 index 000000000000..5eae41e70b0f --- /dev/null +++ b/docs/core/diagrams/_streamed_hydration.mdx @@ -0,0 +1,142 @@ +Thick-border nodes on the overview are black boxes. Each zoom opens **one** box. + +```mermaid +flowchart LR + Baseline["Baseline
shell paints now"] + subgraph pieces["Pieces arrive"] + direction TB + FirstFlush["First flush"] + NestedIsland["Nested island"] + LaterOverlap["Later overlap"] + end + Hydrate["Island hydrates"] + Subscribe["Subscribe for live updates"] + Insertion["RSC vs renderToPipeableStream"] + + Baseline --> pieces + Insertion -.-> pieces + pieces --> Hydrate + Hydrate --> Subscribe + + style FirstFlush stroke-width:3px + style NestedIsland stroke-width:3px + style LaterOverlap stroke-width:3px + style Hydrate stroke-width:3px + style Insertion stroke-width:3px +``` + +| Piece | Server revision | Endpoints in the piece | Entities | Who hydrates from it | +| --- | --- | --- | --- | --- | +| **Baseline** | empty request store | — | — | nobody; shell only | +| **First flush** | one flush, two islands | `getTickers`, `getSymbolInfo(BTC)` | `Ticker:BTC`, `Ticker:ETH`, `Symbol:BTC` | Watchlist + SymbolHeader | +| **Nested island** | nested child first | `getTrades(BTC)` | `Trade:…` | Tape, before parent Book exists | +| **Later overlap** | disjoint parent later | `getOrderBook(BTC)` | `Book:BTC`, plus `Ticker:BTC` again (constructed nested ticker) | Book; live three-way merge vs tick | + +### Island hydrates + +Opens **Island hydrates**. Shared path for every piece. + +```mermaid +flowchart TB + Server["Request store"] + Baseline["Baseline"] + Delta["StateDelta"] + Snapshot["Hydration snapshot"] + LiveStore["Live store"] + Island["Island"] + SubMgr["SubscriptionManager"] + + Server --> Baseline + Server --> Delta + Baseline -->|"seed once"| Snapshot + Delta -->|"fold this piece"| Snapshot + Snapshot -->|"HYDRATE"| LiveStore + Island -->|"useSuspense"| Snapshot + Snapshot -->|"cache hit"| Island + Island -->|"after commit SUBSCRIBE"| SubMgr + SubMgr -->|"ticks"| LiveStore +``` + +### First flush + +Opens **First flush**. One server revision, two islands, one `StateDelta`. + +```mermaid +flowchart LR + Revision["One server revision"] + Delta["One StateDelta"] + Watchlist["Watchlist
getTickers"] + SymbolHeader["SymbolHeader
getSymbolInfo"] + Market["Market still pending"] + + Revision -->|"SET_RESPONSE ×2"| Delta + Delta --> Watchlist + Delta --> SymbolHeader + Watchlist -.-> Market + SymbolHeader -.-> Market +``` + +### Nested island + +Opens **Nested island**. Tree order is not stream order. + +```mermaid +flowchart TB + Market["Market"] + Book["Book — still pending"] + Tape["Tape / Trades"] + Delta["Trades StateDelta"] + + Market --- Book + Market --- Tape + Delta --> Tape +``` + +### Later overlap + +Opens **Later overlap**. A later disjoint piece writes an entity the first flush already put in the store. + +```mermaid +flowchart LR + FirstFlush["First flush wrote Ticker:BTC"] + LiveTick["Live tick may already own that slot"] + BookPiece["Later book piece writes Book:BTC
and Ticker:BTC again"] + Merge["Three-way merge"] + LiveStore["Live store"] + + FirstFlush --> LiveStore + FirstFlush --> LiveTick + LiveTick --> Merge + BookPiece --> Merge + Merge --> LiveStore +``` + +### RSC vs renderToPipeableStream + +Opens **RSC vs renderToPipeableStream**. Two clocks, not two products. + +```mermaid +flowchart TB + subgraph htmlStream["Streaming HTML"] + Insert["useServerInsertedHTML"] + RTP["renderToPipeableStream"] + Chunk["HTML chunk
baseline, then StateDelta + island markup"] + Insert -->|"Next App Router"| Chunk + RTP -->|"state piece colocated
with the revealing Suspense"| Chunk + end + subgraph rsc["RSC"] + Payload["RSC payload"] + Client["Client Component can start
before the HTML delta runs"] + Payload --> Client + end + Snapshot["Hydration snapshot"] + Chunk --> Snapshot + Client -->|"useSuspense"| Snapshot +``` + +| Wire | Host | How a piece gets on the wire | What it cannot promise | +| --- | --- | --- | --- | +| **Streaming HTML + RSC** | `@data-client/react/nextjs` | `useServerInsertedHTML()` writes the inert baseline, then a `StateDelta` script per flush, into the **HTML** stream | It cannot order that script against **RSC**. A Client Component may start before the delta that describes it has executed. | +| **`renderToPipeableStream`** | `@data-client/react/ssr` (Express, Anansi) | Intended: a state-piece colocated in the revealing Suspense/island so React emits the delta and the dependent markup as one ordered subtree | Not shipped in this release — generic `/ssr` remains a one-shot snapshot. Parser/hydration order is still not a contract. | + +The figures are the **intended** client clock. This release does not throw per-key waiters or fold independently of the receiver layout effect. diff --git a/docs/core/getting-started/debugging.md b/docs/core/getting-started/debugging.md index 9d8e40914114..706e1545b6ce 100644 --- a/docs/core/getting-started/debugging.md +++ b/docs/core/getting-started/debugging.md @@ -134,10 +134,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( diff --git a/docs/core/guides/ssr.md b/docs/core/guides/ssr.md index e3dda2158484..e58d8ad8522e 100644 --- a/docs/core/guides/ssr.md +++ b/docs/core/guides/ssr.md @@ -6,6 +6,7 @@ sidebar_label: Server Side Rendering import PkgTabs from '@site/src/components/PkgTabs'; import StackBlitz from '@site/src/components/StackBlitz'; +import StreamedHydration from '../diagrams/\_streamed_hydration.mdx'; @@ -18,6 +19,44 @@ Client takes this one step further by pre-populating the data store. Unlike othe Reactive Data Client becomes interactive the moment the page is visible, making [data mutations](../getting-started/mutations.md) instantaneous. Additionally there is no need for additional data fetches that increase server load and slow client hydration, potentially causing application stutters. +## Incremental streamed hydration {#streamed-hydration} + +The store is not a second document that waits for the page. It is the same stream, in generations: + +1. The shell carries an **inert baseline**. First paint is not delayed for Data Client. +2. Each later committed server revision emits a **StateDelta**. The Next.js adapter folds queued pieces into its own snapshot and publishes them into the live store from the receiver layout effect. Public [`DataProvider`](../api/DataProvider.md) reads stay on [`StateContext`](../api/DataProvider.md); streaming is adapter-owned. +3. Several islands that finish in one flush share one delta. A nested child may become readable before its parent. A later island may write an entity already present from an earlier delta; the three-way merge keeps slots the client already changed. +4. If that fold has already happened, [`useSuspense()`](../api/useSuspense.md) hits. If **RSC** starts the island first, a miss fetches like any client render. +5. After the island commits, [`useLive()`](../api/useLive.md) / [`useSubscription()`](../api/useSubscription.md) dispatch [`SUBSCRIBE`](../api/Actions.md#subscribe) so WebSocket or polling may start. Subscription is not proof the server delta arrived. + +Next.js App Router emits this protocol. Pages Router (`@data-client/ssr/nextjs`) and generic `@data-client/react/ssr` (Express, Anansi, [`renderToPipeableStream`](https://react.dev/reference/react-dom/server/renderToPipeableStream)) remain a one-shot document snapshot, plus an unstable internal composition seam for hosts that already own a snapshot store. + +[`useServerInsertedHTML()`](https://nextjs.org/docs/app/api-reference/functions/use-server-inserted-html) writes into the **HTML** stream. It does not order **RSC**. A Client Component may start before its delta script runs and before the receiver layout effect. Script-before-HTML is insertion order, not a zero-refetch guarantee. + +``` +MarketPage +├── Shell provider, chrome — no Data Client read +├── Watchlist useLive(getTickers) +├── SymbolHeader useLive(getSymbolInfo, { symbol: 'BTC' }) +└── Market Suspense (late) + ├── Book useLive(getOrderBook, { symbol: 'BTC' }) + └── Tape nested Suspense + └── Trades useLive(getTrades, { symbol: 'BTC' }) +``` + +The figures below are **one concept each**: an overview with black boxes, then a zoom that opens that box. They describe the **intended** client clock (per-key waiters and fold-on-script). This release ships the wire (baseline + `StateDelta`) and an adapter-owned receiver. Live publication is still per-delta from a layout effect. + + + +### Open questions {#streamed-hydration-open} + +- **Per-key waiters** and **fold-on-script-arrival** (independent of `StreamedStateReceiver`’s layout effect) remain future client-clock work. Until they land, an RSC-first miss fetches like any client render. +- A render-pure way for a progressively revealed island to read the server revision it was rendered from, without a live `StateContext` identity change that client-renders still-pending siblings, is not shipped. +- Per-delta live publication (`flushSync` plus a live store write) is preserved adapter behavior, not the concurrent happy path. +- Streamed deltas write whole entity/meta slots without schema merge (`writeDelta`). Overlay of a snapshot onto live state does not compare `lastReset`. +- Incremental baseline-plus-delta for generic `renderToPipeableStream` / Anansi is not a stable public API. `@data-client/react/ssr` keeps one-shot helpers; hosts can compose an unstable internal `StreamingDataProvider` if they already own a snapshot store. +- A document-wide [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/API/Document/DOMContentLoaded_event) wait is an acceptable interim, not the long-term contract. Missing-baseline still suspends while the document is loading. + ## NextJS SSR {#nextjs} ### App Router @@ -50,6 +89,101 @@ export default function RootLayout({ children }) { } ``` +Async Server Components anywhere between the provider and your Client Components are fine. +Each committed server revision emits a `StateDelta` that the Next.js adapter folds into its +snapshot and publishes into the live store from the receiver layout effect. 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. + +```tsx title="app/[userId]/layout.tsx" +export default async function UserLayout({ children, params }) { + // resolves after the shell has already been sent + const { userId } = await params; + return
{children}
; +} +``` + +#### Props + +```typescript +interface NextDataProviderProps { + children: ReactNode; + managers?: () => Manager[]; + nonce?: string; + Controller?: typeof Controller; + gcPolicy?: GCInterface; + devButton?: DevToolsPosition | null; +} +``` + +`Controller` applies on both server and client. `gcPolicy` applies in the browser only: a +request-scoped server store has nothing to collect. + +##### managers {#managers} + +The server builds a store per request, so [Managers](../api/Manager.md) must be created per +request as well. It takes a **function**, called once per +request on the server and once in the browser (the [browser DataProvider](../api/DataProvider.md#managers) +accepts the same function; its array form is transitional): + +```tsx title="app/Provider.tsx" +'use client'; +import { getDefaultManagers } from '@data-client/react'; +import { DataProvider } from '@data-client/react/nextjs'; + +// highlight-next-line +const managers = () => [...getDefaultManagers(), new MyManager()]; + +export default function Provider({ children }: { children: React.ReactNode }) { + return {children}; +} +``` + +Manager instances shared across requests would mix up users' data, so an array is rejected. +Server-side managers should not hold resources: their `cleanup()` is not run per request. + +##### nonce {#nonce} + +State is streamed in inline `', + line: 'a\u2028b\u2029c', + amp: 'x & y > z', + __proto__: { polluted: true }, + }; + + it('keeps the payload inside its script tag', () => { + const escaped = escapeJsonForScript(JSON.stringify(hostile)); + expect(escaped).not.toContain('<'); + expect(escaped).not.toContain('>'); + expect(escaped).not.toContain('&'); + expect(escaped).not.toContain('\u2028'); + expect(escaped).not.toContain('\u2029'); + }); + + it('remains valid JSON with the same content', () => { + const parsed = JSON.parse(escapeJsonForScript(JSON.stringify(hostile))); + expect(parsed).toEqual(JSON.parse(JSON.stringify(hostile))); + expect(parsed.title).toBe(hostile.title); + }); + + it('works as the argument of an inline JSON.parse string literal', () => { + const literal = escapeJsonForScript( + JSON.stringify(JSON.stringify(hostile)), + ); + const parsed = new Function(`return JSON.parse(${literal})`)(); + expect(parsed).toEqual(JSON.parse(JSON.stringify(hostile))); + expect(({} as any).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype); + }); + + it('is applied by ServerData', () => { + const html = renderToString( + , + ); + const open = html.indexOf('', open); + const close = html.toLowerCase().indexOf(' m instanceof NetworkManager, - ) as any; - if (networkManager === undefined) - throw new Error('managers must include a NetworkManager'); - const reducer = createReducer(controller); - const enhancer = applyMiddleware( - // redux 5's types are wrong and do not allow any return typing from next, which is incorrect. - // `next: (action: unknown) => unknown`: allows any action, but disallows all return types. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - ...applyManager(managers, controller), - PromiseifyMiddleware, - ); - const store = createStore(reducer, initialState as any, enhancer); - initManager(managers, controller, store.getState())(); + const { store, controller, networkManager } = createServerStore(managers); const selector = (state: any) => state; diff --git a/packages/react/src/server/createServerStore.ts b/packages/react/src/server/createServerStore.ts new file mode 100644 index 000000000000..515179d0a77b --- /dev/null +++ b/packages/react/src/server/createServerStore.ts @@ -0,0 +1,38 @@ +import { + Controller, + NetworkManager, + initialState, + createReducer, + applyManager, + initManager, +} from '@data-client/core'; +import type { Manager } from '@data-client/core'; + +import { PromiseifyMiddleware } from './redux/index.js'; +import { createStore, applyMiddleware } from './redux/redux.js'; +import { NetworkManager as ReactNetworkManager } from '../managers/index.js'; + +/** Redux-style store for one server render; managers must include a NetworkManager */ +export default function createServerStore( + managers?: Manager[], + ControllerClass: typeof Controller = Controller, +) { + const controller = new ControllerClass(); + managers = managers ?? [new ReactNetworkManager()]; + const networkManager = managers.find(m => m instanceof NetworkManager) as + NetworkManager | undefined; + if (networkManager === undefined) + throw new Error('managers must include a NetworkManager'); + const reducer = createReducer(controller); + const enhancer = applyMiddleware( + // redux 5's types are wrong and do not allow any return typing from next, which is incorrect. + // `next: (action: unknown) => unknown`: allows any action, but disallows all return types. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...applyManager(managers, controller), + PromiseifyMiddleware, + ); + const store = createStore(reducer, initialState as any, enhancer); + initManager(managers, controller, store.getState())(); + return { store, controller, networkManager }; +} diff --git a/packages/react/src/server/escapeJsonForScript.ts b/packages/react/src/server/escapeJsonForScript.ts new file mode 100644 index 000000000000..33e72937cfe7 --- /dev/null +++ b/packages/react/src/server/escapeJsonForScript.ts @@ -0,0 +1,17 @@ +const ESCAPES: Record = { + '<': '\\u003c', + '>': '\\u003e', + '&': '\\u0026', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +/** + * Makes JSON text safe to inline inside a `\u2028'; + const getTodo = makeGetTodo({ '1': evil }); + const html = await renderPage( + + + + + + + + + + , + ); + const payloads = readPayloads(html); + const folded = fold(payloads); + expect((folded.entities.Todo!['1'] as Todo).title).toBe(evil); + // the hostile pk is dropped rather than assigned onto the table prototype + expect(Object.getPrototypeOf(folded.entities.Todo)).toBe(Object.prototype); + expect(({} as any).title).toBeUndefined(); + const scripts = html.match(/` can no longer break out of its tag +- `nonce` prop on the Next.js [DataProvider](/docs/guides/ssr#nonce) for Content-Security-Policy + +**[Breaking Changes:](/blog/2026/09/11/v0.19-streamed-nextjs-ssr-managers-factory#migration-guide)** + +- [Next.js `DataProvider` `managers` is a function](/blog/2026/09/11/v0.19-streamed-nextjs-ssr-managers-factory#nextjs-managers) - `managers={[...]}` → `managers={() => [...]}`. Affects `@data-client/react/nextjs` only. + +{/* truncate */} + +## Incremental streamed hydration {#streamed-ssr} + +The shell carries an inert baseline. Each later committed server revision emits a `StateDelta`. +The Next.js adapter folds that piece into its snapshot and publishes it into the live store from +the receiver layout effect. Public `DataProvider` keeps `StateContext` reads. + +```tsx title="app/[userId]/page.tsx" +export default async function TodoPage({ params }) { + const { userId } = await params; // resolves after the shell was sent + return ; // 'use client' + useSuspense +} +``` + +On Next.js, RSC may run a Client Component before the HTML delta and before that layout +effect. A miss then fetches like any client render. Per-key waiters that would suppress that +refetch are not in this release. The figures are in the +[SSR guide](/docs/guides/ssr#streamed-hydration). Next.js App Router is the first adapter; +generic `renderToPipeableStream` remains a one-shot document snapshot. + +Nothing changes in how you write components. See the [SSR guide](/docs/guides/ssr#nextjs) for the +`nonce` prop and limitations. + +## `managers` as a function {#managers-factory} + +[DataProvider](/docs/api/DataProvider#managers) accepts a function that creates the managers. It is +called once when the provider mounts. + +```tsx +import { DataProvider, getDefaultManagers } from '@data-client/react'; + +// highlight-next-line +const managers = () => [...getDefaultManagers(), new MyManager()]; + + + +; +``` + +This is the form the library is moving to. The Next.js provider builds a store per request and can +only take a function; in the browser it also gives each `DataProvider` on a page its own instances +instead of one hoisted array bound to whichever store mounted last. + +:::warning Arrays are transitional + +`managers={[...]}` still works on the browser `DataProvider`, but will be removed in a future +release. Migrate to `managers={() => [...]}` as soon as you can. + +::: + +## Migration guide + + + +### @data-client/react 0.19 + +#### For all users + +Optional now, required later: pass `managers` as a function. + + + +```tsx title="Before" +import { DataProvider, getDefaultManagers } from '@data-client/react'; + +const managers = [...getDefaultManagers(), new MyManager()]; + + + +; +``` + +```tsx title="After" +import { DataProvider, getDefaultManagers } from '@data-client/react'; + +const managers = () => [...getDefaultManagers(), new MyManager()]; + + + +; +``` + + + +#### For Next.js App Router users {#nextjs-managers} + +Skip this section if you do not pass `managers` to the `@data-client/react/nextjs` provider. + +The same change is required here: an array throws with a migration hint. The function runs once per +request on the server and once in the browser. + + + +```tsx title="Before" +'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 {children}; +} +``` + +```tsx title="After" +'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 {children}; +} +``` + + + +### Upgrade support + +As usual, if you have any troubles or questions, feel free to join our [![Chat](https://img.shields.io/discord/768254430381735967.svg?style=flat-square&colorB=758ED3)](https://discord.gg/wXGV27xm6t) or [file a bug](https://github.com/reactive/data-client/issues/new/choose) diff --git a/website/src/components/HomepageFeatures.tsx b/website/src/components/HomepageFeatures.tsx index d68234be891e..c6256afc0dfa 100644 --- a/website/src/components/HomepageFeatures.tsx +++ b/website/src/components/HomepageFeatures.tsx @@ -9,7 +9,7 @@ import ChemicalCompositionSvg from '../../static/img/chemical-composition.svg'; import FastCarSvg from '../../static/img/fast-car.svg'; import GrowingBarChartSvg from '../../static/img/growing-bar-chart.svg'; -type SvgComponent = React.ComponentType>; +type SvgComponent = typeof FastCarSvg; interface BaseFeature { description: React.ReactNode; diff --git a/yarn.lock b/yarn.lock index 549f78c3ed51..7394ca0027fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3245,7 +3245,7 @@ __metadata: react-dom: "npm:^19.2.1" rollup-plugins: "workspace:*" peerDependencies: - "@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 + "@data-client/react": ^0.1.0 || ^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0 "@types/react": ^16.14.0 || ^17.0.0 || ^18.0.0-0 || ^19.0.0 react: ^16.14.0 || ^17.0.0 || ^18.0.0-0 || ^19.0.0 peerDependenciesMeta: @@ -3305,11 +3305,14 @@ __metadata: "@react-navigation/native": ^6.0.0 || ^7.0.0 "@types/react": ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-0 || ^19.0.0 react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0-0 || ^19.0.0 peerDependenciesMeta: "@react-navigation/native": optional: true "@types/react": optional: true + react-dom: + optional: true languageName: unknown linkType: soft @@ -3352,7 +3355,7 @@ __metadata: react-test-renderer: "npm:*" rollup-plugins: "workspace:*" peerDependencies: - "@data-client/react": ^0.12.15 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 + "@data-client/react": ^0.12.15 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0 "@testing-library/react-hooks": ^8.0.0 "@testing-library/react-native": ^13.0.0 "@types/react": ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-0 || ^19.0.0