Skip to content

feat(react): incrementally hydrate SSR state from streamed deltas - #4090

Open
ntucker wants to merge 32 commits into
masterfrom
cursor/nextjs-streamed-ssr-handoff-911d
Open

ntucker wants to merge 32 commits into
masterfrom
cursor/nextjs-streamed-ssr-handoff-911d

Conversation

@ntucker

@ntucker ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Motivation

A streaming HTML response is a sequence of committed revisions. A store serialized once — after a quiet window, or after useReadyCacheState() — cannot describe that sequence. Late islands arrive as markup whose endpoint/meta never landed in the browser, so useSuspense() takes the fetch branch on an empty seed.

PR 4090 already replaced the Next 10 ms createPersistedStoreServer snapshot on the wire: an inert baseline plus per-flush StateDeltas. This tip also moves streamed hydration out of the public SPA store: public DataProvider / @data-client/react use the master reducer and StateContext reads. ./nextjs and an unstable ./ssr __INTERNAL__ seam compose createStreamingReducer and the adapter-owned snapshot.

That is not streaming success. Head still folds into the live store from StreamedStateReceiver’s layout effect (flushSync plus a live write), has no per-key waiter, and has no render-pure revision-visibility protocol. Generic renderToPipeableStream / Anansi still emit one document snapshot by default.

HOLD. Do not merge without Nathaniel. Green tests mean the SPA path was restored and Next behavior was relocated, not that duplicate fetches, concurrent streaming, or live merge integrity are solved.

Solution

Adapter-owned streaming (this tip):

  • Core master createReducer has no HYDRATE case. HYDRATE / hydrateReducer / createHydrate live under state/stream and root __INTERNAL__ only. HydrateAction is not a public ActionTypes member.
  • Public DataProvider is a thin wrapper over private DataProviderBase with core createReducer. DataStore uses that same reducer for committed updates and optimistic replay.
  • Browser useCacheState is use(StateContext) again. The SSRDataProvider server external-store branch remains. ServerSnapshotContext / createServerSnapshot are deleted.
  • Shared packages/react/src/server/stream/ owns createStreamingReducer, minimal SnapshotStore / DeltaQueue, and StreamedStateReceiver. Next keeps transport (global queue, baseline element, scripts, nonce, managers/pending extensions).
  • Next createPersistedStoreClient composes DataProviderBase + createStreamingReducer + StreamedStateReceiver. flushSync and per-delta live publication are preserved HOLD behavior.
  • Generic @data-client/react/ssr keeps one-shot APIs and adds unstable __INTERNAL__.StreamingDataProvider for hosts that already own a snapshot store. Next production does not depend on that namespace.

Still shipped from earlier tips: the wire (inert baseline + StateDelta), three-way merge helpers, Next managers factory, escaped JSON, nonce.

Not in this release: revision visibility, per-key waiters, fold-on-script-arrival, schema-aware writeDelta, reset-aware overlay, a stable generic streaming component, @data-client/core/stream.

Open questions

  • Revision visibility remains absent. A folded snapshot is not visible through use(StateContext) without a live identity change that client-renders pending siblings. Live per-delta publication (flushSync) is preserved, not the concurrent happy path.
  • Schema-blind live merge (writeDelta whole-slot skip/replace) and overlay lastReset remain HOLD. Overlay has no production React consumer after this split.
  • Waiters / fold-on-script remain future client-clock work. An RSC-first miss still fetches like any client render. Missing-baseline still suspends while the document is loading (regression guard, not final no-delay compliance).
  • Next still has no API to emit a delta before the RSC payload that can start the Client Component.
  • Server initManager disposers remain discarded. Server managers stay inert.
  • No runtime deprecation warning yet for browser managers={array}.
  • React 18: a store update while a boundary is still dehydrated can client-render it. Documented limitation.
  • Nathaniel HOLD on squash still stands. Do not merge without an explicit go-ahead.
Open in Web Open in Cursor 

@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a10c9f4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@data-client/react Minor
@data-client/img Minor
@data-client/test Minor
@data-client/core Minor
@data-client/vue Minor
example-benchmark-react Patch
test-bundlesize Patch
coinbase-lite Patch
example-benchmark Patch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercel Bot commented Sep 11, 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 18, 2026 3:21pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark Spread

Details
Benchmark suite Current: a10c9f4 Previous: 38dbb52 Ratio
setOneEntity in 10k entity store 154 ops/sec (±1.05%) 153 ops/sec (±0.97%) 0.99

This comment was automatically generated by workflow using github-action-benchmark.

