Skip to content

Add SSR Suspense support#2837

Closed
edkimmel wants to merge 10 commits into
necolas:masterfrom
edkimmel:suspense
Closed

Add SSR Suspense support#2837
edkimmel wants to merge 10 commits into
necolas:masterfrom
edkimmel:suspense

Conversation

@edkimmel
Copy link
Copy Markdown
Contributor

  • ALS scoping of Dimensions and StyleSheet.
  • Support for multiple react-native-stylesheet elements (Late arrival as suspense resolves)
  • Hydration support for Dimensions

--

Also fixing upstream issues against newer styleq versions

edkimmel and others added 10 commits May 11, 2026 14:34
Adds a setForHydration flag so that callers can pre-seed `dimensions` from
the server-rendered values without `Dimensions.get` immediately calling
update() and clobbering them on the first client-side read. After
hydration completes, unsafe_restoreFromHydration() clears the flag and
runs update() to switch back to live browser values.

Ports the existing patch-package fix to the upstream source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the import from `styleq/transform-localize-style` with a local
copy in `src/exports/StyleSheet/localizeStyle.js`. styleq's published
copy lags this implementation; vendoring decouples our writing-direction
handling from styleq releases.

Ports the existing patch-package fix to the upstream source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`distanceFromEnd = contentLength - visibleLength - offset` treats the
footer as part of the scrollable content, so onEndReached fires before
the user has actually reached the last item — the footer's height
counts against the threshold. Subtracting `this._footerLength` makes
the trigger point relative to the end of real list content.

Applied in both `_adjustCellsAroundViewport` and `_maybeCallOnEdgeReached`.

Ports the existing patch-package fix to the upstream source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new internal module that exposes runInRequestScope + getScopedState
for isolating module-level mutable state across concurrent SSR renders.

On Node, `runInRequestScope(fn)` opens an AsyncLocalStorage scope so any
state read via `getScopedState(key, factory)` during `fn` is per-request;
outside any scope, calls fall through to a process-default singleton.

On the client and React Native, AsyncLocalStorage is unavailable — the
module degrades to a no-op singleton store, matching today's behavior
of RNW's module-level `let` bindings. `node:async_hooks` is loaded via
`eval('require')` so bundlers targeting browser/native do not attempt
to resolve it.

Phase 1b of the streaming-SSR migration will route module state inside
Dimensions, StyleSheet, and AppRegistry through this module so each
request gets its own isolated copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes the module-level `dimensions` object through getScopedState so
each SSR request gets its own `window` / `screen` viewport values.
Concurrent renders with different breakpoints no longer overwrite each
other's metrics.

`listeners`, `shouldInit`, and `setForHydration` stay module-level:
- listeners are runtime subscriptions added post-mount; no resize fires
  on the server.
- shouldInit = canUseDOM, so it is false on the server and never reads
  through update().
- setForHydration only changes inside unsafe_setForHydration, which
  throws on the server.

