Skip to content

test(react): Lane A streamed-hydration contract (failing TDD) - #4092

Draft
ntucker wants to merge 28 commits into
cursor/nextjs-streamed-ssr-handoff-911dfrom
cursor/lane-a-hydration-contract-c544
Draft

ntucker wants to merge 28 commits into
cursor/nextjs-streamed-ssr-handoff-911dfrom
cursor/lane-a-hydration-contract-c544

Conversation

@ntucker

@ntucker ntucker commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Lane B (PR 4090) ships the wire (inert baseline + StateDelta + layout-effect HYDRATE) with honest docs. It does not ship per-key waiters, fold-on-script, or generic renderToPipeableStream baseline+delta.

These tests encode that remaining Lane A contract so React-version upgrades and data-client internals cannot silently drop it. They are expected to fail on 4090 head: a miss still FETCHes, and /ssr is still one-shot.

Do not merge this onto Lane B. Do not merge 4090 from this PR.

Solution

Failing tests only (no product waiters; no docs; no skills — the SSR skill fold already lives on 4090 tip 960349ccde):

  • packages/react/src/server/nextjs/DataProvider/__tests__/lane-a-contract.web.tsx — ReactDOM matrix (^18 + latest; skipped on 16/17). Fetch-count + event order:
    • RSC-first miss → 0 REST until the matching delta, DOM while the stream is still open
    • batch flush (one StateDelta, two islands) → 0 REST
    • nested island: child delta hydrates child; parent stays pending (not satisfied by the child piece)
    • later overlap: disjoint later delta hydrates without REST
    • leftover key: 0 REST while the stream is open; exactly one FETCH after signalStreamClose() (calls optional queue.close?.() so a flush/pipe sentinel can land without this test dispatching DOMContentLoaded)
  • packages/react/src/server/__tests__/lane-a-streaming.node.tsx — Node project (latest + node_matrix React 18). Generic renderToPipeableStream wire: inert baseline in the shell, __DATA_CLIENT_DELTAS__ before nested child markup.

Open questions

  • Leftover close is signalStreamClose(), a test stand-in for the Lane A flush/pipe sentinel. DOMContentLoaded remains an interim product fallback only; this test does not baptize it as the close API.
  • CI on this PR is expected red. That is the TDD signal, not a Lane B regression.

Spike findings (insertion + stream-close) live in the agent-store docs, not this PR.

Out of scope here: shared hydration fixtures with hydration.web.tsx; leftover/close for generic /ssr.

Open in Web Open in Cursor 

cursoragent and others added 26 commits September 11, 2026 15:48
Adds diffState/applyStateDelta/mergeStateDelta/selectBaseline under
state/stream plus a hydrateReducer that three-way merges streamed server
state into the live store, keeping slots the client changed and ignoring
deltas after a client reset. Exposed through __INTERNAL__ for the React
SSR adapters.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…of its script tag

ServerData wrote JSON.stringify output directly into a <script>, so a
string containing </script> ended the tag early. Escape <, >, &, U+2028
and U+2029 as JSON unicode escapes, which keeps the payload valid JSON.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
createServerStore builds the redux-style store, controller and manager
wiring once so the Express and Next.js adapters stop duplicating it.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…ive state

useCacheState now uses useSyncExternalStore's getServerSnapshot in the
browser so a Suspense boundary that hydrates after the store has already
changed (manager updates, streamed data) still matches the server HTML.
Updates keep flowing through StateContext, so transitions remain
non-blocking. DataProvider provides the snapshot through the internal
ServerSnapshotContext; React 16/17 and React Native keep the plain
context read.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…et window

The Next.js DataProvider decided the server store was complete after a
fixed 10ms without in-flight fetches, so any async Server Component that
resolved later left #data-client-data empty and the client refetched
everything it had just received as HTML.

The provider now registers useServerInsertedHTML: the shell carries the
state so far as an inert JSON baseline and every later flush prepends a
delta script describing what changed since. The client folds these into
the hydration snapshot and merges them into the live store (HYDRATE) as
they arrive, so each Suspense boundary hydrates against exactly what it
was rendered from.

Also: nonce prop for CSP, managers accepted as a factory so each request
gets its own instances (arrays are ignored on the server with a warning),
and a local next/navigation declaration under typings/ that is not shipped.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…gnored typings/ directory

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…er streaming hydration in jsdom

A boundary whose data the server never sent (a delta lost to a
serialization failure, or arriving after its boundary hydrated) would
suspend on every hydration retry because the snapshot alone never gains
that data. The hydration view now overlays the server snapshot on the
live store, so such boundaries hydrate after exactly one client fetch
while everything the server did send still wins.

Adds jsdom coverage for both the plain DataProvider (late boundary
hydrates against server state, StrictMode, transitions, legacy branch)
and the Next.js client (deltas streaming into dehydrated boundaries,
queued deltas, client reset, missing baseline, document still loading,
managers factory). Test fixtures under __tests__/fixtures are no longer
collected as suites.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…e and limitations

Also exports NextDataProviderProps, lists react-dom as an optional peer
for the nextjs entry, and makes the Next.js example exercise a Server
Component that resolves after the shell.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…caping

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Manager instances cannot be shared between server requests, so the array
form only ever applied to the browser while the server kept its defaults.
The prop now takes a function, called once per request on the server and
once in the browser; an array throws with a migration hint.

