Skip to content

Read the store with useSyncExternalStore() - #6040

Open
Eugene (OEvgeny) wants to merge 18 commits into
mainfrom
OEvgeny/feat-use-sync-external-store
Open

Eugene (OEvgeny) wants to merge 18 commits into
mainfrom
OEvgeny/feat-use-sync-external-store

Conversation

@OEvgeny

@OEvgeny Eugene (OEvgeny) commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Store hooks re-rendered through useReducer() and 2 layout effects each, and <GraphProvider> subscribed to the store while rendering, never unsubscribing.

Changelog Entry

Changed

  • Hooks: read the store with useSyncExternalStore(), at sync priority on React 18 and later (with the IIFE bundle, when window.ReactDOM is set too), in PR #6040, by @OEvgeny

Fixed

  • Fixed store subscriptions left behind after unmount and by <StrictMode>: createGraphFromStore() now subscribes to the store only while its graph has subscribers, in PR #6040, by @OEvgeny

Description

Store reads use our useSyncExternalStore() ponyfill: React's hook on 18+ (sync priority), 1 layout effect on 16/17. <GraphProvider> reads graph and Redux activities as one snapshot; unmounting leaves no subscription.

Design

  • Own ponyfill: the official shim is CJS only, subscribes in a passive effect.
  • Not 1 layout effect everywhere: on 18+ React's hook commits a change with its render.
  • IIFE with window.React but no window.ReactDOM may render with the bundled React DOM 16: hide React's hook there (no sync priority, as on main).
  • createGraphFromStore() (core/graph.js) subscribes to the store on the first graph.subscribe().
  • <GraphProvider> shares the batched subscription of useSelector() (1 render on 16/17).

Specific Changes

  • react-hooks: the ponyfill (spec on 16.8.6, 18.3.1), keyed stores
  • useSelector(), GraphProvider.tsx, ReduxGraphProvider.tsx: use it
  • Suggested actions, dictateAbortable: keyed stores, render with their activity/state
  • isomorphic-react: hides useSyncExternalStore without window.ReactDOM
  • speechPageObjects.js: events after start() and a frame, like browsers
  • html2: 3 new pages
  • I have added tests and executed them locally
  • I have added .changelog/<PR#>-<slug>.md
  • I have updated documentation

Review Checklist

This section is for contributors to review your work.

  • Accessibility reviewed (tab order, content readability, alt text, color contrast)
  • Browser and platform compatibilities reviewed
  • CSS styles reviewed (minimal rules, no z-index)
  • Documents reviewed (docs, samples, live demo)
  • Internationalization reviewed (strings, unit formatting)
  • package.json and package-lock.json reviewed
  • Security reviewed (no data URIs, check for nonce leak)
  • Tests reviewed (coverage, legitimacy)

Eugene Olonov added 18 commits November 9, 2026 15:27
React 18 and later use their own hook. React 16/17 use a fallback that
subscribes in one layout effect, which runs again only when subscribe changes.
useSelector(), <GraphProvider> and useKeyedStoreValue() now use the ponyfill.
<GraphProvider> reads the graph and the Redux activities as one snapshot,
instead of a store subscription while rendering plus two layout effects. It
follows the store through the batched subscription of useSelector(), so React
16/17 re-render it in the same batch as the selectors.

Selectors move to module scope, so getSnapshot() stays the same between renders.
createGraphFromStore() subscribed to the store when called and never
unsubscribed. <GraphProvider> calls it while rendering, so every mount, and
every render that never committed (e.g. the first one of <StrictMode>), left
a store subscription behind. The graph now subscribes on its first subscriber,
catches up with the store, and unsubscribes after its last one.
…ring

<SuggestedActionsComposer> dispatched setRawState() while rendering. With
useSyncExternalStore() on React 19, that dispatch notified components with a
pending update, and React logged "Cannot update a component while rendering
a different component". Dispatch from layout effects instead: the store still
has the suggested actions by the time they show.
The page puts React 18 in window.React, and its default variant rendered with
the bundled React DOM 16. React DOM 16 has no useSyncExternalStore(). The
Fluent variants already render with createRoot() of React DOM 18.
Dictation progress sets the dictate state in Redux and dictateAbortable in a
useState() of <Composer>. With useSyncExternalStore() on React 18 and later, the
Redux state rendered first, at sync priority, and the microphone button was
disabled while dictating until dictateAbortable rendered in the next task.
dictateAbortable is now in a keyed store read with useSyncExternalStore(), so
both render together.
The mock dispatched its events inside start(), which react-dictate-button calls
from an effect. On React 18 and later, "starting" and "dictating" then render in
the same frame, unlike with a browser's recognizer, and the forced-colors
dictation snapshots differed by a few anti-aliased pixels.
The action sink sets the suggested actions while the store dispatches the
incoming activity. With useSyncExternalStore() on React 18 and later, the
activity rendered at sync priority and the suggested actions, a useState() of
<SuggestedActionsComposer>, one task later. The suggested actions are now in a
store read with useSyncExternalStore(), so both render together.
Waiting for one task did not always paint "starting" first: under load, a
forced-colors dictation snapshot differed by a few pixels again (1 of about 40
runs).
When the host sets window.React but not window.ReactDOM, the IIFE bundle renders
with its React DOM 16, which cannot run React's useSyncExternalStore(). Hide it
from window.React there, so the ponyfill falls back as with React 16/17, like
before. typingIndicator.scroll.html covers this setup again.
@OEvgeny
Eugene (OEvgeny) marked this pull request as ready for review November 10, 2026 04:38
Copilot AI balanced review requested due to automatic review settings November 10, 2026 04:38

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

This branch has not been deployed

No deployments
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