# ADR 0006 — Transparent hydration: `renderToStream` embeds, `mount` seeds

- **Status:** Accepted
- **Date:** 2026-07-02
- **Context:** finishing #48/#49 — the "no double-fetch SSR" story — without any
  user-facing hydration API. Supersedes the *mechanism* half of
  [ADR 0004](./0004-universal-state.md) (its decisions 1–2: the payload and its
  before-first-render seeding semantics stand; the `dehydrate()` /
  `mount({ hydrate, queries })` / `<HydrationBoundary>` surface does not).

## Context

ADR 0004 shipped the primitives (serializable atoms, `seedQuery`, effect adoption) behind a
manual surface: the server called `dehydrate()` and the client passed `{ hydrate, queries }` to
`mount`. That leaks hydration plumbing into feature code, against the north star that you never
type server-vs-client.

A first attempt at making it automatic — an in-tree `<script>` state emitter rendered inside the
Suspense boundary — is unsound: during a single streaming render the emitter renders *before*
async queries settle (they are still `pending` at emit time) and never re-renders post-settlement,
so it can only ever capture atoms, not query results. In-tree emission was abandoned.

## Decisions

### 1. A server-only `/ssr` subpath owns the server half

`renderToStream(source, Root, options?)` on `@tmonier/effract/ssr` mirrors `mount`'s signature and
compile-time checks (missing services, loading obligation). It renders `mount(runtime, Root)`
through `react-dom/server`'s `renderToReadableStream`, **awaits `allReady`** (post-settlement —
the fix for the in-tree dead end), then appends one
`<script type="application/json" data-effract-state>` holding `{ atoms, queries }`: the
`dehydrate()` payload plus every keyed, settled query in this runtime's scope
(`collectSeededQueries`). A separate subpath keeps `react-dom/server` out of every client bundle.
`options.shell` wraps the app in a document; the remaining options pass through to
`renderToReadableStream`.

The script is appended **after** the HTML, outside the React root — so there is no hydration
mismatch to manage, ever. Payload text is escaped (`<`, `>`, `&`, U+2028/29 → `\uXXXX` inside the
JSON) so it cannot break out of the tag.

Because nothing reads the stream until `renderToStream` returns, React flushes completed Suspense
boundaries inline — the HTML a consumer receives is the settled tree, with no fallback machinery.

A `Layer` source builds a fresh runtime for the render and disposes it before returning
(per-request isolation, like the RSC `mount`); a pre-built `ManagedRuntime` lets a server pre-seed
state (run an effect against it first) and keeps ownership.

### 2. The client `mount` seeds automatically

Every client `mount` wraps the tree in an internal `AutoSeed`: on first render — before children —
it looks up `script[data-effract-state]`, `JSON.parse`s it once, `hydrate`s atoms and
`seedQuery`s the query cache. No script (fresh CSR, non-DOM host, the server pass itself) → no-op.
Atoms seed **once per script element** (a second mount must not clobber user changes); queries
seed per runtime (an already-live key is left alone).

`hydrate` is order-independent: a serialKey whose atom does not exist yet (it lives in a service
resolved deeper in the same render) is stashed and applied the moment the atom registers,
consume-once. That is what lets service-held atoms hydrate with zero coordination.

### 3. The public surface shrinks to one opt-in

`dehydrate`/`hydrate` are internal; `HydrationBoundary`, `SeededQuery`, and the
`{ hydrate, queries }` `MountOptions` are gone from the public entries. The only thing a user
writes is `atom.serializable(initial, key, schema)` — marking the state that may cross the wire.

### 4. Runtime-scope identity moves to the `ManagedRuntime`

Query scoping (`runtimeScopeOf`) was keyed by the per-mount executor; it is now keyed by the
runtime object, so the server side can collect exactly the render's queries from outside React —
and two mounts sharing one injected runtime now share one query scope (same services, same data).

## Consequences

- SSR is two calls with zero hydration code: `renderToStream(...)` on the server, the ordinary
  `mount(...)` on the client. No mismatch, no refetch, server state on first paint
  (integration-tested end to end, `apps/bun-ssr` wired as the live demo).
- The returned stream is complete before it is returned — TTFB waits for settlement. Progressive
  shell streaming with a raced state script is future work; correctness won.
- Keys and values of seeded queries cross as JSON, so they must be JSON-serialisable; failed or
  pending queries and keyless `suspend`s are skipped (the client fetches those itself).
- The serializable-atom registry remains module-global: on a server, concurrent requests that
  mutate atoms under the *same* serialKey can still bleed into each other's `dehydrate()` payload.
  Unchanged from ADR 0004; per-request registry scoping is future work.

## Alternatives considered

- **In-tree `<script>` emitter.** Rejected — proven unsound (renders pre-settlement, never
  re-renders; see Context).
- **Streaming with a `TransformStream` flush collecting state at close.** Correct and preserves
  progressive streaming, but the client bootstrap could hydrate before the trailing state script
  arrives; deferred until the story needs progressive shells.
- **Keeping the manual `{ hydrate, queries }` options alongside.** Rejected — two ways to do one
  thing, and the manual one invites exactly the plumbing the design forbids.