BREAKING CHANGE: @data-client/react/nextjs DataProvider managers prop
changes from Manager[] to () => Manager[].

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…kfile

The nextjs managers change ships as a minor, so @data-client/img and
@data-client/test must accept 0.19.x; also records the optional react-dom
peer in yarn.lock.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
- a delta that ends the baseline wait before DOMContentLoaded no longer
  lets the stale load listener clear the receiver installed later; the
  pending wait is cleared once settled
- the client managers factory result is cached on the per-document
  snapshot store so StrictMode's double useMemo cannot run it twice
- Controller is honored on the server store and SSR controller; gcPolicy
  is documented as browser-only; store providers no longer receive props
  consumed at store creation
- only StateDelta and StateBaseline are public; createServerStore drops
  its unused managers return

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Lazy-require react-dom/client after the LegacyReact gate so describe.skip
can run. Gate DOM-preserving / no-recoverable-warning asserts to React 19
and check reset-vs-delta and mid-hydration Probe via the live store.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
On 18, setResponse while a sibling is dehydrated is deferred, and a
client reset can remount from the snapshot. Gate those live-store
claims to React 19; keep data, refetch, and stream liveTitle asserts.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Drop the unused-generality id helper; R19 probe covers the late-update
store read, and reset asserts getState() inline.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Either form is resolved once at mount. A function lets one definition be
shared with the Next.js provider and gives each DataProvider on a page its
own manager instances instead of a hoisted array bound to the last store.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…ional

Doc examples, skill guidance and the DataProvider TSDoc now show
managers={() => [...]}; DataProvider.md and Manager.md state that arrays
will be removed and recommend migrating now. Adds the v0.19 draft blog
post (streamed Next.js SSR, hydration snapshot, managers factory) with a
migration guide.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Once-per-mount via useRef is correct. StrictMode 17/18 discards a
render/useRef pass so the factory runs twice; React 19 keeps one.
Assert stability across re-renders within a mount and keep
init/cleanup pairing on the live instance.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
The browser DataProvider now accepts a factory; arrays are
transitional. Drop the stale "unchanged / Manager[] only" line so
release notes do not contradict the sibling changeset.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Rename escapeJsonForHtml to escapeJsonForScript: CodeQL treats any callee
named like an HTML sanitizer as producing output unsafe for code
construction, hiding the actual \u003c/U+2028 escaping behind the name.
Test regexes that parse generated <script> tags are now case-insensitive
and tolerate whitespace before the closing bracket.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Rewrite the SSR guide, changeset, blog, Next example README, and agent
skills around baseline-plus-delta hydration. Inline the sequence once via
the existing diagrams MDX extract. Keep Open questions honest: waiters
and fold-on-script are the remaining client clock, not this commit.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Rewrite the streaming test HTML parser so CodeQL does not treat a
</script> regex as a bad tag filter. Type homepage SVGs from the SVGR
import to avoid dual @types/react conflict in website typecheck.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Lane B honesty pass: changeset, blog, SSR guide, Next example README,
skill, and JSDoc now match the tip. Wire (G0 + StateDelta + HYDRATE)
ships; per-key waiters, zero-refetch, and generic Fizz incremental
emission do not. A DOMContentLoaded interim is not forbidden.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Split the SSR hydration sequence into an overview with black boxes
and one zoom per box. Replace Fizz/Flight jargon with
renderToPipeableStream and RSC in the guide, skill, changeset, blog,
example README, and Next adapter comments.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@changeset-bot

changeset-bot Bot commented Sep 15, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: a784ec5

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs-site Ignored Ignored Preview Sep 15, 2026 7:49pm UTC

Request Review

SSR is a reference inside data-client-react, not a standalone skill.
Decision trees include an SSR column only where behavior differs; otherwise
they are the same as client (streamed baseline+deltas instead of one
initialState).

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@ntucker

ntucker commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

FOLLOW_UP

Lane A product contracts look honest for a failing TDD draft: RSC-first zero-REST, batch flush, nested child-before-parent, later overlap, and renderToPipeableStream baseline+delta. Draft on Lane B / do-not-merge is right. Expected red CI is the signal.

Before this leaves draft / when Lane A starts:

  1. Rebase onto current 4090 tip and drop the duplicate “fold SSR skill” commit (docs already match tip-to-tip; keep this PR tests-only).
  2. Leftover close: keep the “FETCH only after stream close” assertion, but don’t baptize DOMContentLoaded as the product API — skill already calls DCL an interim. Prefer a signalStreamClose() stand-in so a flush/pipe sentinel doesn’t fight this test.
  3. Optional later: shared hydration fixtures with hydration.web.tsx; leftover/close case for generic /ssr when that close signal exists.

@cursor
cursor Bot force-pushed the cursor/lane-a-hydration-contract-c544 branch from eba188f to 6d2041e Compare September 15, 2026 19:45
Encode RSC-first zero-REST, batch flush, nested child-before-parent,
later overlap, leftover stream-close, and generic renderToPipeableStream
baseline+delta wire. Leftover close uses signalStreamClose() — not
DOMContentLoaded. These fail on 4090 head and must not land on Lane B.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor
cursor Bot force-pushed the cursor/lane-a-hydration-contract-c544 branch from 6d2041e to a784ec5 Compare September 15, 2026 19:49
@cursor
cursor Bot force-pushed the cursor/nextjs-streamed-ssr-handoff-911d branch 2 times, most recently from 3be5158 to a10c9f4 Compare September 18, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants