Skip to content

hydrate_on: deferred hydration can bind a shared Redux store to a re-created instance → divergent state (#4037) #4572

Description

@AbanoubGhadban

Summary

With OSS hydrate_on: :visible / :idle scheduling (#4037), a component's mount is deferred to an arbitrary later time. Its late getStore() can bind to a Redux store instance that a later forEachStore pass unconditionally re-created, while an already-hydrated :immediate sibling still holds the earlier instance. Two components that share a store then dispatch to two different store instances, and their shared state silently diverges — no error, just wrong state.

Where

packages/react-on-rails/src/ClientRenderer.ts

  • initializeStore (:115-121) builds a fresh store from the generator and calls StoreRegistry.setStore(name, store) unconditionally — there is no "already hydrated" guard.
  • forEachStore (:123-127) re-runs initializeStore for every [data-js-react-on-rails-store] element, and it is invoked on every renderComponent (:507), renderAllComponents (:525), and reactOnRailsComponentLoaded call.
  • Deferred mount path: scheduleHydration (:211) → runScheduledRender (:468) → mountReactRoot (:476), whose getStore resolves the store lazily at mount time.
  • The sameNode guard (:395-397) skips re-rendering an already-mounted component, so an already-hydrated component is not rebound when the store is later recreated.

Root cause

Before hydrate_on, all components on a page hydrated in one synchronous pass, so every component read the same store instance created in that pass — the per-render store re-creation in forEachStore was effectively idempotent from the component's point of view. Deferred hydration breaks that invariant: a deferred component's getStore can now run on the far side of a store re-creation triggered by a later render, while an already-hydrated sibling keeps the original instance. initializeStore's unconditional setStore is the pre-existing enabler; #4037's deferral is what makes two live instances observable.

Failure scenario

OSS with Pro installed (non-:immediate modes require it):

  1. A page has component A (hydrate_on: :immediate) and component B (hydrate_on: :visible) that share Redux store S.
  2. Initial renderAllComponents() builds instance S1; A hydrates and dispatches against S1. B is scheduled (not yet mounted).
  3. A Turbo Frame / async fragment injects another RoR component and calls ReactOnRails.reactOnRailsComponentLoaded(...), which re-runs forEachStore and overwrites the registry with a brand-new S2. A is not re-rendered (skipped by the sameNode guard), so it keeps S1.
  4. B scrolls into view, calls getStore('S'), and binds to S2.

Result: A dispatches to S1, B dispatches to S2 — updates from one never reach the other. Repro sketch: OSS dummy app, one redux_store shared by an :immediate and a :visible react_component; after load, trigger a second reactOnRailsComponentLoaded(...) (or a Turbo-frame load) before scrolling B into view; assert A.store !== B.store.

Impact

A real data-integrity bug: silently divergent shared state between components with no error surfaced. The trigger is narrow — it needs mixed :immediate + deferred components sharing a store and a second render / store re-creation between the two hydrations — but hydrate_on is a new feature that makes exactly this timing reachable, and when it hits, debugging is hard because nothing throws.

Suggested fix

  • Guard initializeStore to skip re-initializing a store whose name already has a hydrated instance (only create if absent, or if props/railsContext actually changed), OR
  • Initialize stores once per page-load rather than per renderComponent / forEachStore call.

Either way the invariant to restore is: a given store name resolves to one instance for the lifetime of a page render, regardless of when a component hydrates.

Attribution / verification

Metadata

Metadata

Labels

bugroadmap:17xPost-17.0.0 roadmap generation (supersedes roadmap:modern-react)theme:modern-reactSupporting theme: React 19.2+ feature coverage

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions