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):
- A page has component A (
hydrate_on: :immediate) and component B (hydrate_on: :visible) that share Redux store S.
- Initial
renderAllComponents() builds instance S1; A hydrates and dispatches against S1. B is scheduled (not yet mounted).
- 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.
- 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
Summary
With OSS
hydrate_on: :visible/:idlescheduling (#4037), a component's mount is deferred to an arbitrary later time. Its lategetStore()can bind to a Redux store instance that a laterforEachStorepass unconditionally re-created, while an already-hydrated:immediatesibling 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.tsinitializeStore(:115-121) builds a fresh store from the generator and callsStoreRegistry.setStore(name, store)unconditionally — there is no "already hydrated" guard.forEachStore(:123-127) re-runsinitializeStorefor every[data-js-react-on-rails-store]element, and it is invoked on everyrenderComponent(:507),renderAllComponents(:525), andreactOnRailsComponentLoadedcall.scheduleHydration(:211) →runScheduledRender(:468) →mountReactRoot(:476), whosegetStoreresolves the store lazily at mount time.sameNodeguard (: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 inforEachStorewas effectively idempotent from the component's point of view. Deferred hydration breaks that invariant: a deferred component'sgetStorecan 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 unconditionalsetStoreis the pre-existing enabler; #4037's deferral is what makes two live instances observable.Failure scenario
OSS with Pro installed (non-
:immediatemodes require it):hydrate_on: :immediate) and component B (hydrate_on: :visible) that share Redux storeS.renderAllComponents()builds instanceS1; A hydrates and dispatches againstS1. B is scheduled (not yet mounted).ReactOnRails.reactOnRailsComponentLoaded(...), which re-runsforEachStoreand overwrites the registry with a brand-newS2. A is not re-rendered (skipped by thesameNodeguard), so it keepsS1.getStore('S'), and binds toS2.Result: A dispatches to
S1, B dispatches toS2— updates from one never reach the other. Repro sketch: OSS dummy app, oneredux_storeshared by an:immediateand a:visiblereact_component; after load, trigger a secondreactOnRailsComponentLoaded(...)(or a Turbo-frame load) before scrolling B into view; assertA.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 — buthydrate_onis a new feature that makes exactly this timing reachable, and when it hits, debugging is hard because nothing throws.Suggested fix
initializeStoreto skip re-initializing a store whose name already has a hydrated instance (only create if absent, or if props/railsContext actually changed), ORrenderComponent/forEachStorecall.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
1263119cd, "Add OSS hydrate_on scheduling"); the unconditionalinitializeStore(:119-120) is the pre-window enabler, the two-live-instances divergence is newly reachable in-window.main@b6674a56b(confirmedforEachStoreunconditionally recreates every store, no guard, and the deferredmountReactRootbinds the store late;sameNodeskips rebinding the immediate sibling). Not runtime-reproduced.