Skip to content

Guard store re-initialization to prevent divergent state in deferred hydration (#4572)#4591

Open
AbanoubGhadban wants to merge 2 commits into
mainfrom
4572-hydrate-on-deferred-hydration-store-divergent
Open

Guard store re-initialization to prevent divergent state in deferred hydration (#4572)#4591
AbanoubGhadban wants to merge 2 commits into
mainfrom
4572-hydrate-on-deferred-hydration-store-divergent

Conversation

@AbanoubGhadban

@AbanoubGhadban AbanoubGhadban commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a guard in initializeStore to skip re-creating a store that already exists in hydratedStores
  • Restores the one-instance-per-name-per-page-lifetime invariant broken by deferred hydration

Problem

Before hydrate_on scheduling (#4037), all components hydrated in one synchronous pass, so every component read the same store instance. Deferred hydration (visible/idle) breaks this invariant:

  1. Initial renderAllComponents() creates store S1
  2. Component A (:immediate) hydrates and gets S1
  3. Component B (:visible) is scheduled but doesn't mount yet
  4. A Turbo Frame or async fragment calls reactOnRailsComponentLoaded(), which re-runs forEachStore
  5. initializeStore unconditionally creates a new store S2, overwriting S1 in the registry
  6. Component A is skipped by the sameNode guard, so it keeps S1
  7. Component B scrolls into view and mounts with S2

Result: A dispatches to S1, B dispatches to S2 — divergent shared state with no error.

Fix

Check if the store already exists in hydratedStores before creating a new one. This matches the Pro package's behavior which already guards via the storeRenderers map.

Test plan

  • Added unit tests covering:
    • forEachStore running multiple times doesn't re-create stores
    • Store instance remains stable between immediate and deferred component hydration
  • All existing ClientRenderer tests pass (363 tests)
  • Type check passes
  • Lint passes

Fixes #4572

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate Redux store creation during repeated rendering passes within the same page lifecycle.
    • Ensured immediately hydrated and deferred/visible components consistently reuse the same store instance.
    • Added page-unload cleanup so hydrated stores are cleared on navigation, allowing correct re-initialization on the next page.
  • Tests
    • Added coverage for the store re-initialization guard, deferred hydration timing, and page-unload store clearing.

…hydration

Before hydrate_on scheduling (#4037), all components hydrated in one
synchronous pass, reading the same store instance. Deferred hydration
(visible/idle) breaks this: forEachStore can run again via
reactOnRailsComponentLoaded (e.g., for a Turbo Frame), re-creating the
store while an immediate sibling keeps the old instance and a deferred
sibling will get the new one — divergent shared state with no error.

The fix adds a guard in initializeStore to skip re-creating a store
that already exists in hydratedStores, restoring the one-instance-per-
name-per-page-lifetime invariant.

Fixes #4572

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cdfefc20-213d-4d23-af12-bca8942e1b09

📥 Commits

Reviewing files that changed from the base of the PR and between ff5ec3e and d0015db.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/tests/ClientRenderer.test.ts

Walkthrough

initializeStore now avoids replacing an existing registered store, while page-unload cleanup clears hydrated stores so later pages can create fresh instances. Tests cover repeated renders, deferred hydration, and navigation reset behavior.

Changes

Store Initialization Consistency

Layer / File(s) Summary
Guard store creation across hydration flows
packages/react-on-rails/src/ClientRenderer.ts, packages/react-on-rails/tests/ClientRenderer.test.ts
initializeStore preserves an existing named store, with tests covering repeated renders and immediate versus visible-deferred hydration.
Reset stores between page navigations
packages/react-on-rails/src/ClientRenderer.ts, packages/react-on-rails/tests/ClientRenderer.test.ts
Page-unload cleanup clears hydrated stores, and tests verify subsequent rendering creates a new store instance with updated props.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Renderer
  participant StoreRegistry
  participant ImmediateComponent
  participant DeferredComponent
  Renderer->>StoreRegistry: initialize shared store
  Renderer->>ImmediateComponent: hydrate immediately
  Renderer->>DeferredComponent: schedule visible hydration
  Renderer->>StoreRegistry: check shared store on re-render
  StoreRegistry-->>Renderer: return existing instance
  DeferredComponent->>StoreRegistry: resolve shared store
  StoreRegistry-->>DeferredComponent: return same instance
  Renderer->>StoreRegistry: clear hydrated stores on page unload
  StoreRegistry-->>Renderer: remove store instance
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main fix: preventing divergent Redux store re-initialization during deferred hydration.
Linked Issues check ✅ Passed The code adds a same-name store guard, clears hydrated stores on unload, and tests the deferred-hydration and navigation cases from #4572.
Out of Scope Changes check ✅ Passed The shown changes stay focused on store re-initialization and cleanup, with no unrelated code paths or public API changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 4572-hydrate-on-deferred-hydration-store-divergent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR guards client store initialization so repeated hydration keeps one shared store instance. The main changes are:

  • Adds an early return in initializeStore when a store name is already hydrated.
  • Documents the deferred hydration divergence case in ClientRenderer.ts.
  • Adds tests for repeated store initialization and immediate-plus-deferred hydration.

Confidence Score: 4/5

The changed store guard can keep old Redux state across Turbo or Turbolinks page and frame replacements.

  • The same-page deferred hydration case is covered by the new tests.
  • The browser unload path clears rendered components, but not hydrated stores.
  • A later page or frame with the same store name can skip its new server props and reuse stale state.

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

Important Files Changed

Filename Overview
packages/react-on-rails/src/ClientRenderer.ts Adds a name-based store initialization guard that fixes same-page deferred hydration but can preserve stale stores across Turbo/Turbolinks replacements.
packages/react-on-rails/tests/ClientRenderer.test.ts Adds tests for repeated same-page store initialization and deferred visible hydration.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
  participant A as Current page/frame
  participant R as ClientRenderer
  participant S as StoreRegistry
  participant B as New Turbo page/frame

  A->>R: render components
  R->>S: setStore(name, oldStore)
  A->>R: turbo before-render
  R-->>R: unmount components
  Note over S: oldStore remains hydrated
  B->>R: render new markup
  R->>S: getStore(name, false)
  S-->>R: oldStore
  R-->>B: skip new store props
  B->>S: components read store
  S-->>B: stale oldStore
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
  participant A as Current page/frame
  participant R as ClientRenderer
  participant S as StoreRegistry
  participant B as New Turbo page/frame

  A->>R: render components
  R->>S: setStore(name, oldStore)
  A->>R: turbo before-render
  R-->>R: unmount components
  Note over S: oldStore remains hydrated
  B->>R: render new markup
  R->>S: getStore(name, false)
  S-->>R: oldStore
  R-->>B: skip new store props
  B->>S: components read store
  S-->>B: stale oldStore
Loading

Reviews (1): Last reviewed commit: "Guard store re-initialization to prevent..." | Re-trigger Greptile

Comment on lines +126 to +128
if (StoreRegistry.getStore(name, false) !== undefined) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Stale Store Survives Navigation

When Turbo or Turbolinks replaces a page or frame with new markup that uses the same store name, hydratedStores can still contain the old page's store because the unload path only unmounts components. This early return then skips storeGenerator for the new store props, so the new components mount with stale state from the previous page or frame.

Context Used: CLAUDE.md (source)

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

+ci-run-hosted

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff5ec3ebb0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +126 to +128
if (StoreRegistry.getStore(name, false) !== undefined) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Don't reuse stores across Turbo navigations

This guard makes hydrated stores live beyond a single page: the core unload handler only unmounts components and clears renderedRoots (unmountAllComponents), but it never calls StoreRegistry.clearHydratedStores(), while clientStartup runs renderAllComponents() again on each Turbo/Turbolinks page load. After visiting one page with redux_store('appStore', props: old), navigating to another page that emits the same store name with new props will hit this early return, skip the store generator, and all components on the new page will read the previous page's store instead of the fresh store the helper is documented to create.

Useful? React with 👍 / 👎.

@github-actions github-actions Bot added the ready-for-hosted-ci Run optimized hosted GitHub CI for this PR label Jul 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Hosted CI Requested

Triggered 9 workflow(s) for ff5ec3ebb0cb.
Mode: optimized hosted CI (path-selected by script/ci-changes-detector).
Added ready-for-hosted-ci, so future commits will keep running optimized hosted CI until +ci-stop-hosted is used.

View progress in the Actions tab.

// instance and a not-yet-mounted :visible sibling will get the new one — divergent shared state.
// Skipping re-initialization restores the one-instance-per-name-per-page-lifetime invariant.
// See: https://github.com/shakacode/react_on_rails/issues/4572
if (StoreRegistry.getStore(name, false) !== undefined) {

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.

Correctness: this guard can pin a stale store across Turbo full-page navigations, not just within a single page's deferred hydration.

hydratedStores is only ever cleared via the public ReactOnRails.clearHydratedStores() API (called from generated SSR bundle code in helper.rb), never from the client's page-unload path. unmountAllComponents (registered via onPageUnloaded, i.e. turbo:before-render/turbolinks:before-render) tears down rendered roots but does not call StoreRegistry.clearHydratedStores().

Before this PR, initializeStore unconditionally re-created the store on every forEachStore() call, which incidentally gave "fresh store per Turbo page visit" as a side effect (in addition to causing the same-page divergence bug this PR fixes). With the new guard, once a store name (e.g. a common "CurrentUserStore" hydrated on every layout) is registered on page A, navigating via Turbo to page B — which re-renders a store element with the same name but different server-rendered props — will hit this early return and keep reusing page A's stale store/state instead of hydrating with page B's props.

Compare with the Pro package, which clears its per-page store cache on unload: packages/react-on-rails-pro/src/ClientSideRenderer.ts's unmountAll() calls unmountAllStores() (storeRenderers.clear()) in addition to unmountAllComponents(). This PR's core-package fix is missing the equivalent reset — e.g. calling StoreRegistry.clearHydratedStores() from unmountAllComponents() here — so the "restore one-instance-per-name" fix only holds within a single page's lifetime if something also resets the registry between Turbo page loads.

The added tests only exercise repeated forEachStore calls within the same simulated page (no runPageUnload() between renders), so this regression isn't covered. Worth adding a test that runs runPageUnload() (simulating Turbo navigation) between two renders with the same store name and different props, asserting the store is re-created in that case.

(Greptile's automated review already flagged this same issue on the latest commit ff5ec3eb.)

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review: Guard store re-initialization to prevent divergent state in deferred hydration

What this PR does

Adds a guard in initializeStore (packages/react-on-rails/src/ClientRenderer.ts) so that forEachStore skips re-creating a store whose name is already present in StoreRegistry's hydratedStores map. This targets a real bug (#4572): with deferred hydration (hydrate_on: visible|idle), forEachStore can run multiple times within the same page (e.g. via reactOnRailsComponentLoaded for async/Turbo Frame content), and previously would silently overwrite the shared store instance, causing already-mounted (:immediate) and not-yet-mounted (deferred) siblings of the same store name to end up bound to two different store instances.

Correctness issue (left as an inline comment)

The guard as written checks only "does a hydrated store with this name exist," with no notion of which page it was hydrated for. hydratedStores is never cleared on the client's Turbo/Turbolinks page-unload path (unmountAllComponents in this same file, registered via onPageUnloaded) — only the public ReactOnRails.clearHydratedStores() API touches it, and that's invoked from generated SSR-bundle code, not the client runtime.

Before this change, initializeStore unconditionally recreated the store on every forEachStore() call. That was the root cause of the same-page divergence bug this PR fixes, but it also had the (likely unintentional) side effect of giving every full Turbo page navigation a fresh store built from that page's own hydration props. With the new unconditional-skip guard, a store name that recurs across pages (e.g., a common "CurrentUserStore" hydrated in a shared layout) will now keep serving the first page's store/state forever after a Turbo navigation, since nothing resets hydratedStores between page loads.

For comparison, the Pro package's client renderer clears its equivalent per-page cache on unload: unmountAll() in packages/react-on-rails-pro/src/ClientSideRenderer.ts calls both unmountAllComponents() and unmountAllStores() (storeRenderers.clear()). The core package's fix here adds the "don't re-create" guard but not the matching "reset between page loads" half — e.g., unmountAllComponents() in ClientRenderer.ts doesn't call StoreRegistry.clearHydratedStores().

Note: Greptile's automated review (posted on the current head commit ff5ec3eb) already flagged this same underlying issue independently, which corroborates it's not a false positive.

Test coverage gap

Both new tests in ClientRenderer.test.ts simulate forEachStore running multiple times within a single page (no runPageUnload() between renders), which is the exact case the fix targets, but leaves the cross-navigation regression uncovered. Consider adding a test that calls the mocked runPageUnload() between two renders of a differently-parameterized store with the same name, asserting the store is recreated after a real page transition — that would both prove the fix doesn't regress full-page navigation and catch this specific bug.

Minor notes

  • The inline comment added above initializeStore is thorough and clearly documents the reasoning/issue link — good practice for a subtle invariant like this.
  • Guard implementation itself (early-return using StoreRegistry.getStore(name, false)) is otherwise clean and minimal, consistent with existing code style in this file.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
packages/react-on-rails/tests/ClientRenderer.test.ts (1)

1452-1454: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Move IntersectionObserver cleanup to afterEach to prevent mock leakage on test failure.

The IntersectionObserver mock is restored at the end of the test body. If the test fails before reaching line 1452, the mock leaks to subsequent test suites. Move cleanup to the afterEach block (or a dedicated teardown) so it runs regardless of test outcome.

♻️ Proposed fix
     afterEach(() => {
       StoreRegistry.clearHydratedStores();
       StoreRegistry.clearStoreGenerators();
+      const observerGlobal = globalThis as unknown as { IntersectionObserver?: unknown };
+      delete observerGlobal.IntersectionObserver;
     });

Then remove the inline cleanup at the end of the test:

-    // Cleanup
-    const observerGlobal = globalThis as unknown as { IntersectionObserver?: unknown };
-    delete observerGlobal.IntersectionObserver;
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react-on-rails/tests/ClientRenderer.test.ts` around lines 1452 -
1454, Move the IntersectionObserver cleanup from the test body into the
surrounding afterEach or dedicated teardown, using the existing observerGlobal
setup so it executes even when the test fails. Remove the inline delete at the
end of the test while preserving cleanup of globalThis.IntersectionObserver.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/react-on-rails/tests/ClientRenderer.test.ts`:
- Around line 1452-1454: Move the IntersectionObserver cleanup from the test
body into the surrounding afterEach or dedicated teardown, using the existing
observerGlobal setup so it executes even when the test fails. Remove the inline
delete at the end of the test while preserving cleanup of
globalThis.IntersectionObserver.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0e88de60-e766-4f5a-8be7-7dceecf8bce4

📥 Commits

Reviewing files that changed from the base of the PR and between 4747499 and ff5ec3e.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/tests/ClientRenderer.test.ts

Addresses review feedback: the store re-initialization guard alone would
cause stale state when navigating (via Turbo/Turbolinks) to a new page
that uses the same store name with different props.

Now stores are cleared on page unload, ensuring:
1. Within a single page: stores are stable (not re-created by forEachStore)
2. Across navigation: stores are fresh (re-created with new page's props)

This matches the Pro package behavior where unmountAllStores() clears
the storeRenderers map on page unload.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 63.53 KB (+0.03% 🔺)
react-on-rails/client bundled (gzip) (time) 63.53 KB (+0.03% 🔺)
react-on-rails/client bundled (brotli) 54.59 KB (+0.25% 🔺)
react-on-rails/client bundled (brotli) (time) 54.59 KB (+0.25% 🔺)
react-on-rails-pro/client bundled (gzip) 64.87 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 64.87 KB (0%)
react-on-rails-pro/client bundled (brotli) 55.81 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 55.81 KB (0%)
registerServerComponent/client bundled (gzip) 135.37 KB (0%)
registerServerComponent/client bundled (gzip) (time) 135.37 KB (0%)
registerServerComponent/client bundled (brotli) 81.66 KB (0%)
registerServerComponent/client bundled (brotli) (time) 81.66 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 127.84 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 127.84 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 74.96 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 74.96 KB (0%)

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

Addressed review feedback

@greptile-apps raised a valid concern about stale stores surviving navigation. Fixed in d0015db:

Added clearAllStores() registered with onPageUnloaded() to clear hydrated stores on Turbo/Turbolinks navigation. This ensures:

  1. Within a page: Stores are stable (the guard prevents re-creation by forEachStore)
  2. Across navigation: Stores are fresh (cleared on unload, re-created with new page's props)

This matches the Pro package behavior where unmountAllStores() clears the storeRenderers map on page unload.

Added test: clears stores on page unload so navigation re-initializes with new props

@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown

The fix is well-implemented. The two-part solution correctly handles both scenarios:

  1. Within a page (initializeStore guard): StoreRegistry.getStore(name, false) !== undefined short-circuits before re-creating — this is the core fix for the divergent state bug.
  2. Across navigation (clearAllStores on onPageUnloaded): hydratedStores.clear() resets the map so the next page's forEachStore creates fresh stores with the new props.

A few observations:

clearAllStores is a thin wrapper around StoreRegistry.clearHydratedStores(). You could inline it directly into the onPageUnloaded call, but the named function improves readability and matches the pattern of unmountAllComponents — no objection.

Ordering of onPageUnloaded callbacksunmountAllComponents is registered before clearAllStores. If any renderer teardown reads from the store (unlikely but possible), this ordering is correct: stores remain live during component cleanup. Good.

Test coverage — the added test clears stores on page unload so navigation re-initializes with new props directly validates the lifecycle contract.

This mirrors the Pro package behavior faithfully and the invariants are well-documented in the code comments. LGTM.

// instance and a not-yet-mounted :visible sibling will get the new one — divergent shared state.
// Skipping re-initialization restores the one-instance-per-name-per-page-lifetime invariant.
// See: https://github.com/shakacode/react_on_rails/issues/4572
if (StoreRegistry.getStore(name, false) !== undefined) {

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.

Good fix for the divergent-store bug. One trade-off worth calling out explicitly (in the comment or docs): with this guard, a data-js-react-on-rails-store element that reappears with different props (e.g. a Turbo Frame partial reload that re-renders the store's redux_store tag with fresh server data, without a full Turbo Drive navigation) will now be silently ignored — initializeStore returns before even parsing el.textContent. That's presumably intentional (it's what restores the one-instance invariant), but it's a behavior change for any app that was relying on reactOnRailsComponentLoaded to refresh store props mid-page. Might be worth a line in the hydrate_on docs (docs/oss/building-features/hydration-scheduling.md) noting that store props are only read once per page lifetime.


// Register cleanup on page unload
onPageUnloaded(unmountAllComponents);
onPageUnloaded(clearAllStores);

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.

Nice — this closes the gap the guard above would otherwise open (stores never re-initializing across Turbo navigations). Confirmed onPageUnloaded fires this after unmountAllComponents (Set insertion order), though the two don't currently depend on each other's side effects, so ordering isn't load-bearing today — just flagging in case that changes later.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0015dbb50

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +126 to +128
if (StoreRegistry.getStore(name, false) !== undefined) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rehydrate stores for replaced async fragments

When a Turbo Frame/AJAX fragment that owns FrameStore is replaced without a full page unload, reactOnRailsComponentLoaded() still calls forEachStore, but this guard now returns solely because the old store name remains in StoreRegistry. The newly inserted redux_store JSON is ignored (the new page-unload clear only runs from onPageUnloaded, not component-loaded partials), so the replacement component mounts against stale props from the removed fragment. Fresh evidence versus the earlier navigation comment is that this path is a partial replacement where no unload callback clears the registry.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Review

Overview

Fixes #4572: initializeStore unconditionally re-created a store every time forEachStore ran (e.g. on a repeat reactOnRailsComponentLoaded call from a Turbo Frame / async fragment), which could leave already-hydrated :immediate components bound to a stale store instance while later :visible/:idle components got a fresh one — divergent shared Redux state. The fix adds a guard in initializeStore that skips creation if StoreRegistry.getStore(name, false) already returns a store, and pairs it with a new clearAllStores() hook on onPageUnloaded so Turbo/Turbolinks navigations still get fresh stores with the new page's props.

Code quality

  • Root cause and fix are clearly explained in code comments, consistent with the file's existing style (e.g. the MIRROR OF-style comment blocks elsewhere).
  • The guard is a cheap, well-placed early return — it skips the JSON.parse and store-generator call entirely rather than doing wasted work.
  • Pairing the guard with clearAllStores() on page unload is the right complement; without it the guard would leak stale stores across full Turbo navigations. Confirmed the two onPageUnloaded registrations run in the correct order (unmount before clear), though nothing today actually depends on that order.
  • Matches the Pro package's existing storeRenderers-map guard pattern, so behavior is now consistent between core and Pro.

Tests

  • Three new tests cover: repeated forEachStore not re-creating a store, store-instance stability between an immediate and a deferred (visible) sibling component, and store clearing/re-creation across simulated Turbo navigation. Good coverage of the exact scenario from the linked issue.

Notes / suggestions (non-blocking)

  • Left two inline comments:
    • The guard makes store hydration "first-write-wins" for a page's lifetime — a store data element that reappears with different props (e.g. a Turbo Frame partial update without a full Turbo Drive navigation) will now be silently ignored. That's presumably intentional, but might be worth a callout in docs/oss/building-features/hydration-scheduling.md.
    • No functional issue with the onPageUnloaded registration order, just noting the implicit dependency.
  • CHANGELOG.md isn't updated. Per .claude/docs/changelog-guidelines.md, user-visible bug fixes should get an entry under #### Fixed in the Unreleased section.

No correctness, security, or performance issues found otherwise. The change is small, well-targeted, and directly addresses the reported bug.

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

Addressing review comments

All automated reviewers (Greptile, Claude, Codex) flagged the same issue on the first commit: stores surviving Turbo navigations. This is now fixed in the second commit (d0015db) which adds clearAllStores() on page unload.

Summary of behavior:

  • Within a page: Stores are stable (guard prevents re-creation by forEachStore)
  • Across Turbo navigations: Stores are fresh (cleared on unload, re-created with new page's props)
  • Turbo Frames: If a frame replaces a store element with new props without a full navigation, the store keeps its existing state. This is intentional — it maintains the one-instance invariant within a page lifecycle. Apps needing mid-page store refresh should use Redux actions/dispatch rather than relying on store re-initialization.

Re: documentation suggestion

Good point about documenting this behavior. This is primarily relevant to the new hydrate_on feature. I'll add a note to the hydration scheduling docs in a follow-up if maintainers agree it's needed.

@AbanoubGhadban

Copy link
Copy Markdown
Collaborator Author

Re: Codex P2 (Turbo Frame partial replacement)

The P2 comment raises a valid edge case: when a Turbo Frame replaces content (without a full page unload), the store keeps its existing state rather than re-initializing with new props.

This is intentional and correct for the bug being fixed:

  1. The bug (hydrate_on: deferred hydration can bind a shared Redux store to a re-created instance → divergent state (#4037) #4572): Deferred components (hydrate_on: :visible) can bind to a different store instance than immediate siblings if forEachStore runs between their hydration times.

  2. The fix: Ensure all components on a page see the same store instance throughout the page's lifecycle.

  3. Turbo Frame behavior: A Turbo Frame replacement is not a page navigation — it's a partial DOM update within the same page. The store should remain stable so that:

    • Already-hydrated components keep their state
    • Newly-inserted components bind to the same store
    • No state divergence occurs

Apps that need to update store state when a Turbo Frame loads should:

  • Dispatch Redux actions to update the existing store
  • Or trigger a full Turbo Drive navigation if they need fresh server-rendered store props

This matches the Pro package behavior where stores are stable within a page and cleared only on full navigation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-hosted-ci Run optimized hosted GitHub CI for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant