Skip to content

Fix hydration mismatch when reactOnRailsPageLoaded() called multiple times#2211

Merged
justin808 merged 6 commits intomasterfrom
jg/fix-2210-rsc-test
Dec 13, 2025
Merged

Fix hydration mismatch when reactOnRailsPageLoaded() called multiple times#2211
justin808 merged 6 commits intomasterfrom
jg/fix-2210-rsc-test

Conversation

@justin808
Copy link
Copy Markdown
Member

@justin808 justin808 commented Dec 11, 2025

Summary

Fixes #2210 - When reactOnRailsPageLoaded() is called multiple times (e.g., for asynchronously loaded content), previously rendered client-side components were being re-hydrated, causing hydration mismatch errors in React 19.

Root cause: The shouldHydrate check (!!domNode.innerHTML) was true for previously client-rendered components because they already had HTML content. This caused React to try to hydrate content that wasn't server-rendered.

Fix: Skip rendering for components that are already tracked in the renderedRoots Map. This prevents re-hydration of already-rendered components while still allowing new components to be rendered.

Changes

  • packages/react-on-rails/src/ClientRenderer.ts: Add check to skip already-rendered components in renderElement()
  • packages/react-on-rails/tests/ClientRenderer.test.ts: Add unit test for the fix
  • react_on_rails/spec/dummy/: Add test page and E2E Playwright test for the scenario

Test plan

  • Unit test passes: Component should not be re-rendered when renderComponent() is called twice
  • Playwright E2E test: async_page_loaded_test page should show no hydration errors when adding components dynamically
  • Manual test: Go to /async_page_loaded_test, click "Add Component" multiple times, verify no console errors

Workaround mentioned in issue

The user noted that using reactOnRailsComponentLoaded(domId) per component works without issues. This fix makes reactOnRailsPageLoaded() work correctly as well.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Prevents duplicate renders when reactOnRailsPageLoaded() is called multiple times, eliminating hydration errors.
    • Properly handles DOM node replacement scenarios during component re-rendering.
  • Tests

    • Added comprehensive test suite for multiple reactOnRailsPageLoaded() invocation scenarios.
    • Added end-to-end tests validating hydration behavior with dynamic component additions and node replacements.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Dec 11, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Implements deduplication of component renders to prevent hydration mismatches when reactOnRailsPageLoaded() is invoked multiple times. Tracks rendered components in a renderedRoots map, skips re-renders for the same DOM node, unmounts and re-renders when DOM nodes are replaced, and updates the hydration heuristic to activate only when DOM nodes contain server-rendered content.

Changes

Cohort / File(s) Summary
Core render deduplication logic
packages/react-on-rails/src/ClientRenderer.ts
Adds renderedRoots map to track previously rendered components; skips rendering if the same DOM node is already rendered; unmounts and removes prior renders when DOM nodes are replaced; refines hydration to trigger only when domNode.innerHTML is present (server-rendered content).
Unit and integration tests
packages/react-on-rails/tests/ClientRenderer.test.ts,
react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
Adds unit tests verifying repeated renders are skipped and rendering resumes when DOM nodes are replaced; comprehensive Playwright E2E test validating hydration behavior across multiple reactOnRailsPageLoaded() invocations, dynamic component additions, and DOM node replacement scenarios.
Test infrastructure and fixtures
react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx,
react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb,
react_on_rails/spec/dummy/client/app/packs/client-bundle.js,
react_on_rails/spec/dummy/config/routes.rb,
react_on_rails/spec/dummy/app/views/shared/_header.erb
New React component for manual render testing; Rails view with test harness for exercising repeated reactOnRailsPageLoaded() calls and DOM node replacement scenarios; component registration and routing configuration; navigation menu update.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20–30 minutes

  • ClientRenderer.ts: Verify correctness of render deduplication logic, unmount lifecycle handling, error handling in unmount operations, and hydration heuristic—ensure no hydration occurs for previously client-rendered content
  • Test coverage: Confirm unit and E2E tests exercise the edge cases (same DOM node, replaced DOM node, multiple invocations)
  • Integration: Check that test fixtures properly isolate behavior and don't mask real issues

Suggested reviewers

  • Judahmeek
  • alexeyr-ci
  • Romex91

Poem

🐰 Multiple renders? A hydration fright,
Deduplication sets the stage just right—
Same DOM skipped, replaced ones remount with care,
ReactOnRails renders without despair!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes out-of-scope changes: migrations from Yarn to PNPM across multiple CI scripts, Procfiles, and setup files are unrelated to the hydration fix and the #2210 issue. Move Yarn-to-PNPM migration changes (bin/ci-local, bin/ci-rerun-failures, bin/ci-switch-config, Procfile updates, setup files) to a separate PR to keep this PR focused on the hydration fix.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing hydration mismatches when reactOnRailsPageLoaded() is called multiple times, which aligns with the changeset's primary objective.
Linked Issues check ✅ Passed The PR directly addresses issue #2210 by implementing the core fix: skip rendering for already-tracked components to prevent unnecessary hydration of client-rendered content.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 941d195 and 0ba8b30.

📒 Files selected for processing (8)
  • packages/react-on-rails/src/ClientRenderer.ts (1 hunks)
  • packages/react-on-rails/tests/ClientRenderer.test.ts (1 hunks)
  • react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb (1 hunks)
  • react_on_rails/spec/dummy/app/views/shared/_header.erb (1 hunks)
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.js (2 hunks)
  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx (1 hunks)
  • react_on_rails/spec/dummy/config/routes.rb (1 hunks)
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js (1 hunks)

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 and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Dec 11, 2025

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.43 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.43 KB (0%)
react-on-rails/client bundled (brotli) 53.65 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.65 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.39 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.39 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.53 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.53 KB (0%)
registerServerComponent/client bundled (gzip) 71.12 KB (0%)
registerServerComponent/client bundled (gzip) (time) 71.12 KB (0%)
registerServerComponent/client bundled (brotli) 61.27 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.27 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 65.75 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 65.75 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.42 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.42 KB (0%)

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 11, 2025

Code Review Summary

This PR effectively fixes issue #2210 where calling reactOnRailsPageLoaded() multiple times caused hydration mismatch errors for client-rendered components. The solution is elegant and well-tested.


Strengths

1. Root Cause Analysis is Spot-On

The fix correctly identifies that the problem occurs when reactOnRailsPageLoaded() is called multiple times - the shouldHydrate check (!!domNode.innerHTML) was incorrectly treating previously client-rendered content as server-rendered content.

2. Minimal, Surgical Fix

  • Only 14 lines changed in production code (ClientRenderer.ts)
  • Leverages existing renderedRoots Map infrastructure
  • No breaking changes or performance overhead
  • Clean early return pattern (lines 84-89)

3. Comprehensive Test Coverage

  • Unit test verifies the core fix
  • E2E Playwright test validates actual user scenario
  • Manual test page provides visual verification
  • AsyncComponent uses inline styles to expose hydration mismatches

4. Code Quality


Observations and Suggestions

1. Missing Changelog Entry (BLOCKING)

According to CLAUDE.md, user-visible bug fixes should be added to CHANGELOG.md. This is a user-facing fix for a regression.

Suggested addition under Fixed section:

2. E2E Test Status

The PR description shows unchecked Playwright E2E test items. Please confirm these are passing in CI.

3. Potential Edge Case

The fix prevents re-rendering components once in renderedRoots. If users update props in DOM and call reactOnRailsPageLoaded() again, component will be skipped. Worth documenting if this is a known limitation.


Security Considerations

No security concerns identified.


Overall Assessment

Verdict: Excellent fix, ready to merge after addressing the changelog.

Risk Level: Low - minimal code change, comprehensive tests, no breaking changes.

Recommendation: Add changelog entry, confirm E2E tests pass, then merge with confidence.

Great work on this fix!

@justin808 justin808 force-pushed the jg/fix-2210-rsc-test branch from c37fddb to 2295e78 Compare December 12, 2025 04:49
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 12, 2025

Code Review - PR #2211

Thank you for the fix! This addresses a real bug in the beta version. Here's my comprehensive review:


✅ Strengths

  1. Excellent Root Cause Analysis: The fix correctly identifies that renderedRoots already tracks rendered components, and the check !!domNode.innerHTML was incorrectly treating client-rendered content as server-rendered.

  2. Minimal, Surgical Change: The fix adds just 8 lines of code at the right place (lines 84-89 in ClientRenderer.ts), preventing re-rendering without changing the overall logic flow.

  3. Comprehensive Test Coverage:

    • Unit test validates the fix at the function level
    • E2E Playwright tests cover realistic user scenarios
    • Manual test page with visual feedback for QA
  4. Good Documentation: Clear comments explain WHY the check exists, not just WHAT it does.


🔍 Code Quality Observations

ClientRenderer.ts Changes

Line 84-89: The guard clause is well-placed and properly returns early.

if (renderedRoots.has(domNodeId)) {
  if (trace) {
    console.log(`Skipping already rendered component: ${name} (dom id: ${domNodeId})`);
  }
  return;
}

Good:

  • Consistent with existing trace logging pattern
  • Early return prevents unnecessary work
  • Log message is descriptive

Test Quality

ClientRenderer.test.ts (lines 206-263):

⚠️ Minor Concern: The test mocks reactHydrateOrRender but doesn't verify the mock wasn't called - it only checks the call count didn't increase. This is correct but could be more explicit:

// Current approach (works but indirect)
expect(mockHydrateOrRender.mock.calls.length).toBe(callCountAfterFirstRender);

// More explicit alternative
expect(mockHydrateOrRender).not.toHaveBeenCalledWith(
  expect.anything(),
  expect.anything(),
  expect.anything()
);

Verdict: Current approach is fine, but the test name could be even clearer about what it's testing.

E2E Tests (page_loaded_multiple_calls.spec.js)

Excellent:

📝 Documentation Suggestion: Consider adding a comment in the spec explaining why this test is critical for regression prevention.


🐛 Potential Issues

1. Edge Case: Manually Cleared DOM Nodes

Scenario: What if user code manually clears a DOM node's innerHTML after it's been rendered?

// Component rendered
ReactOnRails.reactOnRailsPageLoaded();

// User code clears the DOM
document.getElementById('MyComponent-1').innerHTML = '';

// Second call - will skip rendering due to renderedRoots.has() check
ReactOnRails.reactOnRailsPageLoaded();

Impact: The component won't re-render because it's still in renderedRoots.

Assessment: This is an edge case and probably acceptable behavior (user shouldn't manually manipulate React-managed DOM). But worth documenting.

Recommendation: Add a note in the JSDoc comment for renderElement or reactOnRailsPageLoaded about this assumption.

2. Memory Management for SPA-style Navigation

The current renderedRoots Map grows indefinitely. For apps with lots of dynamic components (e.g., Turbo/Hotwire navigation), this could theoretically accumulate entries.

Current cleanup: unmountAllComponents() is called on onPageUnloaded, which should handle full page navigations.

Question: Does Turbo/Hotwire trigger onPageUnloaded? If not, renderedRoots might accumulate across Turbo navigations.

Recommendation:

  • Verify Turbo integration clears renderedRoots on Turbo page changes
  • Consider adding a public ReactOnRails.clearRenderedRoots() API for manual cleanup if needed

3. Test Checkbox Not Marked Complete

In the PR description, this checkbox is unchecked:

- [ ] Playwright E2E test: async_page_loaded_test page should show no hydration errors

Action: Please run the Playwright test locally and mark it as complete, or document if there are blockers.


🔒 Security Considerations

✅ No security concerns identified. The change:

  • Doesn't introduce XSS risks (no DOM manipulation)
  • Doesn't expose internal state
  • Doesn't change authentication/authorization logic

⚡ Performance Considerations

Positive Impact: The early return on line 88 prevents unnecessary:

  • Component registry lookups
  • React element creation
  • ReactDOM calls

The Map.has() check is O(1), so negligible overhead.

Estimated performance improvement: For apps calling reactOnRailsPageLoaded() multiple times with N components, this eliminates (calls - 1) × N unnecessary re-renders.


📋 Recommendations

High Priority

  1. Mark Playwright test as complete or document why it's pending
  2. Run RuboCop: Per CLAUDE.md, bundle exec rubocop is mandatory before commit
  3. Verify Turbo/Hotwire integration: Ensure renderedRoots is properly cleared on Turbo page changes

Medium Priority

  1. Add JSDoc comment to reactOnRailsPageLoaded() documenting the "already rendered" behavior:

    /**
     * Public API function that can be called to render components after they are loaded.
     * Safe to call multiple times - already-rendered components will be skipped.
     * 
     * @param domId - Optional specific component ID. If omitted, renders all components.
     * @note Components are tracked in memory and won't re-render on subsequent calls.
     *       If you need to force re-render, unmount the component first.
     */
  2. Consider adding a test for the Turbo integration scenario if applicable

Low Priority

  1. Changelog entry format: The PR description has good changelog content, but verify it follows the format in CLAUDE.md (with PR link format)

🎯 Final Verdict

LGTM with minor recommendations

This is a solid fix for a real bug. The implementation is clean, well-tested, and should have minimal risk. The concerns raised are edge cases that likely won't affect most users, but documenting them would improve long-term maintainability.

Estimated CI Risk: Low - the change is defensive (adds a guard clause) and comprehensive tests should catch regressions.


📝 Pre-Merge Checklist (from CLAUDE.md)

Based on the project's requirements:

  • bundle exec rubocop runs with zero offenses
  • All files end with newline character
  • Playwright tests pass locally (mark checkbox in PR description)
  • CHANGELOG.md updated (verify format matches project standards)
  • Consider adding to CHANGELOG.md if not already there

Note: Please verify the above before merging. The CLAUDE.md guidelines are very explicit about these requirements.


Great work on the fix! Let me know if you'd like me to elaborate on any of these points. 🚀

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

205-264: Make the regression test deterministic: clear mocks per test + prefer jest.requireMock.

   beforeEach(() => {
+    jest.clearAllMocks();
     // Clear registries
     ComponentRegistry.clear();
     StoreRegistry.clearHydratedStores();
@@
-      const mockHydrateOrRender = require('../src/reactHydrateOrRender.ts').default as jest.Mock;
+      const mockHydrateOrRender = jest.requireMock('../src/reactHydrateOrRender.ts')
+        .default as jest.Mock;
       const callCountAfterFirstRender = mockHydrateOrRender.mock.calls.length;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c37fddb and 2295e78.

📒 Files selected for processing (7)
  • packages/react-on-rails/src/ClientRenderer.ts (1 hunks)
  • packages/react-on-rails/tests/ClientRenderer.test.ts (1 hunks)
  • react_on_rails/spec/dummy/app/views/pages/async_page_loaded_test.html.erb (1 hunks)
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.js (2 hunks)
  • react_on_rails/spec/dummy/client/app/startup/AsyncComponent.jsx (1 hunks)
  • react_on_rails/spec/dummy/config/routes.rb (1 hunks)
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • react_on_rails/spec/dummy/client/app/startup/AsyncComponent.jsx
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.js
  • react_on_rails/spec/dummy/config/routes.rb
  • react_on_rails/spec/dummy/app/views/pages/async_page_loaded_test.html.erb
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md}: ALWAYS run bundle exec rubocop and fix ALL violations before every commit/push
ALWAYS ensure files end with a newline character before committing

Files:

  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/tests/ClientRenderer.test.ts
**/*.{js,ts,jsx,tsx,json,css,scss,md}

📄 CodeRabbit inference engine (CLAUDE.md)

ALWAYS let Prettier handle ALL formatting - never manually format code

Files:

  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/tests/ClientRenderer.test.ts
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pnpm as the JavaScript package manager, never use npm or yarn

Files:

  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/tests/ClientRenderer.test.ts
🧠 Learnings (6)
📓 Common learnings
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to /CHANGELOG.md : Update `/CHANGELOG.md` for open-source features, bug fixes, breaking changes, deprecations, and performance improvements using format: `[PR 1818](https://github.com/shakacode/react_on_rails/pull/1818) by [username](https://github.com/username)`
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to spec/dummy/**/*.{spec,test}.{js,ts} : Playwright E2E tests use `cypress-on-rails` gem for Rails integration; use helpers like `app('clean')`, `appEval()`, and `appFactories()` for test setup instead of UI automation
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation is handled in deeper level calls of the React on Rails Pro codebase, so it doesn't need to be validated again in the `rsc_payload_react_component` helper method.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation in React on Rails Pro is handled through a chain of validations:
1. Pro version check in `run_stream_inside_fiber`
2. RSC support check during pack generation via `ReactOnRailsPro.configuration.enable_rsc_support`
3. RSC support validation during component registration
This makes additional validation in the helper methods unnecessary.
📚 Learning: 2025-09-15T21:24:48.207Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/tests/ClientRenderer.test.ts
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2024-12-12T13:07:09.929Z
Learnt from: alexeyr-ci
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/ReactOnRailsRSC.ts:87-87
Timestamp: 2024-12-12T13:07:09.929Z
Learning: When handling errors in 'node_package/src/ReactOnRailsRSC.ts', include the error stack in error messages in development and test environments to aid debugging.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2025-02-13T19:09:15.991Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/RSCWebpackLoader.ts:0-0
Timestamp: 2025-02-13T19:09:15.991Z
Learning: In React Server Components webpack loader, using `new Function('return import("react-server-dom-webpack/node-loader")')()` is necessary as a workaround to bypass TypeScript compilation issues with direct dynamic imports.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2025-02-13T16:50:26.861Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
🧬 Code graph analysis (1)
packages/react-on-rails/tests/ClientRenderer.test.ts (1)
packages/react-on-rails/src/ClientRenderer.ts (1)
  • renderComponent (133-147)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: examples (3.4, latest)
  • GitHub Check: examples (3.2, minimum)
  • GitHub Check: rspec-package-tests (3.4, latest)
  • GitHub Check: rspec-package-tests (3.2, minimum)
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles
  • GitHub Check: precompile-check
  • GitHub Check: pro-lint-js-and-ruby
  • GitHub Check: build-dummy-app-webpack-test-bundles
  • GitHub Check: check-bundle-size
  • GitHub Check: claude-review
🔇 Additional comments (1)
packages/react-on-rails/src/ClientRenderer.ts (1)

96-100: Hydration heuristic still hinges on innerHTML; please sanity-check against React 19 expectations.

Given React 19’s hydration behavior is sensitive, it’s worth verifying const shouldHydrate = !!domNode.innerHTML; is still the desired “SSR marker” (vs e.g. childNodes checks / an explicit attribute) now that dedupe is in place.

Comment thread packages/react-on-rails/src/ClientRenderer.ts Outdated
@justin808
Copy link
Copy Markdown
Member Author

@AbanoubGhadban:

  • Verified failure and the fix manually
2025-12-11_18-45-17 2025-12-11_19-01-42

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 12, 2025

Code Review: Fix hydration mismatch when reactOnRailsPageLoaded() called multiple times

✅ Overall Assessment

This is a well-implemented fix with comprehensive testing. The solution correctly addresses the root cause and includes both unit and E2E tests. Great work!


🎯 Strengths

  1. Correct Root Cause Analysis: The fix properly identifies that the issue is re-rendering already-rendered components, not a hydration problem per se.

  2. Robust Edge Case Handling: The code handles the DOM node replacement scenario (lines 94-113 in ClientRenderer.ts), which shows thoughtful consideration of real-world usage patterns.

  3. Excellent Test Coverage:

    • Unit tests verify the core logic
    • E2E tests validate the actual user scenario
    • Tests cover both single and multiple dynamic additions
  4. Clear Documentation: Inline comments explain the "why" behind the logic, which will help future maintainers.


🔍 Code Quality Observations

ClientRenderer.ts changes (lines 81-113)

Positive:

  • The isConnected check is smart - it ensures we're checking the actual DOM state, not just object equality
  • Proper cleanup of replaced nodes prevents memory leaks
  • Error handling during unmount is appropriate (swallowing errors for replaced nodes)

Minor Concern:

const sameNode = existing.domNode === domNode && existing.domNode.isConnected;

This check compares existing.domNode === domNode AND existing.domNode.isConnected. However, if the nodes are the same object reference, we don't actually need to check .isConnected on the new domNode - we're only checking the existing one. This is fine, but could be slightly more explicit:

const sameNode = existing.domNode === domNode && domNode.isConnected;

Since they're the same reference, this is functionally identical, just slightly more semantically clear.


🧪 Testing

Unit Tests (ClientRenderer.test.ts):

  • ✅ Tests the skip behavior correctly
  • ✅ Tests the DOM replacement scenario
  • ✅ Good use of mock call counting to verify behavior

E2E Tests (page_loaded_multiple_calls.spec.js):

  • ✅ Comprehensive console error monitoring
  • ✅ Tests multiple scenarios (2, 3, 4 components)
  • ✅ Validates both visual rendering and absence of errors

Suggestion: Consider adding a test case for rapid successive calls (e.g., calling reactOnRailsPageLoaded() twice in quick succession before React finishes rendering). This could reveal race conditions if they exist.


📝 Documentation & Changelog

⚠️ MISSING: Changelog entry

According to the project's CLAUDE.md, user-visible bug fixes should be documented in /CHANGELOG.md. This fix should include an entry like:

#### Fixed
[PR 2211](https://github.com/shakacode/react_on_rails/pull/2211) by [justin808](https://github.com/justin808): Fixed hydration mismatch errors when `reactOnRailsPageLoaded()` is called multiple times for asynchronously loaded content. Previously rendered client-side components are now correctly skipped during subsequent calls.

🔒 Security Considerations

✅ No security concerns identified. The changes are purely internal logic improvements.


⚡ Performance Considerations

Positive:

  • The renderedRoots Map lookup is O(1), so no performance degradation
  • Early return for already-rendered components saves unnecessary work
  • Proper cleanup prevents memory leaks

Minor optimization opportunity:
The isConnected check happens on every call. For pages with many components called frequently, you could consider a WeakMap-based approach, but the current implementation is perfectly fine for typical usage patterns.


🎨 Code Style

✅ Follows project conventions
✅ Proper TypeScript typing
✅ Clear variable names
✅ Appropriate use of early returns


🐛 Potential Edge Cases to Consider

  1. What if domNode is removed from DOM but not replaced?

    • Current behavior: Will re-render into the cached (disconnected) node
    • This might be unexpected. Consider adding:
    if (existing && !existing.domNode.isConnected) {
      // Node was removed, clean up and re-render
      renderedRoots.delete(domNodeId);
    }
  2. What if component props change but DOM node is the same?

    • Current behavior: Component won't re-render with new props
    • This might be intentional, but worth documenting if it's expected

📋 Action Items

Before merging:

  1. ✅ Add CHANGELOG.md entry (as shown above)
  2. ⚠️ Consider the disconnected node edge case
  3. ⚠️ Consider documenting the props-change behavior

Optional enhancements:

  • Add a test for rapid successive calls
  • Consider adding a debug mode that logs when components are skipped

🎉 Conclusion

This is a high-quality fix that demonstrates good understanding of React rendering lifecycle and edge case handling. The comprehensive test coverage gives confidence that this won't regress. With the changelog entry added, this is ready to merge!

Recommendation: Approve with minor changelog addition

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

205-220: Consider extracting the unmount logic to reduce duplication.

The unmount pattern (checking supportsRootApi, then root.unmount() vs unmountComponentAtNode) is duplicated between here and lines 96-105. While not critical, extracting a helper function would improve maintainability.

+function unmountRoot({ root, domNode }: { root: RenderReturnType; domNode: Element }): void {
+  if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) {
+    root.unmount();
+  } else {
+    unmountComponentAtNode(domNode);
+  }
+}
+
 function unmountAllComponents(): void {
   renderedRoots.forEach(({ root, domNode }) => {
     try {
-      if (supportsRootApi && root && typeof root === 'object' && 'unmount' in root) {
-        // React 18+ Root API
-        root.unmount();
-      } else {
-        // React 16-17 legacy API
-        unmountComponentAtNode(domNode);
-      }
+      unmountRoot({ root, domNode });
     } catch (error) {
       console.error('Error unmounting component:', error);
     }
   });
   renderedRoots.clear();
 }

Then reuse unmountRoot in the renderElement cleanup block as well.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2295e78 and e81bae8.

📒 Files selected for processing (4)
  • packages/react-on-rails/src/ClientRenderer.ts (1 hunks)
  • packages/react-on-rails/tests/ClientRenderer.test.ts (1 hunks)
  • react_on_rails/spec/dummy/app/views/pages/async_page_loaded_test.html.erb (1 hunks)
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
  • packages/react-on-rails/tests/ClientRenderer.test.ts
  • react_on_rails/spec/dummy/app/views/pages/async_page_loaded_test.html.erb
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md}: ALWAYS run bundle exec rubocop and fix ALL violations before every commit/push
ALWAYS ensure files end with a newline character before committing

Files:

  • packages/react-on-rails/src/ClientRenderer.ts
**/*.{js,ts,jsx,tsx,json,css,scss,md}

📄 CodeRabbit inference engine (CLAUDE.md)

ALWAYS let Prettier handle ALL formatting - never manually format code

Files:

  • packages/react-on-rails/src/ClientRenderer.ts
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pnpm as the JavaScript package manager, never use npm or yarn

Files:

  • packages/react-on-rails/src/ClientRenderer.ts
🧠 Learnings (6)
📓 Common learnings
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to /CHANGELOG.md : Update `/CHANGELOG.md` for open-source features, bug fixes, breaking changes, deprecations, and performance improvements using format: `[PR 1818](https://github.com/shakacode/react_on_rails/pull/1818) by [username](https://github.com/username)`
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation is handled in deeper level calls of the React on Rails Pro codebase, so it doesn't need to be validated again in the `rsc_payload_react_component` helper method.
Learnt from: alexeyr-ci
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/ReactOnRailsRSC.ts:87-87
Timestamp: 2024-12-12T13:07:09.929Z
Learning: When handling errors in 'node_package/src/ReactOnRailsRSC.ts', include the error stack in error messages in development and test environments to aid debugging.
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2024-12-12T13:07:09.929Z
Learnt from: alexeyr-ci
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/ReactOnRailsRSC.ts:87-87
Timestamp: 2024-12-12T13:07:09.929Z
Learning: When handling errors in 'node_package/src/ReactOnRailsRSC.ts', include the error stack in error messages in development and test environments to aid debugging.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2025-02-12T16:38:06.537Z
Learnt from: Romex91
Repo: shakacode/react_on_rails PR: 1697
File: package-scripts.yml:28-28
Timestamp: 2025-02-12T16:38:06.537Z
Learning: The file `node_package/lib/ReactOnRails.full.js` is autogenerated during the build process and should not be present in the repository.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2025-09-15T21:24:48.207Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
📚 Learning: 2025-02-13T16:50:26.861Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/turbolinksUtils.ts:34-36
Timestamp: 2025-02-13T16:50:26.861Z
Learning: In React on Rails, when checking for Turbolinks version 5 using `turbolinksVersion5()`, always ensure `Turbolinks` exists first by checking `turbolinksInstalled()` to prevent TypeError when accessing properties.

Applied to files:

  • packages/react-on-rails/src/ClientRenderer.ts
🧬 Code graph analysis (1)
packages/react-on-rails/src/ClientRenderer.ts (1)
packages/react-on-rails-pro-node-renderer/src/shared/tracing.ts (1)
  • trace (87-89)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: build-dummy-app-webpack-test-bundles (3.4, 22, latest)
  • GitHub Check: examples (3.2, minimum)
  • GitHub Check: examples (3.4, latest)
  • GitHub Check: rspec-package-tests (3.4, latest)
  • GitHub Check: rspec-package-tests (3.2, minimum)
  • GitHub Check: pro-lint-js-and-ruby
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles
  • GitHub Check: build-dummy-app-webpack-test-bundles
  • GitHub Check: check-bundle-size
  • GitHub Check: claude-review
🔇 Additional comments (4)
packages/react-on-rails/src/ClientRenderer.ts (4)

14-15: LGTM! Good approach to track rendered roots.

The renderedRoots Map correctly stores both the root and domNode, enabling proper cleanup and the "same node" comparison needed to prevent hydration issues.


81-113: LGTM! Well-structured handling of already-rendered and replaced components.

The logic correctly:

  1. Skips re-rendering when the same DOM node is still connected
  2. Cleans up the old root when a node with the same ID was replaced
  3. Handles both React 18+ Root API and legacy unmount gracefully
  4. Swallows unmount errors appropriately since the old DOM is already gone

This properly addresses the hydration mismatch issue described in #2210.


120-142: Good documentation of the fix rationale.

The comment on lines 120-122 clearly explains why the innerHTML check now works correctly - it's the combination of the skip logic above that makes this check accurate. Previously client-rendered content would have innerHTML and incorrectly trigger hydration; now those cases are caught by the renderedRoots check.


222-223: LGTM! Proper cleanup registration.

Registering unmountAllComponents via onPageUnloaded ensures React roots are properly cleaned up on page navigation, preventing memory leaks.

Comment on lines +81 to +92
// Check if this component was already rendered by a previous call
// This prevents hydration errors when reactOnRailsPageLoaded() is called multiple times
// (e.g., for asynchronously loaded content)
const existing = renderedRoots.get(domNodeId);
if (existing) {
// Only skip if it's the exact same DOM node and it's still connected to the document
const sameNode = existing.domNode === domNode && existing.domNode.isConnected;
if (sameNode) {
if (trace) {
console.log(`Skipping already rendered component: ${name} (dom id: ${domNodeId})`);
}
return;
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.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if CHANGELOG.md mentions this PR or the related issue
rg -n "2211|2210" CHANGELOG.md || echo "No mention of PR #2211 or issue #2210 found in CHANGELOG.md"

Repository: shakacode/react_on_rails

Length of output: 127


Add entry to CHANGELOG.md for this bug fix.

CHANGELOG.md must be updated for bug fixes using the format: [PR 2211](https://github.com/shakacode/react_on_rails/pull/2211) by [justin808](https://github.com/justin808)

🤖 Prompt for AI Agents
In packages/react-on-rails/src/ClientRenderer.ts around lines 81 to 92, the PR
added a guard to skip re-rendering already rendered components; update
CHANGELOG.md to record this bug fix by adding an entry in the required format:
`[PR 2211](https://github.com/shakacode/react_on_rails/pull/2211) by
[justin808](https://github.com/justin808)` under the appropriate unreleased or
version section, ensuring the wording indicates it fixes skipping duplicate
renders/hydration errors and follows existing changelog entry style.

@justin808
Copy link
Copy Markdown
Member Author

@claude review this PR

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 12, 2025

Claude finished @justin808's task in 2m 7s —— View job


PR Review for #2211: Fix hydration mismatch when reactOnRailsPageLoaded() called multiple times

✅ Overall Assessment

Verdict: LGTM with one minor action item (CHANGELOG entry)

This is an excellent fix for issue #2210. The implementation is surgical, well-tested, and handles edge cases thoughtfully. The root cause analysis is spot-on, and the solution is minimal yet comprehensive.


🎯 Strengths

1. Correct Root Cause Analysis

The PR correctly identifies that calling reactOnRailsPageLoaded() multiple times caused the shouldHydrate check (!!domNode.innerHTML) to incorrectly treat previously client-rendered content as server-rendered content. This is the exact issue.

2. Minimal, Surgical Implementation (ClientRenderer.ts:81-113)

  • Only 32 lines of production code added
  • Leverages existing renderedRoots Map infrastructure (line 15)
  • Clean early return pattern prevents unnecessary work
  • Handles both React 18+ Root API and legacy unmount gracefully

Key logic (lines 84-93):

const existing = renderedRoots.get(domNodeId);
if (existing) {
  const sameNode = existing.domNode === domNode && existing.domNode.isConnected;
  if (sameNode) {
    // Skip re-rendering same component
    return;
  }
  // DOM node was replaced - clean up old root and continue
}

3. Excellent Edge Case Handling (lines 94-113)

The fix intelligently handles the replaced DOM node scenario that CodeRabbit identified:

  • Detects when DOM node with same ID was replaced (different object reference)
  • Properly unmounts the old root to prevent memory leaks
  • Allows re-rendering to the new DOM node
  • Gracefully handles unmount errors (line 106-111) since old DOM is gone

4. Comprehensive Test Coverage

Unit Tests (ClientRenderer.test.ts:206-311):

  • ✅ Tests skipping already-rendered components (lines 231-266)
  • ✅ Tests re-rendering when DOM node is replaced (lines 268-310)
  • ✅ Properly mocks reactHydrateOrRender and verifies call counts

E2E Tests (page_loaded_multiple_calls.spec.js):

  • ✅ Tests single component addition (lines 50-104)
  • ✅ Tests multiple additions (3 dynamic components, lines 106-151)
  • ✅ Tests DOM node replacement scenario (lines 169-205)
  • ✅ Tests multiple replacements (lines 207-228)
  • ✅ Monitors console for hydration errors correctly

Test Page (async_page_loaded_test.html.erb):

  • ✅ Excellent manual test page with clear instructions
  • ✅ Visual feedback for both scenarios (add and replace)
  • ✅ Captures and displays hydration errors in real-time
  • ✅ Demonstrates the fix working correctly

5. Clear Documentation


🔍 Code Quality Observations

ClientRenderer.ts

Line 87: isConnected Check

const sameNode = existing.domNode === domNode && existing.domNode.isConnected;

Smart use of .isConnected to verify the DOM node is still in the document. This prevents skipping components whose nodes were removed but not replaced.

Lines 96-105: Dual Unmount Support

if (supportsRootApi && existing.root && typeof existing.root === 'object' && 'unmount' in existing.root) {
  existing.root.unmount();
} else {
  unmountComponentAtNode(existing.domNode);
}

Proper handling of both React 18+ Root API and legacy unmount. The type guards are appropriate.

Line 123: Hydration Check Now Correct

const shouldHydrate = !!domNode.innerHTML;

The comment above (lines 120-122) correctly explains why this now works: previously rendered components are skipped above, so innerHTML only indicates server-rendered content.

Unit Tests

Minor: Mock Clearing (line 258)

const mockHydrateOrRender = require('../src/reactHydrateOrRender.ts').default as jest.Mock;

Consider using jest.clearAllMocks() in beforeEach to ensure clean state between tests. Current approach works but could be more explicit.

Call Count Verification (line 265)

expect(mockHydrateOrRender.mock.calls.length).toBe(callCountAfterFirstRender);

This correctly verifies the mock wasn't called again. Could be slightly more explicit with:

expect(mockHydrateOrRender).not.toHaveBeenCalledTimes(callCountAfterFirstRender + 1);

But current approach is fine.

E2E Tests

Console Error Filtering (lines 59-66)

if (text.toLowerCase().includes('hydrat') || 
    text.toLowerCase().includes('did not match') || 
    text.toLowerCase().includes('mismatch'))

Good filtering for hydration-specific errors. This prevents false positives from other console errors.

Test Robustness
The tests wait for networkidle and use proper Playwright selectors. Well-structured.


⚠️ Required Action Item

Missing CHANGELOG.md Entry

Per CLAUDE.md guidelines:

Update /CHANGELOG.md for open-source features, bug fixes, breaking changes, deprecations, and performance improvements using format: [PR 1818](https://github.com/shakacode/react_on_rails/pull/1818) by [username](https://github.com/username)

This is a user-visible bug fix that should be documented.

Suggested entry for the Unreleased section under #### Fixed:

#### Fixed
- Fixed hydration mismatch errors when `reactOnRailsPageLoaded()` is called multiple times for asynchronously loaded content. Previously rendered client-side components are now correctly skipped during subsequent calls, preventing React from attempting to hydrate already-rendered content. [PR 2211](https://github.com/shakacode/react_on_rails/pull/2211) by [justin808](https://github.com/justin808). Fixes [issue #2210](https://github.com/shakacode/react_on_rails/issues/2210).

🧪 Testing Verification

Based on the PR description and comments:

  • ✅ Unit test passes (confirmed by CI)
  • ✅ Manual testing completed (user verified with screenshots)
  • ⚠️ E2E test status: The PR description shows the Playwright test checkbox unchecked, but CI shows it passing. Recommendation: Mark the checkbox as complete in the PR description.

🔒 Security & Performance

Security

✅ No security concerns identified. The changes:

  • Don't introduce XSS risks
  • Don't expose internal state
  • Properly sanitize/validate inputs

Performance

Positive performance impact:

  • Map.has() and Map.get() are O(1) operations
  • Early return prevents unnecessary React element creation and rendering
  • For apps calling reactOnRailsPageLoaded() N times with M components, this eliminates (N-1) × M unnecessary re-renders
  • Proper cleanup prevents memory leaks

💡 Potential Enhancements (Optional)

1. Extract Unmount Logic (Low Priority)

The unmount pattern is duplicated at lines 96-105 and 208-214. Consider extracting to a helper function:

function unmountRoot(entry: { root: RenderReturnType; domNode: Element }): void {
  if (supportsRootApi && entry.root && typeof entry.root === 'object' && 'unmount' in entry.root) {
    entry.root.unmount();
  } else {
    unmountComponentAtNode(entry.domNode);
  }
}

This is a nice-to-have, not blocking.

2. JSDoc Enhancement (Low Priority)

Consider adding a note to the reactOnRailsPageLoaded JSDoc about the skip behavior:

/**
 * Public API function that can be called to render components after they are loaded.
 * Safe to call multiple times - already-rendered components will be skipped.
 * 
 * @note Components are tracked and won't re-render on subsequent calls unless 
 *       the DOM node is replaced. If you need to force re-render with new props,
 *       use reactOnRailsComponentLoaded(domId) on individual components.
 */

📋 Pre-Merge Checklist

Per CLAUDE.md requirements:

  • Code follows project conventions
  • TypeScript typing is correct
  • Tests are comprehensive (unit + E2E)
  • Manual testing completed (verified by user)
  • CHANGELOG.md updatedAction required
  • bundle exec rubocop passes (assumed from CI)
  • Files end with newline (assumed from CI)
  • Prettier formatting (assumed from CI)

🎉 Final Recommendation

LGTM - Approve after CHANGELOG entry is added

This is a high-quality fix that:

  • ✅ Correctly diagnoses and fixes the root cause
  • ✅ Has minimal code footprint (32 production lines)
  • ✅ Includes comprehensive tests (unit + E2E + manual)
  • ✅ Handles edge cases thoughtfully (DOM replacement)
  • ✅ Has no breaking changes or security issues
  • ✅ Improves performance

Risk Assessment: Low

  • Changes are defensive (adds guards)
  • Comprehensive test coverage reduces regression risk
  • No API changes

Action Item: Add CHANGELOG.md entry, then merge with confidence!

Great work on this fix! 🚀


@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 12, 2025

Code Review: Fix hydration mismatch when reactOnRailsPageLoaded() called multiple times

Overall Assessment

This PR effectively addresses issue #2210 with a well-thought-out solution. The fix is elegant, properly tested, and includes comprehensive E2E coverage.

Core Fix Analysis

The Problem: When reactOnRailsPageLoaded() is called multiple times, previously client-rendered components were being re-hydrated because !!domNode.innerHTML returned true, causing hydration mismatch errors in React 19.

The Solution: Track rendered roots and skip re-rendering components already mounted, while handling DOM node replacement edge cases.

Strengths

  1. Correct DOM node identity check using existing.domNode === domNode && existing.domNode.isConnected
  2. Properly handles DOM node replacement (same ID, different element)
  3. Safe unmounting with try-catch for disconnected nodes
  4. Comprehensive logging for debugging
  5. Updated comments accurately explain the behavior

Recommendations

High Priority:

  • Verify Playwright tests pass in CI
  • Verify no memory leaks in SPAs - check if onPageUnloaded cleans up renderedRoots Map

Medium Priority:

  • Consider documenting DOM replacement scenario more explicitly
  • Add test for isConnected === false edge case

Low Priority:

  • Document why unmount errors are silently ignored
  • Consider improving type safety for unmount type guard

Test Coverage

Excellent test coverage with both unit tests and comprehensive E2E tests covering initial load, multiple calls, multiple additions, DOM replacement, and multiple replacements.

Security & Performance

No security concerns. Performance is good with O(1) Map lookups and early returns.

Verdict

Approve with minor recommendations

This is high-quality work that correctly fixes the root cause with comprehensive testing. Recommendations are mostly for documentation and edge case coverage, not blocking issues.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (7)
react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook (2)

9-16: Avoid brittle “4 levels up” repo-root detection (prefer self-validating resolution).

Hardcoding repo_root = File.expand_path("../../../..", __dir__) will silently break if the dummy app moves (or the repo is vendored differently). Consider resolving relative to this file by searching upward for a repo marker (e.g., .git, pnpm-workspace.yaml, etc.) or at least adding a sanity check that react_on_rails/spec/support/... exists before assuming repo_root, so failures are clearer.


14-19: Minor: log/loader polish (emoji + load).

  • The “❌” in warn can be noisy/problematic in some CI log pipelines; plain ASCII might be safer.
  • If there’s any chance this hook is evaluated multiple times in the same Ruby process, require shared_hook is safer than load to avoid double execution.
react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js (1)

197-197: Consider reducing the hard-coded timeout.

The 200ms timeout using page.waitForTimeout(200) is a hard-coded delay. While it may work, consider using event-driven waits or shorter timeouts if possible.

If the DOM replacement is synchronous or triggers observable changes, you could replace this with:

-    await page.waitForTimeout(200);
+    await page.waitForFunction(() => window.replacementComplete === true);

Or set a flag in the replacement logic and wait for it. This would make the test more reliable and faster.

bin/ci-local (1)

210-216: Dummy app setup: consider pinning install behavior for reproducibility
For local CI parity, it may be worth using the repo’s standard pnpm flags (if any) like --frozen-lockfile (when the intent is “CI-like”) vs permissive installs.

react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb (3)

65-73: Restore console.error on cleanup to avoid cross-test bleed
Since this page is intended for Playwright runs, consider restoring console.error = originalError on beforeunload / pagehide (or after each scenario) so other dummy pages/tests aren’t affected if they share the same context.


165-174: Reduce flakiness: replace fixed setTimeout(100) with a bounded poll / MutationObserver
A hard 100ms delay is a common CI flake source. Consider polling until rendered (with an upper bound like 2s) or using a MutationObserver on #components-container and resolving when the node content changes.


168-172: Brittle assertion: checking innerHTML substring may break on markup changes
If the component’s markup/class changes, this test will fail even if the behavior is correct. Prefer asserting on a stable test id/attribute rendered by ManualRenderComponent (e.g., data-testid="manual-render-component").

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e81bae8 and 1be3450.

📒 Files selected for processing (13)
  • bin/ci-local (6 hunks)
  • bin/ci-rerun-failures (3 hunks)
  • bin/ci-switch-config (2 hunks)
  • react_on_rails/spec/dummy/Procfile.dev (1 hunks)
  • react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb (1 hunks)
  • react_on_rails/spec/dummy/bin/setup (1 hunks)
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.js (2 hunks)
  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx (1 hunks)
  • react_on_rails/spec/dummy/config/routes.rb (1 hunks)
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js (1 hunks)
  • react_on_rails_pro/spec/dummy/Procfile.static (1 hunks)
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook (1 hunks)
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • react_on_rails/spec/dummy/config/routes.rb
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.js
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md}: ALWAYS run bundle exec rubocop and fix ALL violations before every commit/push
ALWAYS ensure files end with a newline character before committing

Files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
**/*.{js,ts,jsx,tsx,json,css,scss,md}

📄 CodeRabbit inference engine (CLAUDE.md)

ALWAYS let Prettier handle ALL formatting - never manually format code

Files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Only use pnpm as the JavaScript package manager, never use npm or yarn

Files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
🧠 Learnings (27)
📓 Common learnings
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to /CHANGELOG.md : Update `/CHANGELOG.md` for open-source features, bug fixes, breaking changes, deprecations, and performance improvements using format: `[PR 1818](https://github.com/shakacode/react_on_rails/pull/1818) by [username](https://github.com/username)`
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.
📚 Learning: 2025-09-16T08:01:11.146Z
Learnt from: justin808
Repo: shakacode/react_on_rails PR: 1770
File: lib/generators/react_on_rails/templates/base/base/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.jsx:2-2
Timestamp: 2025-09-16T08:01:11.146Z
Learning: React on Rails uses webpack CSS Modules configuration with namedExports: true, which requires the import syntax `import * as style from './file.module.css'` rather than the default export pattern. This configuration enables better tree shaking and bundle size optimization for CSS modules.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
📚 Learning: 2024-10-08T20:53:47.076Z
Learnt from: theforestvn88
Repo: shakacode/react_on_rails PR: 1620
File: spec/dummy/client/app/startup/HelloTurboStream.jsx:3-3
Timestamp: 2024-10-08T20:53:47.076Z
Learning: The `RailsContext` import in `spec/dummy/client/app/startup/HelloTurboStream.jsx` is used later in the project, as clarified by the user theforestvn88.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
📚 Learning: 2025-02-12T16:38:06.537Z
Learnt from: Romex91
Repo: shakacode/react_on_rails PR: 1697
File: package-scripts.yml:28-28
Timestamp: 2025-02-12T16:38:06.537Z
Learning: The file `node_package/lib/ReactOnRails.full.js` is autogenerated during the build process and should not be present in the repository.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
📚 Learning: 2025-02-13T19:09:15.991Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/RSCWebpackLoader.ts:0-0
Timestamp: 2025-02-13T19:09:15.991Z
Learning: In React Server Components webpack loader, using `new Function('return import("react-server-dom-webpack/node-loader")')()` is necessary as a workaround to bypass TypeScript compilation issues with direct dynamic imports.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
📚 Learning: 2025-04-26T21:55:55.874Z
Learnt from: alexeyr-ci2
Repo: shakacode/react_on_rails PR: 1732
File: spec/dummy/client/app-react16/startup/ReduxSharedStoreApp.client.jsx:40-44
Timestamp: 2025-04-26T21:55:55.874Z
Learning: In the react_on_rails project, files under `app-react16` directories are copied/moved to corresponding `/app` directories during the conversion process (removing the `-react16` suffix), which affects their relative import paths at runtime.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
📚 Learning: 2025-09-15T21:24:48.207Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1781
File: node_package/src/ClientSideRenderer.ts:82-95
Timestamp: 2025-09-15T21:24:48.207Z
Learning: In React on Rails, the force_load feature includes both explicit `data-force-load="true"` usage and the ability to hydrate components during the page loading state (`document.readyState === 'loading'`). Both capabilities require a Pro license, so the condition `!railsContext.rorPro && (isComponentForceLoaded || document.readyState === 'loading')` correctly gates both scenarios.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb
  • bin/ci-local
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to /CHANGELOG.md : Update `/CHANGELOG.md` for open-source features, bug fixes, breaking changes, deprecations, and performance improvements using format: `[PR 1818](https://github.com/shakacode/react_on_rails/pull/1818) by [username](https://github.com/username)`

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
📚 Learning: 2025-02-13T16:50:47.848Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: node_package/src/clientStartup.ts:18-21
Timestamp: 2025-02-13T16:50:47.848Z
Learning: In the react_on_rails module, the `reactOnRailsPageUnloaded` function in clientStartup.ts is intentionally kept private as it's only used internally as a callback for `onPageUnloaded`.

Applied to files:

  • react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx
  • react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to react_on_rails_pro/**/*.{js,ts,jsx,tsx,json,css,scss} : The `react_on_rails_pro/` directory has its own Prettier/ESLint configuration separate from the root; CI lints both directories separately

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to spec/dummy/**/*.{spec,test}.{js,ts} : Playwright E2E tests use `cypress-on-rails` gem for Rails integration; use helpers like `app('clean')`, `appEval()`, and `appFactories()` for test setup instead of UI automation

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb
  • bin/ci-rerun-failures
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
📚 Learning: 2025-01-23T18:20:45.824Z
Learnt from: alexeyr-ci
Repo: shakacode/react_on_rails PR: 1687
File: spec/dummy/package.json:0-0
Timestamp: 2025-01-23T18:20:45.824Z
Learning: When adding or updating dependencies in spec/dummy/package.json, maintain version consistency with other package.json files in the codebase to avoid potential version conflicts.

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
  • bin/ci-switch-config
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to spec/dummy/e2e/playwright/app_commands/**/*.rb : Create custom app commands for Playwright E2E tests in this directory using the `command` syntax provided by `cypress-on-rails` gem

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • bin/ci-rerun-failures
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Monorepo contains separate open-source and Pro packages; changes affecting both require updating both `/CHANGELOG.md` and `/CHANGELOG_PRO.md`

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • bin/ci-local
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to lib/react_on_rails/**/*.rb : Create corresponding RBS signature files in `sig/react_on_rails/` for new Ruby files and add them to Steepfile for type checking

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to package.json,pnpm-lock.yaml : Test build scripts after modifying package.json or dependencies: run `pnpm run prepack` and `pnpm run yalc.publish` to verify build pipeline

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails_pro/spec/dummy/Procfile.static
  • bin/ci-rerun-failures
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
  • bin/ci-switch-config
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to **/*.{js,ts,jsx,tsx} : Only use `pnpm` as the JavaScript package manager, never use `npm` or `yarn`

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
  • bin/ci-rerun-failures
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
  • bin/ci-switch-config
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to spec/dummy/config/webpack/**/*.js : When debugging Webpack configuration issues, create temporary debug scripts to inspect rules and loaders; clean them up before committing

Applied to files:

  • react_on_rails/spec/dummy/Procfile.dev
  • react_on_rails/spec/dummy/bin/setup
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to spec/dummy/**/*.{js,ts} : Install Playwright browsers via `cd spec/dummy && pnpm playwright install --with-deps` before running E2E tests

Applied to files:

  • react_on_rails/spec/dummy/bin/setup
  • bin/ci-rerun-failures
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
  • bin/ci-switch-config
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to lib/generators/react_on_rails/**/*.rb : Generators run in host app context, not engine context; do not assume host app structure (e.g., `app/javascript/` may not exist in older apps)

Applied to files:

  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to **/*.{rb,js,ts,jsx,tsx,json,yml,yaml,md} : ALWAYS run `bundle exec rubocop` and fix ALL violations before every commit/push

Applied to files:

  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/dummy/Procfile.static
  • bin/ci-rerun-failures
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
  • bin/ci-local
📚 Learning: 2025-09-29T14:45:42.687Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1833
File: lib/react_on_rails/dev/process_manager.rb:72-83
Timestamp: 2025-09-29T14:45:42.687Z
Learning: In Ruby bundler contexts, when bundler intercepts system commands for executables not in the Gemfile, both version checks (like `system("foreman", "--version")`) and execution commands (like `system("foreman", "start", ...)`) fail equally, both returning false. This means availability checks using version flags accurately reflect whether execution commands will work in the current bundler context.

Applied to files:

  • react_on_rails/spec/dummy/bin/setup
  • react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to lib/react_on_rails/**/*.rb : Engine code has limited access to host application; use `Rails.root` for host app paths and `ReactOnRails.configure` for engine configuration

Applied to files:

  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
📚 Learning: 2025-10-23T17:22:01.074Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1875
File: lib/react_on_rails/utils.rb:112-124
Timestamp: 2025-10-23T17:22:01.074Z
Learning: In React on Rails, when Pro is installed but not licensed, the intended behavior is to raise an error on boot. The `react_on_rails_pro?` method validates licenses and should raise errors early (including during path resolution in methods like `server_bundle?`) to enforce licensing requirements rather than failing later with obscure errors.

Applied to files:

  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
  • bin/ci-local
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to **/*.rbs : Add RBS signature files for all new Ruby files in `lib/react_on_rails/` and register them in Steepfile; validate with `bundle exec rake rbs:validate`

Applied to files:

  • react_on_rails_pro/spec/dummy/bin/shakapacker-precompile-hook
📚 Learning: 2025-12-10T09:27:45.105Z
Learnt from: CR
Repo: shakacode/react_on_rails PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-10T09:27:45.105Z
Learning: Applies to **/.github/workflows/*.yml : Changes to CI workflows require comprehensive local testing BEFORE pushing to avoid CI failure cycles

Applied to files:

  • bin/ci-local
📚 Learning: 2025-02-18T13:08:01.477Z
Learnt from: AbanoubGhadban
Repo: shakacode/react_on_rails PR: 1644
File: lib/react_on_rails/helper.rb:190-197
Timestamp: 2025-02-18T13:08:01.477Z
Learning: RSC support validation in React on Rails Pro is handled through a chain of validations:
1. Pro version check in `run_stream_inside_fiber`
2. RSC support check during pack generation via `ReactOnRailsPro.configuration.enable_rsc_support`
3. RSC support validation during component registration
This makes additional validation in the helper methods unnecessary.

Applied to files:

  • bin/ci-local
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: dummy-app-integration-tests (3.2, 20, minimum)
  • GitHub Check: dummy-app-integration-tests (3.4, 22, latest)
  • GitHub Check: rspec-package-tests (3.4, latest)
  • GitHub Check: rspec-package-tests (3.2, minimum)
  • GitHub Check: examples (3.2, minimum)
  • GitHub Check: examples (3.4, latest)
  • GitHub Check: precompile-check
  • GitHub Check: build-dummy-app-webpack-test-bundles
  • GitHub Check: build
  • GitHub Check: build-dummy-app-webpack-test-bundles
  • GitHub Check: pro-lint-js-and-ruby
  • GitHub Check: check-bundle-size
  • GitHub Check: claude-review
🔇 Additional comments (23)
react_on_rails/spec/dummy/bin/setup (1)

21-21: LGTM: Consistent with PNPM migration.

The change from bin/yarn to pnpm install aligns with the repository-wide migration to PNPM as the JavaScript package manager.

As per coding guidelines, ensure this aligns with the broader migration across all setup scripts and CI tooling.

react_on_rails_pro/spec/execjs-compatible-dummy/bin/setup (1)

23-23: LGTM: Correct flag preservation during migration.

The migration to pnpm install --frozen-lockfile correctly preserves the lockfile constraint, ensuring deterministic dependency installation in the test environment.

react_on_rails/spec/dummy/Procfile.dev (1)

8-8: LGTM: Procfile updated for PNPM.

The ReScript build command correctly uses pnpm instead of yarn, consistent with the repository-wide migration.

react_on_rails/spec/dummy/client/app/startup/ManualRenderComponent.jsx (1)

1-40: LGTM: Well-structured test component with clear purpose.

The component is appropriately designed for testing hydration behavior:

  • PropTypes validation is correct for the required name prop
  • Inline styles intentionally use camelCase to detect hydration mismatches (kebab-case vs camelCase CSS properties)
  • Clear documentation explains the testing purpose and scope
  • data-testid attribute supports E2E test assertions
react_on_rails_pro/spec/dummy/Procfile.static (1)

5-8: LGTM: Procfile commands migrated to PNPM.

Both asset build and node-renderer commands correctly use pnpm run, consistent with the repository-wide migration from Yarn.

bin/ci-rerun-failures (3)

191-197: LGTM: CI commands migrated to PNPM.

All CI job commands correctly use PNPM:

  • Linting uses pnpm run eslint and pnpm start format.listDifferent
  • Testing uses pnpm test

This ensures consistency with the repository-wide PNPM migration.


260-260: LGTM: Help text updated for PNPM.

The echo message correctly reflects the updated pnpm test command, maintaining consistency with the actual commands executed.


292-292: LGTM: Dependency installation uses PNPM.

The dependency check correctly uses pnpm install for both bundle and node package installation.

react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/page_loaded_multiple_calls.spec.js (5)

24-50: LGTM: Initial load test is well-structured.

The test correctly:

  • Monitors console errors for hydration-related messages
  • Waits for network idle before assertions
  • Verifies component visibility and content
  • Asserts no hydration errors occurred

52-108: LGTM: Multiple call test validates the fix.

This test directly addresses issue #2210 by:

  • Verifying initial render
  • Triggering reactOnRailsPageLoaded() via button click
  • Confirming new component renders without hydration errors
  • Asserting existing component remains functional

The comprehensive console monitoring (hydration, mismatch, "did not match") ensures proper detection of React 19 hydration issues.


110-155: LGTM: Multiple additions test provides thorough coverage.

The test validates that multiple successive calls to reactOnRailsPageLoaded() work correctly by:

  • Adding 3 components dynamically
  • Verifying each component renders with correct content
  • Asserting total component count
  • Confirming no hydration errors across all additions

173-213: LGTM: DOM replacement edge case is well-tested.

This test addresses the CodeRabbit-identified edge case where a DOM node is replaced with a new element using the same ID. The test correctly:

  • Verifies initial render
  • Triggers DOM node replacement
  • Confirms re-rendering to the new node
  • Validates success indicators

This ensures the fix doesn't incorrectly skip rendering when the DOM node reference changes.


215-238: LGTM: Multiple replacements test ensures stability.

The test validates that multiple DOM node replacements work correctly, ensuring the component tracking logic handles repeated replacements without issues.

bin/ci-switch-config (4)

270-275: LGTM: Lockfile handling correct for minimum config.

The script correctly:

  • Checks for and removes pnpm-lock.yaml instead of yarn.lock
  • Uses pnpm install --no-frozen-lockfile to allow dependency downgrades

This is appropriate for switching to the minimum dependency configuration.


280-284: LGTM: spec/dummy lockfile handling consistent.

The spec/dummy directory lockfile handling mirrors the root directory approach, correctly using pnpm install --no-frozen-lockfile for the minimum configuration.


411-416: LGTM: Lockfile handling correct for latest config.

The script correctly:

  • Checks for and removes pnpm-lock.yaml
  • Uses pnpm install --frozen-lockfile to ensure exact dependency versions

This is appropriate for restoring to the latest configuration with deterministic builds.


421-425: LGTM: spec/dummy restoration uses frozen lockfile.

The spec/dummy directory correctly uses pnpm install --frozen-lockfile for the latest configuration, ensuring deterministic dependency installation.

bin/ci-local (5)

156-161: Confirm Pro install uses the intended pnpm mode (workspace vs standalone)
pnpm install inside react_on_rails_pro/ will behave differently depending on whether the repo is configured as a pnpm workspace and how the Pro package is linked. Please verify this succeeds from a clean clone (no node_modules/, no pnpm store primed).


170-174: Good: hard-stop when root dependency install fails
Failing fast here prevents running partial CI with missing gems or JS deps.


182-185: Verify pnpm start format.listDifferent is actually wired the same as the prior yarn invocation
pnpm start <args> forwards args to the start script, but this assumes start is the right entrypoint for format.listDifferent in this repo (often this is nps-driven). If format.listDifferent is an nps task, consider calling it explicitly (e.g., pnpm run nps format.listDifferent) for clarity and parity.


189-190: Verify pnpm test targets the same suite as CI expects
Depending on package.json, pnpm test may run a different subset than the previous yarn command(s). Worth confirming this matches CI.


244-247: Pro checks: validate the pnpm run nps <task> command names
These will fail if nps isn’t defined as a script alias or if task names differ between root and Pro. Please confirm from a clean state that each command resolves (eslint/format/check-typescript/test). Based on learnings, Pro has separate lint/format config, so it’s good that you run these from react_on_rails_pro/.

Also applies to: 256-258

react_on_rails/spec/dummy/app/views/pages/manual_render_test.html.erb (1)

94-129: Nice: DOM construction mirrors react_on_rails client-only markup
This is a solid way to reproduce the “async inserted component + reactOnRailsPageLoaded()” path without relying on server rendering.

justin808 and others added 4 commits December 12, 2025 16:54
…times (#2210)

When reactOnRailsPageLoaded() is called multiple times (e.g., for
asynchronously loaded content), previously rendered client-side
components were being re-hydrated, causing hydration mismatch errors
in React 19.

The fix:
- Track rendered components in renderedRoots Map (already existed for cleanup)
- Skip rendering if component is already tracked, preventing re-hydration
- This ensures only new components are rendered on subsequent calls

Includes:
- Unit test to verify the fix
- E2E Playwright test with test page in dummy app
- AsyncComponent for testing the scenario

Closes #2210

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Address CodeRabbit feedback: the previous guard would skip rendering even
when the DOM node with the same ID was replaced (e.g., via async HTML
injection). This could cause legitimate renders to be skipped and leak
old React roots.

Now the guard:
- Checks if the stored DOM node is the same instance AND still connected
- If the node was replaced, unmounts the old root and removes the entry
- Only skips rendering when it's truly the same connected node

Added unit test for the "same id, new node" scenario.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add "Replace Component" button to test page that demonstrates the
CodeRabbit edge case: when a DOM node is removed and recreated with
the same ID, the component should still render to the new node.

Added E2E tests:
- Test that component renders to replaced DOM node
- Test multiple consecutive DOM replacements work correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
The term "async" was confusing as it suggested Pro's async hydration
feature. This test is for the core package's manual rendering API
(reactOnRailsPageLoaded called multiple times), not Pro's async
hydration feature.

Changes:
- Renamed AsyncComponent.jsx to ManualRenderComponent.jsx
- Renamed async_page_loaded_test route/view to manual_render_test
- Updated data-testid from "async-component" to "manual-render-component"
- Added clarifying notes in component and test file comments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@justin808
Copy link
Copy Markdown
Member Author

@claude review this PR.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 13, 2025

Claude finished @justin808's task in 3m 6s —— View job


PR Review for #2211: Fix hydration mismatch when reactOnRailsPageLoaded() called multiple times

✅ Overall Assessment

Verdict: Excellent fix - LGTM with one required action (CHANGELOG entry)

This PR provides a clean, well-tested solution to issue #2210. The implementation is minimal yet comprehensive, with excellent test coverage and thoughtful handling of edge cases.


🎯 Core Fix Analysis

The Problem (Lines 81-92 in ClientRenderer.ts)

When reactOnRailsPageLoaded() is called multiple times (e.g., for dynamically loaded content), previously rendered client-side components were being re-hydrated because:

  • The shouldHydrate check (!!domNode.innerHTML) returned true for already-rendered components
  • React tried to hydrate content that wasn't server-rendered, causing hydration mismatch errors in React 19

The Solution

Brilliant use of existing infrastructure: The fix leverages the existing renderedRoots Map to track which components have been rendered, and adds smart logic to:

  1. Skip re-rendering same components (lines 84-92):

    const existing = renderedRoots.get(domNodeId);
    if (existing) {
      const sameNode = existing.domNode === domNode && existing.domNode.isConnected;
      if (sameNode) {
        return; // Skip - component already rendered
      }
  2. Handle DOM node replacement (lines 94-113):

    • Detects when DOM node with same ID was replaced (different object reference)
    • Properly unmounts the old root to prevent memory leaks
    • Allows re-rendering to the new DOM node
    • Handles both React 18+ Root API and legacy unmount
  3. Fix hydration check (line 123):

    • Now shouldHydrate = !!domNode.innerHTML correctly identifies only server-rendered content
    • Previously rendered components are filtered out above, so this check is accurate

✨ Strengths

1. Minimal Code Footprint

Only 37 lines added to production code, with zero breaking changes. The fix is surgical and focused.

2. Smart Edge Case Handling

The CodeRabbit-identified edge case (DOM node replacement with same ID) is brilliantly handled:

  • Uses existing.domNode === domNode to detect replacement
  • Checks isConnected to verify node is still in document
  • Properly cleans up old root before rendering new one
  • Swallows unmount errors gracefully (old DOM is already gone)

3. Excellent Test Coverage

Unit Tests (ClientRenderer.test.ts:206-311):

  • ✅ Tests skipping already-rendered components
  • ✅ Tests re-rendering when DOM node is replaced
  • ✅ Proper mock usage and call count verification

E2E Tests (page_loaded_multiple_calls.spec.js):

  • ✅ Initial load without errors
  • ✅ Single dynamic addition
  • ✅ Multiple dynamic additions (3 components)
  • ✅ DOM node replacement scenario
  • ✅ Multiple DOM replacements
  • ✅ Console error monitoring with proper filtering

Manual Test Page (manual_render_test.html.erb):

  • ✅ Clear instructions for QA
  • ✅ Visual feedback for both scenarios
  • ✅ Real-time hydration error detection
  • ✅ Tests both addition and replacement

4. Performance Benefits

  • Map.has() and Map.get() are O(1) operations
  • Early return prevents unnecessary React element creation
  • For apps calling reactOnRailsPageLoaded() N times with M components, this eliminates (N-1) × M unnecessary re-renders
  • Proper cleanup prevents memory leaks

5. Clean Architecture

  • Leverages existing renderedRoots Map (line 15) - no new data structures
  • Integrates with existing cleanup via onPageUnloaded (line 223)
  • Follows existing patterns for React 18/legacy compatibility

🔍 Code Quality Observations

ClientRenderer.ts

Line 87: Smart isConnected Check

const sameNode = existing.domNode === domNode && existing.domNode.isConnected;

Excellent use of .isConnected to verify the DOM node is still in the document. This prevents skipping components whose nodes were removed but not replaced.

Lines 96-105: Dual Unmount Support

if (supportsRootApi && existing.root && typeof existing.root === 'object' && 'unmount' in existing.root) {
  existing.root.unmount();
} else {
  unmountComponentAtNode(existing.domNode);
}

Proper handling of both React 18+ Root API and legacy unmount. The type guards are appropriate.

Lines 106-111: Graceful Error Handling

catch (unmountError) {
  // Ignore unmount errors for replaced nodes
  if (trace) {
    console.log(`Error unmounting replaced component: ${name}`, unmountError);
  }
}

Smart to swallow unmount errors here - if the DOM node was replaced, unmounting the old node may legitimately fail, and we want to proceed with rendering to the new node.

Lines 120-122: Updated Comment
The comment correctly explains why the innerHTML check now works - it's the combination of the skip logic above that makes this accurate.

Unit Tests

Line 258: Mock Retrieval Pattern

const mockHydrateOrRender = require('../src/reactHydrateOrRender.ts').default as jest.Mock;

This works but could be slightly cleaner with jest.clearAllMocks() in beforeEach to ensure pristine state. Current approach is functional though.

Line 265: Call Count Verification

expect(mockHydrateOrRender.mock.calls.length).toBe(callCountAfterFirstRender);

This correctly verifies the mock wasn't called again. Could be slightly more explicit with toHaveBeenCalledTimes() but current approach is fine.

E2E Tests

Lines 62-68: Comprehensive Error Filtering

if (text.toLowerCase().includes('hydrat') || 
    text.toLowerCase().includes('did not match') || 
    text.toLowerCase().includes('mismatch'))

Excellent filtering for hydration-specific errors. This prevents false positives from other console errors.

Line 197: Hard-coded Timeout

await page.waitForTimeout(200);

This is a minor flake risk. Consider using page.waitForFunction() or checking for a specific DOM state instead. However, for a 200ms timeout this is likely acceptable.


⚠️ Required Action Items

🚨 HIGH PRIORITY: Missing CHANGELOG Entry

Per CLAUDE.md requirements:

Update /CHANGELOG.md for open-source features, bug fixes, breaking changes, deprecations, and performance improvements using format: [PR 1818](https://github.com/shakacode/react_on_rails/pull/1818) by [username](https://github.com/username)

This is a user-visible bug fix that should be documented.

Suggested entry for the Unreleased section under #### Fixed:

#### Fixed
- Fixed hydration mismatch errors when `reactOnRailsPageLoaded()` is called multiple times for asynchronously loaded content. Previously rendered client-side components are now correctly skipped during subsequent calls, preventing React from attempting to hydrate already-rendered content. Includes proper handling of DOM node replacement scenarios (same ID, different element). [PR 2211](https://github.com/shakacode/react_on_rails/pull/2211) by [justin808](https://github.com/justin808). Fixes [#2210](https://github.com/shakacode/react_on_rails/issues/2210).

💡 Optional Enhancements (Low Priority)

1. Extract Unmount Logic (Refactoring Opportunity)

The unmount pattern is duplicated at lines 96-105 and 205-214. Consider extracting to a helper:

function unmountRoot(entry: { root: RenderReturnType; domNode: Element }): void {
  if (supportsRootApi && entry.root && typeof entry.root === 'object' && 'unmount' in entry.root) {
    entry.root.unmount();
  } else {
    unmountComponentAtNode(entry.domNode);
  }
}

This is a nice-to-have, not blocking.

2. JSDoc Enhancement

Consider adding a note to the reactOnRailsPageLoaded JSDoc about the skip behavior:

/**
 * Public API function that can be called to render components after they are loaded.
 * Safe to call multiple times - already-rendered components will be skipped.
 * 
 * @note Components are tracked and won't re-render on subsequent calls unless 
 *       the DOM node is replaced. To force re-render with new props, use
 *       reactOnRailsComponentLoaded(domId) on individual components.
 */