Adds a Node-environment test (asyncContext-test.node.js) verifying that
concurrent runInRequestScope scopes do not see each other's
Dimensions.set values, and that scope writes do not leak to the
process default. Existing jsdom tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dual-write per-request delta channel to the shared StyleSheet so
streaming SSR consumers can emit only the rules that actually landed
during a given chunk, while the process-wide sheet stays a singleton
(matching today's module-load-time accumulation behavior).

How it works:
- createOrderedCSSStyleSheet.insert now returns { ruleAdded, groupCreated }
  so callers can detect dedup hits (rule was already present) without
  re-implementing the selectors map.
- The dom/index.js wrapper forwards the primary sheet's InsertResult.
- StyleSheet.insertRules mirrors only genuine adds (ruleAdded === true)
  into an ALS-scoped per-request delta buffer via getScopedState. If no
  request scope is active (client / module-load / legacy two-pass
  renderer) the dual-write is a no-op and behavior is unchanged.

New StyleSheet APIs:
- takeRequestDelta(): drains the buffer to a CSS text fragment, with
  [stylesheet-group="N"]{} markers emitted only on the first flush for
  each group per request.
- resetRequestDelta(): clears the buffer without emitting. The streaming
  pipeline calls this after the shell head dump, since the full sheet
  text already covered everything inserted up to that point.
- markGroupsAsEmitted(groups): pre-marks groups whose markers are
  present in the shell head so subsequent delta flushes don't duplicate
  them.

Tests (node env):
- takeRequestDelta returns "" outside any scope.
- A scope captures created rules; markers appear once per group per
  request, not on every flush.
- Concurrent runInRequestScope renders see only their own rules.
- A duplicate `create()` across two requests appears in the first
  delta and is empty in the second (dedup via the shared sheet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the singular `<style id="react-native-stylesheet">` assumption so
streaming SSR can emit per-chunk `<style data-rnw-delta="N">` tags
without leaving the client-side dedup map / groups records out of sync.

Changes:
- createOrderedCSSStyleSheet accepts an optional `additionalSheets` array
  alongside the primary CSSStyleSheet. The hydration loop is refactored
  so the same logic walks each source; only the primary records
  absolute rule indices in `groups[g].start`. Groups discovered only in
  additional sheets get `start: null` and let sheetInsert compute the
  position lazily on first runtime add.
- dom/index.js#createSheet scans the rootNode for
  `style[data-rnw-delta]` elements during the initial sheet bootstrap
  and passes their CSSStyleSheets to createOrderedCSSStyleSheet.

Effect: when the streaming SSR pipeline emits per-chunk delta tags,
their rules apply to the document immediately (no FOUC), and on
hydration RNW's runtime sheet learns about them too. A subsequent
StyleSheet.create call for a rule already in a delta tag dedups
correctly and avoids inserting a duplicate runtime rule into the
primary sheet.

Tests:
- New unit case in dom-createOrderedCSSStyleSheet-test.js exercises
  the multi-sheet hydration path: primary + two delta sheets across
  four distinct groups; verifies the unified text, dedup of
  delta-sourced rules, and admission of brand-new rules.
- New dom-delta-hydration-test.js plants delta `<style>` elements in
  the document head before booting createSheet and verifies the
  module-scope sheets array hydrates from all of them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Streaming SSR can emit `<style data-rnw-delta="N">` tags interspersed
with body content as suspense boundaries resolve. The previous commit
made initial hydration walk every existing delta tag, but tags that
arrive AFTER RNW's first sheet bootstrap (i.e. with later chunks)
were not getting their rules into the dedup map / groups records.

This commit adds the inline-script handshake pattern:

  <style data-rnw-delta="N">...rules...</style>
  <script>
    (window.__RNW_DELTA__ = window.__RNW_DELTA__ || []).push("N");
    if (window.__RNW_INGEST_DELTA__) window.__RNW_INGEST_DELTA__();
  </script>

createSheet installs __RNW_INGEST_DELTA__ on its first boot. The hook
drains window.__RNW_DELTA__: for each id, it finds the corresponding
`<style data-rnw-delta="N">` element, walks its cssRules, and calls
registerExisting on every sheet in the wrapper's sheets array.

New OrderedCSSStyleSheet.registerExisting:
- Same dedup + records semantics as `insert`, but skips the CSSOM
  `insertRule` call. Used because the rule is already in the document
  via the delta tag — we only need to teach the bookkeeping about it
  so subsequent runtime StyleSheet.create dedups instead of injecting
  a duplicate into the primary sheet.

The handshake works whether the inline script runs before or after
RNW boots:
- Before: __RNW_INGEST_DELTA__ is undefined, so the if-call is a noop;
  the id stays queued. On boot, installDeltaIngest drains the queue.
- After: the if-call invokes the hook directly, which processes the
  new id (and any other queued ids).

Re-processing the same delta id is a no-op (processed-ids Set).

If RNW never boots (e.g. JS-disabled crawler), nothing is lost — the
browser already applied the rules via the `<style data-rnw-delta>`
element. The bookkeeping is purely an optimization to avoid duplicate
CSSOM inserts at runtime; it has no effect on rendered output.

Tests (jest.isolateModules per case for fresh dom/index module state):
- Queue drained on first boot.
- Delta arriving AFTER boot is ingested when the hook is called.
- Reprocessing the same id is a no-op (no duplicate rules).
- Pre-boot script that ran before RNW loaded still gets drained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upstream styleq now returns a 3-tuple [classNames, inlineStyles, debugString]
instead of the 2-tuple the StyleSheet inline snapshots were captured
against. The third element is currently always "" — a debug-only slot
that doesn't change rendered output but causes the snapshot equality
check to fail.

Necolas has a pending `update-styleq` branch upstream with the same
fix; we just regenerate locally rather than waiting for it to merge.

`jest -u` against packages/react-native-web/src/exports/StyleSheet/__tests__/index-test.js
re-captures all 18 affected snapshots. No code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@edkimmel edkimmel closed this May 18, 2026
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.

1 participant