Comment thread packages/react/src/server/__tests__/escapeJsonForHtml.node.tsx Fixed

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark React

Details
Benchmark suite Current: a10c9f4 Previous: a76996a Ratio
data-client: getlist-100 138.89 ops/s (± 4.1%) 125 ops/s (± 4.8%) 0.90
data-client: getlist-500 46.62 ops/s (± 3.4%) 39.84 ops/s (± 5.5%) 0.85
data-client: update-entity 370.37 ops/s (± 8.0%) 312.5 ops/s (± 7.2%) 0.84
data-client: update-user 357.14 ops/s (± 9.0%) 294.12 ops/s (± 7.8%) 0.82
data-client: getlist-500-sorted 48.31 ops/s (± 7.3%) 43.39 ops/s (± 9.2%) 0.90
data-client: update-entity-sorted 322.58 ops/s (± 6.3%) 263.16 ops/s (± 5.9%) 0.82
data-client: update-entity-multi-view 350.99 ops/s (± 8.0%) 277.78 ops/s (± 7.0%) 0.79
data-client: list-detail-switch-10 9.26 ops/s (± 9.2%) 7.22 ops/s (± 5.2%) 0.78
data-client: update-user-10000 99.5 ops/s (± 10.0%) 71.43 ops/s (± 12.4%) 0.72
data-client: invalidate-and-resolve 40.65 ops/s (± 4.9%) 32.95 ops/s (± 5.6%) 0.81
data-client: unshift-item 224.75 ops/s (± 7.7%) 188.68 ops/s (± 5.9%) 0.84
data-client: delete-item 303.03 ops/s (± 4.1%) 250 ops/s (± 4.4%) 0.83
data-client: move-item 196.08 ops/s (± 8.4%) 158.77 ops/s (± 8.3%) 0.81

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: a10c9f4 Previous: 93555f8 Ratio
normalizeLong 432 ops/sec (±4.64%) 447 ops/sec (±4.75%) 1.03
normalizeLong Values 392 ops/sec (±1.02%) 408 ops/sec (±1.51%) 1.04
normalizeLong Scalar 372 ops/sec (±3.49%) 352 ops/sec (±3.69%) 0.95
normalizeLong Scalar update 889 ops/sec (±0.19%) 895 ops/sec (±0.68%) 1.01
denormalizeLong 234 ops/sec (±5.81%) 233 ops/sec (±6.00%) 1.00
denormalizeLong Values 224 ops/sec (±5.09%) 213 ops/sec (±4.94%) 0.95
denormalizeLong donotcache 966 ops/sec (±0.73%) 1002 ops/sec (±0.64%) 1.04
denormalizeLong Values donotcache 741 ops/sec (±0.37%) 737 ops/sec (±0.59%) 0.99
denormalizeLong Scalar donotcache 1019 ops/sec (±0.34%) 1073 ops/sec (±0.13%) 1.05
denormalizeShort donotcache 500x 1376 ops/sec (±0.14%) 1437 ops/sec (±0.29%) 1.04
denormalizeShort 500x 630 ops/sec (±6.63%) 639 ops/sec (±6.97%) 1.01
denormalizeShort 500x withCache 6848 ops/sec (±0.18%) 6834 ops/sec (±5.52%) 1.00
queryShort 500x withCache 3081 ops/sec (±0.11%) 3206 ops/sec (±0.97%) 1.04
buildQueryKey All 58838 ops/sec (±1.13%) 58478 ops/sec (±1.39%) 0.99
query All withCache 6120 ops/sec (±2.05%) 5828 ops/sec (±2.46%) 0.95
denormalizeLong with mixin Entity 205 ops/sec (±6.69%) 209 ops/sec (±7.50%) 1.02
denormalizeLong withCache 6556 ops/sec (±0.21%) 7517 ops/sec (±0.32%) 1.15
denormalizeLong withCache (Scalar churn) 6512 ops/sec (±0.90%) 7491 ops/sec (±0.24%) 1.15
denormalizeLong Values withCache 5113 ops/sec (±2.03%) 5132 ops/sec (±1.60%) 1.00
denormalizeLong Scalar withCache 7750 ops/sec (±0.63%) 7648 ops/sec (±0.98%) 0.99
denormalizeLong Scalar update withCache 4085 ops/sec (±0.30%) 4074 ops/sec (±0.24%) 1.00
denormalizeLong All withCache 6221 ops/sec (±0.23%) 6058 ops/sec (±0.18%) 0.97
denormalizeLong Query-sorted withCache 6324 ops/sec (±1.63%) 6098 ops/sec (±1.48%) 0.96
denormalizeLongAndShort withEntityCacheOnly 1688 ops/sec (±0.26%) 1748 ops/sec (±0.19%) 1.04
denormalize bidirectional 50 4546 ops/sec (±10.52%) 4498 ops/sec (±10.41%) 0.99
denormalize bidirectional 50 donotcache 40892 ops/sec (±0.23%) 42385 ops/sec (±1.43%) 1.04
getResponse 4442 ops/sec (±3.88%) 4418 ops/sec (±4.05%) 0.99
getResponse (null) 9825777 ops/sec (±0.62%) 10236651 ops/sec (±0.70%) 1.04
getResponse (clear cache) 203 ops/sec (±6.21%) 203 ops/sec (±7.07%) 1
getSmallResponse 3383 ops/sec (±1.19%) 3543 ops/sec (±0.24%) 1.05
getSmallInferredResponse 2852 ops/sec (±0.24%) 2852 ops/sec (±1.79%) 1
getResponse Collection 4405 ops/sec (±1.98%) 4306 ops/sec (±4.05%) 0.98
get Collection 3076 ops/sec (±0.32%) 2707 ops/sec (±0.19%) 0.88
get Query-sorted 4993 ops/sec (±1.76%) 5052 ops/sec (±1.47%) 1.01
setLong 434 ops/sec (±0.32%) 467 ops/sec (±0.60%) 1.08
setLongWithMerge 246 ops/sec (±1.09%) 257 ops/sec (±0.45%) 1.04
setLongWithSimpleMerge 260 ops/sec (±0.36%) 272 ops/sec (±0.80%) 1.05
setSmallResponse 500x 906 ops/sec (±1.41%) 926 ops/sec (±1.48%) 1.02

This comment was automatically generated by workflow using github-action-benchmark.

@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR

The streaming handoff design is the right shape for this problem — baseline + deltas via useServerInsertedHTML, three-way HYDRATE merge, and useSyncExternalStore overlay for Gap B. I’m not asking to unwind that. TanStack-style dehydrate/HydrationBoundary doesn’t replace a normalized entity graph with in-place GC; this isn’t idle complexity.

What blocks Ready (adversarial pass kept these):

  1. yarn.lockreact-dom was added as an optional peer on @data-client/react but the lockfile wasn’t updated. Vercel deploy fails with YN0028 (immutable install would modify the lockfile). Commit the lockfile refresh.

  2. React 17 matrix — new hydration.web.tsx / provider-hydration.web.tsx statically import react-dom/client, so the suite never loads. The existing LegacyReact ? describe.skip never runs. Lazy/require the client APIs (or otherwise keep the import off the React 17 path) so the skip can work.

  3. React 18 matrixunit_tests-^18 is red while latest is green. Docs already scope DOM-preserving hydration to React 19 (flushSync during a dehydrated boundary can client-render on 18). Gate __server / “no recoverable warning” asserts to React 19 — do not invent a DOM-preserving React 18 receive path. Separately, reset-vs-delta and late Probe/liveTitle convergence still need an 18-aware fix or precise asserts so 18 covers live-store guarantees without over-claiming React 19 behavior.

  4. ActionTypesHydrateAction — prefer shipping @data-client/core as a minor, not patch. Exhaustive manager switches break either way; patch makes the bump harder to justify after release. Flip the changeset before Ready.

Not asking in this PR: managers factory (array was already unused for the server store; warn + factory is an improvement), soft-nav / router.refresh streaming, Express 10ms path, CodeQL on the double-stringify escape path (looks like a false positive), or a redesign of overlay/merge/flushSync.

Draft is fine; Ready needs green ^17/^18, the lockfile, and the semver flip.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Size Change: +70 B (+0.09%)

Total Size: 81.1 kB

📦 View Changed
Filename Size Change
examples/test-bundlesize/dist/rdcClient.js 10.9 kB +70 B (+0.65%)
ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.46 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcEndpoint.js 8.07 kB
examples/test-bundlesize/dist/react.js 59.6 kB
examples/test-bundlesize/dist/webpack-runtime.js 784 B

compressed-size-action

@ntucker
ntucker marked this pull request as ready for review September 11, 2026 17:27
@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR (updated for 284b306)

Prior note raced this push. Cleared on this head:

  1. yarn.lock / optional react-dom peer — lockfile includes it; good.
  2. ActionTypesHydrateAction as core minor — changeset flipped; good.
  3. Managers factory as a hard break (array throws + migration hint) — fine; keep it.

Still blocks Ready (adversarial pass kept these; CircleCI unit_tests-^17 / ^18 still red on this SHA):

  1. React 17 matrixhydration.web.tsx / provider-hydration.web.tsx still statically import react-dom/client, so the suite never loads (Cannot find module at import). LegacyReact ? describe.skip never runs. Lazy/require the client APIs after the version gate (or only inside describeHydration) so the skip works. Do not exclude the whole provider-hydration file — describe('useCacheState branches') is outside describeHydration and is the React 16/17 shape coverage.

  2. React 18 matrix — docs already scope DOM-preserving hydration to React 19. Gate __server / empty-errors (no recoverable client-render warning) to React 19 — do not invent a DOM-preserving React 18 receive path. On ^18, streamsIntoDehydratedBoundary already gets liveTitle right and only dies on __server; keep those live-store asserts. For reset-vs-delta liveTitle and mid-hydration Probe text: prefer asserting via store.getState() / controller state, or gate Probe DOM expectations to R19 — overlay/getServerSnapshot can win during hydration, so those failures are not a license to “fix” a phantom live receive path. Do not skip the whole describeHydration on ^18 (folds-deltas, baseline/loading, managers-factory still pass).

Not asking: soft-nav streaming, Express 10ms path, CodeQL on the double-stringify / test regexes (false positives), website GHA HomepageFeatures SvgComponent typecheck (unrelated; CircleCI typecheck green), or redesign of overlay/merge/flushSync.

Draft is fine; Ready needs green ^17/^18.

@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR (updated for 7dd9a6f)

7dd9a6f cleared the non-matrix review items: Controller honored on server/client, managers factory cached for StrictMode, DOMContentLoaded race fixed, public stream types trimmed. Good — keep those.

Still blocks merge (adversarial pass kept these; CircleCI unit_tests-^17 / ^18 still red on this SHA). PR is Ready while those are red — fine as process note; the matrix fix is the unblocker.

  1. React 17 matrixhydration.web.tsx / provider-hydration.web.tsx still statically import react-dom/client, so the suites never load (Cannot find module at import). LegacyReact ? describe.skip never runs. Lazy/require the client APIs after the version gate (or only inside describeHydration) so the skip works. Do not exclude the whole provider-hydration file — describe('useCacheState branches') is outside describeHydration and is the React 16/17 shape coverage.

  2. React 18 matrix — docs already scope DOM-preserving hydration to React 19. Gate __server / empty-errors (no recoverable client-render warning, including the transition case) to React 19 — do not invent a DOM-preserving React 18 receive path. On ^18, streamsIntoDehydratedBoundary already gets liveTitle/text right and only dies on __server; keep those live-store asserts. For reset-vs-delta liveTitle and mid-hydration Probe text: prefer asserting via store.getState() / controller state (Probe/useCache can still see overlay/getServerSnapshot during hydration; that is not a missing live receive path). Gating Probe DOM expectations to R19 is an acceptable backup. Do not skip the whole describeHydration on ^18 (folds-deltas, baseline/loading, DOMContentLoaded race, managers-factory, Controller still pass).

Not asking: soft-nav streaming, Express 10ms path, CodeQL / GHA website typecheck (unrelated; CircleCI typecheck green), or redesign of overlay/merge/flushSync.

Ready needs green ^17/^18.

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.05941% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.58%. Comparing base (a76996a) to head (a10c9f4).

Files with missing lines Patch % Lines
packages/core/src/state/stream/writeDelta.ts 90.00% 1 Missing and 5 partials ⚠️
packages/core/src/state/stream/diffState.ts 92.30% 0 Missing and 3 partials ⚠️
packages/core/src/state/stream/selectBaseline.ts 86.95% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4090      +/-   ##
==========================================
- Coverage   97.84%   97.58%   -0.26%     
==========================================
  Files         156      168      +12     
  Lines        3057     3229     +172     
  Branches      612      658      +46     
==========================================
+ Hits         2991     3151     +160     
- Misses         18       21       +3     
- Partials       48       57       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Staff LGTM (updated for f123190)

Prior matrix CHANGE_THIS_PR items are cleared on this head:

  1. React 17react-dom/client is jest.requireActual after the LegacyReact gate in both hydration suites; describe('useCacheState branches') still runs outside describeHydration. CircleCI unit_tests-^17 green.
  2. React 18__server / empty-errors (transition + late-delta) gated to React 19; mid-hydration store writes and reset-vs-delta live-store drop asserted on 19 only (18 defers updates / may remount — documented). Stream liveTitle, folds-deltas, refetch counts, and the rest of describeHydration still run on ^18. CircleCI unit_tests-^18 green on 7aeb7fa and f123190.

Adversarial pass on the R18 re-spec: KILL as CHANGE_THIS_PR — honest React 18 hydration scheduling, not a missing product path. Optional FOLLOW_UP only if we ever need stronger R18 guarantees around reset-during-streaming vs remount/snapshot fold — not this PR.

Hold squash for Bugbot success. GHA “Typecheck website” / CodeQL remain the prior unrelated / false-positive set; CircleCI typecheck is green.

No further changes requested on this PR.

@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Staff review — LGTM / FOLLOW_UP only @ f123190

CHANGE_THIS_PR items from 7dd9a6f are cleared on this head. No new CHANGE_THIS_PR.

Cleared

  • React 17: hydrateRoot via jest.requireActual after LegacyReact gate in hydration.web.tsx / provider-hydration.web.tsx; useCacheState branches kept; unit_tests-^17 PASS
  • React 18: __server / empty-errors gated to R19; describeHydration still runs; live-store coverage kept; unit_tests-^18 PASS
  • Prior clears stand: lockfile optional react-dom, core HYDRATE minor, Controller prop, managers factory StrictMode cache, DOMContentLoaded race, stream API trim

Architecture still LGTM: baseline+deltas, three-way HYDRATE, useSyncExternalStore overlay — right shape for normalized SSR handoff.

FOLLOW_UP (not blocking)

  • GHA typecheck / CodeQL red look like the previously noted unrelated website / FP class (CircleCI typecheck green) — confirm before squash if anything new
  • Holding squash for Bugbot SUCCESS (still pending on this HEAD)

Ready for merge once Bugbot is green (or waived).

@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR (updated for 114d7e5)

Prior Staff LGTM at f123190 still covers the streaming/hydration design. This push only adds browser DataProvider managers factory support — shape is fine (array still works; resolve once per mount via useRef; multi-provider isolation test is the right product claim). Adversarial pass kept the item below.

Blocks Ready

  1. React 17 / 18 matrix — CircleCI unit_tests-^17 / unit_tests-^18 red on this SHA. Sole failure: provider.tsxmanagers factory › is called once, even across re-renders and under StrictMode — Expected 1, Received 2. unit_tests-latest (React 19) stays green.

    Once-per-mount via useRef matches Controller / gcPolicy. StrictMode remount on 17/18 resets that ref, so the factory runs again after cleanup — correct, not a missing document-level cache. Next.js snapshotStore.managers caching is a different lifetime; do not copy it onto browser DataProvider just to force call-count 1 (would fight multi-provider isolation and reuse cleaned-up instances).

    Fix (test-only): split the claim — assert the factory is stable across re-renders within a mount; drop or version-gate the “once under StrictMode” call-count (React 19 currently sees 1, 17/18 see 2). Keep init/cleanup pairing. Unconditional expect(2) would break latest.

Not asking: stale “unchanged” wording in .changeset/nextjs-managers-factory.md (array still accepted on browser; low-severity changelog nit), soft-nav streaming, Express 10ms path, website GHA typecheck / CodeQL (same unrelated / FP class as before; CircleCI typecheck green), or redesign of overlay/merge/flushSync.

Ready needs green ^17/^18 again.

@cursor
cursor Bot force-pushed the cursor/nextjs-streamed-ssr-handoff-911d branch from 114d7e5 to f123190 Compare September 11, 2026 18:07
@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Staff LGTM (updated after force-push back to f123190)

head_ref_force_pushed at 18:07 UTC dropped 114d7e5 (feat(react): DataProvider managers accepts a factory). Current tip is again f123190.

CHANGE_THIS_PR cleared

  • Prior block was the StrictMode once-call assert on that managers-factory commit (unit_tests-^17 / ^18: Expected 1, Received 2). Reverting the commit removes the red matrix without a document-level managers cache — correct call for this PR. Do not re-land the factory on browser DataProvider inside this branch unless the test is re-spec’d (stable across re-renders within a mount; version-aware / drop StrictMode once-claim).

Prior LGTM at f123190 still stands

  • Streaming/hydration design unchanged: baseline + deltas, three-way HYDRATE, useSyncExternalStore overlay.
  • Matrix gates, lockfile, core HYDRATE minor, Next.js managers factory, Controller, DOMContentLoaded race — all still good.

Hold squash for

  • CircleCI re-run after the force-push (same tip was green before)
  • Bugbot SUCCESS (or waive)

Optional FOLLOW_UP only (not this PR): browser DataProvider managers as Manager[] | (() => Manager[]) with the honest StrictMode call-count claim.

No further changes requested on this PR.

@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR (updated for d7ecc8c)

Prior Staff LGTM at f123190 still covers the streaming/hydration design. This tip re-lands browser DataProvider managers factory (114d7e5) plus docs (d7ecc8c) framing the function form as the long-term API. Shape is fine (array still works; resolve once per mount via useRef; multi-provider isolation is the right product claim). Adversarial pass kept the items below.

Blocks Ready

  1. React 17 / 18 matrix — CircleCI unit_tests-^17 / unit_tests-^18 red on this SHA. Sole failure: provider.tsxmanagers factory › is called once, even across re-renders and under StrictMode — Expected 1, Received 2. unit_tests-latest (React 19) stays green.

    Once-per-mount via useRef matches Controller / gcPolicy. StrictMode remount on 17/18 resets that ref, so the factory runs again after cleanup — correct, not a missing document-level cache. Next.js snapshotStore.managers caching is a different lifetime; do not copy it onto browser DataProvider just to force call-count 1 (would fight multi-provider isolation and reuse cleaned-up instances).

    Fix (test-only): split the claim — assert the factory is stable across re-renders within a mount; drop or version-gate the “once under StrictMode” call-count (React 19 currently sees 1, 17/18 see 2). Keep init/cleanup pairing. Unconditional expect(2) would break latest.

  2. Stale Next.js managers changeset.changeset/nextjs-managers-factory.md still says the browser DataProvider is unchanged and still takes Manager[]. Sibling .changeset/dataprovider-managers-factory.md and the code now say arrays are transitional and the factory is supported. Fix or remove that one sentence so the release notes don’t contradict.

Not asking: GHA website typecheck / CodeQL (same HomepageFeatures SvgComponent / FP class as before; CircleCI typecheck green on prior tip), soft-nav streaming, Express 10ms path, or redesign of overlay/merge/flushSync.

Ready needs green ^17/^18 again (and the changeset line).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d7ecc8c. Configure here.

Comment thread packages/react/src/components/DataProvider.tsx Outdated
@cursor
cursor Bot force-pushed the cursor/nextjs-streamed-ssr-handoff-911d branch from d7ecc8c to f123190 Compare September 11, 2026 18:30
@ntucker

ntucker commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Staff LGTM (updated after second force-push back to f123190)

head_ref_force_pushed at 18:30 UTC dropped the re-landed browser DataProvider managers factory (d7ecc8c / 114d7e5). Current tip is again f123190.

CHANGE_THIS_PR cleared

  • Prior blocks on that tip were: (1) StrictMode once-call assert (unit_tests-^17 / ^18 Expected 1, Received 2), and (2) stale “browser unchanged / Manager[] only” sentence in .changeset/nextjs-managers-factory.md contradicting the factory docs. Reverting removes both without a document-level managers cache — correct call for this PR.
  • Do not re-land the browser factory inside this branch unless the test is re-spec’d (stable across re-renders within a mount; version-aware / drop StrictMode once-claim) and the Next.js managers changeset no longer claims the browser is unchanged.

Prior LGTM at f123190 still stands

  • Streaming/hydration design unchanged: baseline + deltas, three-way HYDRATE, useSyncExternalStore overlay.
  • Matrix gates, lockfile, core HYDRATE minor, Next.js managers factory, Controller, DOMContentLoaded race — all still good.
  • Browser DataProvider remains managers?: Manager[] on this tip; .changeset/nextjs-managers-factory.md “browser unchanged” line is accurate again.

Hold squash for

  • CircleCI re-run after this force-push (same tip was green before at ~18:09)
  • Bugbot SUCCESS (or waive) — in progress on this HEAD

Optional FOLLOW_UP only (not this PR): browser DataProvider managers as Manager[] | (() => Manager[]) with the honest StrictMode call-count claim + matching changeset wording.

No further changes requested on this PR.

@ntucker

ntucker commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR (updated for d7ecc8c)

Prior Staff LGTM at f123190 still covers the streaming/hydration design. Tip is again d7ecc8c (browser DataProvider managers factory + docs). Shape is fine (array still works; resolve once per mount via useRef; multi-provider isolation is the right product claim). Adversarial pass kept the items below.

Blocks Ready

  1. React 17 / 18 matrix — CircleCI unit_tests-^17 / unit_tests-^18 red on this SHA. Sole failure: provider.tsxmanagers factory › is called once, even across re-renders and under StrictMode — Expected 1, Received 2. unit_tests-latest (React 19) stays green.

    Once-per-mount via useRef matches Controller / gcPolicy. Under Strict Mode the render function (and each useRef) runs twice and one pass is discarded — that purity check calls the factory on the discarded pass; it is not a second production mount, and initManager effects never run on the discarded instance. Next.js snapshotStore.managers caching is a different lifetime; do not copy it onto browser DataProvider just to force call-count 1 (would fight multi-provider isolation and reuse cleaned-up instances).

    Fix (test-only): split the claim — assert the factory is stable across re-renders within a mount; drop or version-gate the “once under StrictMode” call-count (React 19 currently sees 1, 17/18 see 2). Keep init/cleanup pairing. Unconditional expect(2) would break latest.

  2. Stale Next.js managers changeset.changeset/nextjs-managers-factory.md still says the browser DataProvider is unchanged and still takes Manager[]. Sibling .changeset/dataprovider-managers-factory.md and the code now say arrays are transitional and the factory is supported. Fix or remove that one sentence so the release notes don’t contradict.

Not asking: GHA website typecheck / CodeQL (same HomepageFeatures SvgComponent / FP class as before; CircleCI typecheck green), soft-nav streaming, Express 10ms path, or redesign of overlay/merge/flushSync.

Ready needs green ^17/^18 again (and the changeset line).

@ntucker

ntucker commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Staff LGTM (updated for 47e757f)

Prior CHANGE_THIS_PR at d7ecc8c is cleared on this tip:

  1. StrictMode once-call assert52535fc re-specs the managers factory test: version-aware mount call count (1 on React 19, 2 on 17/18), then asserts stability across re-renders within a mount and init/cleanup pairing on the live instance. No document-level managers cache — correct.
  2. Stale Next.js managers changeset47e757f drops the “browser unchanged / Manager[] only” line; now matches sibling dataprovider-managers-factory and the code.

Prior LGTM still stands for the streaming/hydration design (baseline + deltas, three-way HYDRATE, useSyncExternalStore overlay) and the earlier matrix / lockfile / core minor clears.

Hold squash for

  • CircleCI unit_tests-^17 / ^18 / latest green on this HEAD
  • Bugbot SUCCESS (or waive) — prior Bugbot note on d7ecc8c about Expected-1 was the old test claim; obsolete once this tip’s re-spec lands
  • Nathaniel’s earlier Hold still applies — do not merge without an explicit go-ahead

No further changes requested on this PR. Optional FOLLOW_UP only if we later want stronger R18 remount-from-snapshot guarantees — not this PR.

@ntucker

ntucker commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Staff LGTM (updated for 1dd5bfd)

Prior CHANGE_THIS_PR at d7ecc8c cleared on 47e757f still stands. This tip is CodeQL hygiene only:

  1. Improper code sanitization FPescapeJsonForHtmlescapeJsonForScript (call sites + file rename). Escape set unchanged (\u003c/\u003e/\u0026/U+2028/U+2029). Name matches the real sink (inert JSON <script> and the double-stringify JSON.parse(...) literal in deltaScript).
  2. Bad HTML filtering regexp (tests) — case-insensitive + optional whitespace before > on test parsers only (escapeJsonForScript.node.tsx, streaming.node.tsx). Production escape path untouched.

No CHANGE_THIS_PR. Architecture LGTM unchanged: baseline + deltas, three-way HYDRATE, useSyncExternalStore overlay, managers factory with honest StrictMode claim.

Hold squash for

  • CircleCI unit_tests-^17 / ^18 / latest green on this HEAD
  • Bugbot SUCCESS — already green on 1dd5bfd
  • Nathaniel’s earlier Hold — do not merge without an explicit go-ahead

GHA typecheck (website HomepageFeatures SvgComponent) is the same prior unrelated failure; CircleCI typecheck has been the source of truth. CodeQL Analyze still pending on this SHA — expect the rename to clear the prior FP class.

No further changes requested on this PR. Optional FOLLOW_UP only if we later want stronger R18 remount-from-snapshot guarantees — not this PR.

@ntucker

ntucker commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

CHANGE_THIS_PR — real Next 16 consumer validation found a client handoff race on 1dd5bfd.

I tested packed artifacts from this exact head in the order-book app that originally reproduced the 10 ms bug:

  • Next 16.3.4 / React 19.2.8
  • root-layout @data-client/react/nextjs provider
  • async route params + six useLive() consumers
  • custom WebSocket manager supplied through the new factory API

Server side passes: production build is clean; 8 concurrent mixed-symbol requests each returned 87 data cells and a route-isolated cache. The baseline is the 101-byte initial state and 3–5 streamed deltas reconstruct the expected OrderBook/ticker/trades/candles state.