🔒 Security & Performance

Security

✅ No security concerns identified. The changes:

  • Don't introduce XSS risks
  • Don't expose internal state
  • Properly sanitize/validate inputs

Performance

Positive performance impact:

  • Map.has() and Map.get() are O(1) operations - no overhead
  • Early return prevents unnecessary React element creation and rendering
  • Estimated improvement: For apps calling reactOnRailsPageLoaded() N times with M components, eliminates (N-1) × M unnecessary re-renders
  • Proper cleanup prevents memory leaks

📋 Pre-Merge Checklist

Per CLAUDE.md requirements:

  • Code follows project conventions
  • TypeScript typing is correct
  • Tests are comprehensive (unit + E2E + manual)
  • Manual testing completed (verified by user with screenshots)
  • CHANGELOG.md updatedAction required
  • bundle exec rubocop passes (assumed from CI)
  • Files end with newline (assumed from CI)
  • Prettier formatting (assumed from CI)

🎉 Final Recommendation

LGTM - Approve after CHANGELOG entry is added

This is high-quality work that:

  • ✅ Correctly diagnoses and fixes the root cause
  • ✅ Has minimal code footprint (37 production lines)
  • ✅ Includes comprehensive tests (unit + E2E + manual)
  • ✅ Handles edge cases thoughtfully (DOM replacement)
  • ✅ Has no breaking changes or security issues
  • ✅ Improves performance
  • ✅ User has manually verified the fix

Risk Assessment: Low

  • Changes are defensive (adds guards)
  • Comprehensive test coverage reduces regression risk
  • No API changes
  • Backward compatible

Action Item: Add CHANGELOG.md entry, then merge with confidence!

Great work on this fix! 🚀


Additional Context

Comparison with CodeRabbit Reviews:
CodeRabbit's reviews correctly identified the DOM replacement edge case, which this PR handles excellently. The implementation addresses all their concerns:

  • ✅ Guards against "same id, new DOM node" scenario
  • ✅ Unmounts old roots properly
  • ✅ No memory leaks
  • ✅ Comprehensive test coverage for the edge case

User Verification:
The user manually tested the fix and provided screenshots showing:

  • Before fix: Hydration mismatch errors in console
  • After fix: No errors, components render correctly

This gives additional confidence beyond the automated tests.


@justin808 justin808 force-pushed the jg/fix-2210-rsc-test branch from 941d195 to df482fc Compare December 13, 2025 03:09
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 13, 2025

Code Review - PR #2211

Overall Assessment: ✅ LGTM with minor suggestions

This is a well-thought-out fix for Issue #2210. The implementation correctly addresses the hydration mismatch problem while also handling the edge case of replaced DOM nodes.


Strengths

1. Correct Root Cause Analysis