Client side does not meet the zero-refetch claim on this head. On a clean hard navigation, browser PerformanceResourceTiming showed the SSR endpoints starting again immediately:

  • watchlist ticker: ~39 ms
  • symbol info: ~40 ms
  • order book / trades / candles: ~236 ms

(The later direct depth request from this app's Binance sequence synchronizer is expected and separate.) No hydration warning was required to trigger this; useful SSR HTML remained visible, but the Data Client requests were duplicated.

The raw response ordering is correct: each delta script precedes its dependent Fizz HTML. The failing assumption is that this also precedes Client Component execution. In Next 16, Flight can start that client work before the inserted HTML delta script executes and before StreamedStateReceiver commits its layout effect. Because the inert baseline is already present in <head>, getSnapshotStore() does not suspend, initializes from the empty baseline, and useSuspense() starts client fetches before later deltas are usable.

I validated the conservative fix below:

 const element = document.getElementById(BASELINE_ID);
-if (!element && document.readyState === 'loading') {
+if (document.readyState === 'loading') {
   throw (queue.pending ??= new Promise<void>(resolve => {
-    // whichever fires first wins; the loser must not clobber the receiver
     const done = () => {
       document.removeEventListener('DOMContentLoaded', done);
-      if (queue.onDelta === done) queue.onDelta = undefined;
       queue.pending = undefined;
       resolve();
     };
     document.addEventListener('DOMContentLoaded', done);
-    queue.onDelta = done;
   }));
 }

In other words: while the initial document is still parsing, wait through all streamed deltas; do not let the first delta end bootstrap. At DOMContentLoaded, fold the complete queue and initialize the client store once.

With only that runtime refinement:

  • no Data Client SSR endpoint refetched on startup;
  • the only browser REST request was the expected app-owned depth resync at 1839 ms;
  • DOMContentLoaded was 1268 ms (response start 82 ms), so the tradeoff is explicit: SSR pixels still stream, but Data Client interactivity waits for the full initial document;
  • 8 concurrent mixed routes still passed cache reconstruction/isolation;
  • app production build, TypeScript, and ESLint passed;
  • both hydration suites passed (15 tests);
  • all React package suites passed: 55 suites, 498 passed / 1 skipped, 127 snapshots.

The regression test needs the baseline installed before getSnapshotStore() with document.readyState = 'loading', then stream a delta and assert the thrown bootstrap promise is still unsettled until DOMContentLoaded. The existing test installs the baseline after the first call, so it does not cover the real condition. It should also require the thrown value to be a Promise rather than using optional chaining, and restore readyState in finally.

If preserving progressive interactivity is required, the alternative is a larger endpoint-aware client wait: missing useSuspense() keys must wait for the next server delta while the document is loading, retry after each delta, and only fetch client-side after DOMContentLoaded confirms no server value arrived. Raw HTML script order alone is not a sufficient synchronization contract on Next 16.

I would not merge the current head with the zero-client-request claim unchanged. The DOMContentLoaded gate is small and validated; a more progressive design needs an equivalent real Next 16 browser test that asserts request counts, not only final DOM/cache state.

@cursor cursor Bot changed the title fix(react): stream Next.js SSR state per flush and hydrate cache reads from the server snapshot feat(react): incrementally hydrate SSR state from streamed deltas Sep 15, 2026
cursoragent and others added 29 commits September 18, 2026 15:19
…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>
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>
Revert the previous fold (per-row "Same" tables and intended-behavior claims
in SKILL.md). SSR now lives in references/ssr.md inside data-client-react and
documents only what differs from the browser: entry per host, streamed
baseline + StateDelta on Next.js App Router vs one initialState elsewhere, and
what is not shipped (per-key waiters, fold-on-script, streamed generic
renderToPipeableStream). No standalone data-client-ssr skill.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Add RSC as a trigger term in the data-client-react description, drop the
docs-terminology rule from references/ssr.md (not a host difference), and
state the SUBSCRIBE caveat without restating browser behavior.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Public DataProvider injects core createReducer and cache reads stay on
StateContext. HYDRATE, hydrateReducer, and the snapshot receiver move
behind DataProviderBase plus a private streaming reducer for ./nextjs
and an unstable ./ssr composition seam.

HOLD: revision visibility, schema-blind writeDelta, and overlay lastReset
are unchanged. flushSync per-delta publication is preserved adapter behavior.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor
cursor Bot force-pushed the cursor/nextjs-streamed-ssr-handoff-911d branch 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.

3 participants