The fix properly identifies that reactOnRailsPageLoaded() was re-hydrating already-rendered components because !!domNode.innerHTML was true for client-rendered content. The solution of tracking rendered roots prevents this.

2. Handles Edge Case Well

The CodeRabbit feedback about replaced DOM nodes (same ID, new element) is correctly handled:

  • Checks if it's the exact same DOM node instance via reference equality
  • Checks if the node is still connected to the document (isConnected)
  • Properly unmounts old root before rendering to the new node
  • This prevents memory leaks from orphaned React roots

3. Excellent Test Coverage

  • Unit tests verify the core logic (skip already-rendered, render replaced nodes)
  • E2E tests cover multiple scenarios comprehensively
  • Manual test page provides easy verification during development
  • Tests are well-documented with clear intent

4. Good Code Quality

  • Clear comments explaining the logic
  • Defensive error handling (try/catch for unmount)
  • Trace logging for debugging
  • Follows existing code patterns

🔍 Security & Performance Considerations

Security: ✅ No Concerns

  • No XSS risks introduced
  • Proper JSON parsing with type safety
  • No new external dependencies
  • Standard DOM API usage

Performance: ✅ Good

  • renderedRoots.get() is O(1) - minimal overhead
  • Only runs when reactOnRailsPageLoaded() is called (not on every render)
  • Proper cleanup prevents memory leaks
  • Early return for already-rendered components avoids unnecessary work

💡 Suggestions for Improvement

1. Minor: Type Safety in Unmount Check (Low Priority)

Current code (lines 96-100):

if (
  supportsRootApi &&
  existing.root &&
  typeof existing.root === 'object' &&
  'unmount' in existing.root
) {

Suggestion: The type checking is a bit verbose. Consider using a type guard:

function isReactRoot(root: RenderReturnType): root is ReactRoot {
  return supportsRootApi && root !== null && typeof root === 'object' && 'unmount' in root;
}

// Then use:
if (isReactRoot(existing.root)) {
  existing.root.unmount();
} else {
  unmountComponentAtNode(existing.domNode);
}

Impact: Minor readability improvement, not critical.


2. Minor: Consider Logging Cleanup (Low Priority)

Current code: Only logs errors during unmount, not successful cleanup.

Suggestion: Add trace logging for the cleanup path to help debugging:

if (trace) {
  console.log(`Cleaning up replaced component: \${name} (dom id: \${domNodeId})`);
}

Impact: Better observability during development.


3. Consider: Edge Case Documentation (Documentation)

The logic handles an important edge case (replaced nodes), but it might not be obvious to future maintainers why we check isConnected.

Suggestion: Add a comment explaining the edge case:

// Only skip if it's the exact same DOM node and it's still connected to the document
// If the DOM node was replaced (e.g., via innerHTML or async HTML injection), 
// we need to unmount the old root and render to the new node to prevent:
// 1. Memory leaks from orphaned React roots
// 2. Components not rendering when legitimately needed
const sameNode = existing.domNode === domNode && existing.domNode.isConnected;

🎯 Specific Code Review Notes

ClientRenderer.ts:87-93

The reference equality check (existing.domNode === domNode) is correct—it checks object identity, not just ID matching.

ClientRenderer.ts:106-111

Swallowing unmount errors for replaced nodes is appropriate—the old node is likely already detached and unable to unmount cleanly.

ClientRenderer.ts:141

Storing both root and domNode in the Map is necessary for the replacement check—good design.

Test Coverage

The test suite covers:

  • ✅ Skip already-rendered components
  • ✅ Render to replaced DOM nodes
  • ✅ Multiple consecutive additions
  • ✅ Multiple consecutive replacements
  • ✅ No hydration errors in any scenario

📋 Testing Recommendations

Before merge, verify:

  1. Manual testing: Visit /manual_render_test and:

    • Click "Add Component" 3-5 times → Should see no console errors
    • Click "Replace Component" 3-5 times → Should see success messages
    • Check browser console for any hydration warnings
  2. Regression testing: Ensure existing SSR components still hydrate correctly

  3. CI status: All checks passing (currently in progress)


🚀 Conclusion

This is a high-quality fix that:

  • ✅ Solves the reported issue correctly
  • ✅ Handles edge cases properly
  • ✅ Has comprehensive test coverage
  • ✅ Maintains backward compatibility
  • ✅ No security or performance concerns

The suggestions above are minor improvements, not blockers. The PR is ready to merge as-is.

Recommendation: Approve & Merge


Reviewed using CLAUDE.md guidelines for code quality, testing, and best practices.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Dec 13, 2025

Code Review: Fix Hydration Mismatch on Multiple reactOnRailsPageLoaded() Calls

Overall Assessment: This is a well-implemented fix with excellent test coverage. The solution correctly addresses the root cause and handles edge cases thoughtfully.


Strengths

1. Root Cause Analysis

The fix correctly identifies that the issue stems from shouldHydrate = !!domNode.innerHTML returning true for previously client-rendered components. The solution (checking renderedRoots Map) is clean and efficient.

2. Excellent Test Coverage

  • Unit tests: Cover both the skip scenario and the DOM replacement edge case
  • E2E tests: Comprehensive Playwright tests with multiple scenarios including initial render, multiple dynamic additions, DOM node replacement, and console error detection
  • Manual test page: Provides visual verification at /manual_render_test

3. Edge Case Handling

The DOM node replacement logic is particularly well done - existing.domNode === domNode && existing.domNode.isConnected correctly distinguishes between same render calls (skip) vs replaced DOM nodes (clean up old root, render to new node).

4. Cleanup on Replacement

Proper unmounting of the old root when a DOM node is replaced prevents memory leaks, with support for both React 18+ (createRoot API) and React 17- (render API).


Observations & Minor Suggestions

1. TypeScript Safety in Unmount Check

The unmount check is defensive but could benefit from a comment explaining it handles both React 18+ and React 17- gracefully.

2. Performance Consideration

The renderedRoots Map grows with every component render. In long-running SPAs, this could accumulate entries for components no longer on the page.

Minor concern: If someone programmatically removes components without calling React unmount, entries will leak. However, this is an edge case and the current implementation is fine for typical usage.

Future enhancement: Consider cleanup on page unload or provide a manual cleanup API for SPAs with dynamic content.

3. Test Quality Note

The unit tests verify mock call counts rather than actual DOM state changes. While this works, the E2E tests compensate by testing actual browser behavior, so overall coverage is excellent.


Security & Performance

  • Security: No concerns - no user input handling or external data processing
  • Performance: Minimal overhead (just a Map lookup). Skip logic actually improves performance by preventing unnecessary re-renders
  • Memory: Minor consideration about Map growth (see above), but not a blocker

Test Coverage: Excellent

Unit Tests: Skip already-rendered components, Re-render when DOM node replaced

E2E Tests: Initial render, Multiple reactOnRailsPageLoaded() calls, Multiple dynamic additions, DOM replacement, Console error detection


Potential Issues: None Found

The implementation correctly handles:

  1. Preventing duplicate renders of the same component
  2. Allowing re-renders when DOM nodes are replaced
  3. Cleaning up old roots to prevent memory leaks
  4. Supporting both React 17- and React 18+ APIs

Recommendation: APPROVE

This PR is ready to merge pending CI passing.

Minor follow-ups (optional, can be done in future PRs):

  1. Add comment explaining React 17/18 compatibility in unmount check
  2. Consider explicit cleanup for long-running SPAs if Map growth becomes an issue
  3. Document memory characteristics of renderedRoots Map

Great work on the thorough investigation and implementation!

@justin808 justin808 merged commit 4615dd2 into master Dec 13, 2025
25 of 26 checks passed
@justin808 justin808 deleted the jg/fix-2210-rsc-test branch December 13, 2025 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hydration mismatch with React 19 and the latest beta version of this gem

1 participant