Skip to content

Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide#3933

Merged
justin808 merged 28 commits into
mainfrom
jg/3892-react19-root-error-callbacks
Jun 15, 2026
Merged

Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide#3933
justin808 merged 28 commits into
mainfrom
jg/3892-react19-root-error-callbacks

Conversation

@justin808

@justin808 justin808 commented Jun 11, 2026

Copy link
Copy Markdown
Member

Fixes #3892.

Summary

ReactHydrateOptions was typed end-to-end but never constructed: the OSS ClientRenderer called reactHydrateOrRender(domNode, element, shouldHydrate) with no options, so apps could not observe recoverable hydration errors at all. This PR adds a public global registration API for React's root error callbacks and wires it into every hydrateRoot/createRoot call React on Rails makes:

ReactOnRails.setOptions({
  rootErrorHandlers: {
    onRecoverableError: (error, errorInfo, context) => { /* hydration mismatches & friends */ },
    onCaughtError: (error, errorInfo, context) => { /* error-boundary catches (React 19) */ },
    onUncaughtError: (error, errorInfo, context) => { /* unboundaried errors (React 19) */ },
  },
});
  • Enriched context: each callback receives React's (error, errorInfo) plus { componentName, domNodeId } for the affected root.
  • Dev-mode default: when hydrating in Rails development, recoverable hydration errors get an actionable supplemental [ReactOnRails] line (component name, dom id, component stack, link to the new debugging guide) while React's default reporting stays intact — each error is default-reported exactly once (reportError, so dev overlays and window-'error' tooling keep firing): by Pro's internal handler on chained paths, by the core dev logger otherwise. In other environments nothing is attached unless registered.
  • Snapshot semantics: handlers are captured when each root is created (a root callback permanently replaces React's default reporting for that root, so wrappers must never decay into silent no-ops after resetOptions).
  • Merge semantics: partial rootErrorHandlers updates merge per key (registering only onCaughtError later keeps an earlier onRecoverableError); an explicit undefined clears a single key; resetOptions clears all.
  • Legacy React: graceful no-op + one-time console.warn (React <18 for everything; React 18 supports onRecoverableError only).
  • Pro composition: on RSC-wrapped hydrate paths (ClientSideRenderer, wrapServerComponentRenderer/client), the user callback is CHAINED after Pro's internal handleRecoverableError — both always run; the internal handler is never clobbered. The RSCRoute ssr:false bailout signal (Pro control flow, not an app error) is filtered before both so it never reaches user error reporters. Pro's identifierPrefix client mirror verified untouched.
  • New guide: Debugging Hydration Mismatches in Rails — Rails-specific cause catalog (Time.current/timezones, current_user-conditional ERB, I18n locale drift, CSRF-token props, asset hosts, nondeterministic render) with fix patterns (props-not-ERB, client-only rendering, narrowly-scoped suppressHydrationWarning), callback registration incl. a Sentry example, and the double-reporting precedence note (onCaughtError vs error boundaries). Added to docs/sidebars.ts.

Descoped (phase 2)

Test plan

  • OSS Jest (packages/react-on-rails/tests/):
    • rootErrorHandlers.test.ts — version gating (16/17/18/19) with warnings, dev-default attach rules, dev-log-before-user ordering, sync-throw and async-rejection isolation, snapshot-on-reset regression.
    • rootErrorCallbacks.test.tsx — real react-dom 19: forced hydration mismatch invokes user onRecoverableError with enriched context; branded dev message with guide link; createRoot path invokes onUncaughtError; error boundary path invokes onCaughtError.
    • ReactOnRails.test.jsx — public setOptions/resetOptions/validation wiring.
  • Pro Jest (packages/react-on-rails-pro/tests/): chaining verified (user callback AND internal handler both fire; bailout suppressed from both; non-RSC paths get user callbacks without the internal handler); new wrapServerComponentRendererClient.errorCallbacks.test.tsx for the RSC client wrapper; full Pro suite incl. RSC/streaming stays green (280 + 17 tests).
  • Dummy e2e (react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/root_error_callbacks.spec.js): a server-rendered component with a forced mismatch fires onRecoverableError with {componentName, domNodeId}; a client-rendered component throwing during render fires onUncaughtError. Passing on chromium, firefox, and webkit.

Validation

  • pnpm run build / pnpm run lint / pnpm run type-check / pnpm start format.listDifferent — all pass
  • OSS Jest 203/203; Pro Jest 281/281 + RSC 17/17
  • (cd react_on_rails && BUNDLE_GEMFILE=../Gemfile bundle exec rubocop) — no offenses
  • Dummy RSpec (system + requests + helpers): 177 examples, 0 failures; dummy Jest 14/14
  • Playwright e2e: full suite 63/63 across 3 browsers (Playwright does not auto-run on PRs; run locally per .claude/docs/playwright-e2e-testing.md)
  • script/check-docs-sidebar origin/main — pass; lychee link check — 0 errors
  • Known local-only failure: Pro node-renderer streaming suites need the gitignored react_on_rails_pro/spec/dummy/ssr-generated/server-bundle.js build artifact (absent in a fresh worktree); unrelated to this diff (no node-renderer code touched), 26/30 node-renderer suites pass.
  • Codex review (codex review --base origin/main): three passes; the first two findings (async-rejection isolation P3, handler-snapshot P2) accepted and fixed with regression tests; the final pass on the review-fix diff was clean.
  • /simplify (pinned claude-opus-4-8): one reuse finding accepted and applied in a6886795 (Pro's local reportRecoverableError was byte-identical to OSS defaultReportRecoverableError; now a single exported implementation); all other findings verified false-positive or clarity-neutral and skipped. Targeted re-validation green (OSS 24/24, Pro 30/30, build/type-check/lint/format clean).
  • Claude Code review pass (local, report-only): 6 findings — 4 accepted and fixed in d9169a68 (dev logger forwards errorInfo/component stack and preserves default reporting; no double console dump on the Pro chained dev path via defaultReportingHandledInternally; getRootErrorHandlers() returns a copy; isThenable extracted to a shared @internal module), 2 rejected as speculative/structural (see decision log). Cursor Bugbot's partial-update merge-semantics finding fixed in the same commit.

Codex Decision Log

  • Non-blocking: API shape for registering callbacks

    • Decision: setOptions({ rootErrorHandlers: {...} }) per the issue suggestion; handlers stored in react-on-rails/@internal/rootErrorHandlers module state (mirrors @internal/rendererTeardown precedent) so OSS and Pro renderers share it without import cycles.
    • Why: fits the existing options surface and validation style.
    • Review later: name bikesheddable pre-merge.
  • Non-blocking: Pro chaining order and the RSCRoute ssr:false bailout

    • Decision: bailout filter → internal reportError → user callback. The bailout digest is filtered from BOTH handlers.
    • Why: the bailout is deliberate Pro control flow, not an app error — letting it hit user Sentry callbacks would be noise Pro itself suppresses.
    • Review later: maintainers confirm filtering the bailout from user callbacks is desired (spirit-of-the-rule deviation from a literal "both must run").
  • Non-blocking: dev-default scope

    • Decision: branded recoverable-error log attaches only when hydrating AND railsContext.railsEnv === 'development'; nothing is attached in other envs when no handler is registered.
    • Why: actionable DX without swallowing or rewrapping prod error reporting.
    • Review later: whether test env should also get the branded log.
  • Non-blocking: snapshot semantics

    • Decision: roots keep the handlers they were created with; re-registration affects only later roots (codex P2 fix).
    • Why: a root callback permanently replaces React's default reporting for that root; lazily re-reading registration would silently swallow errors after resetOptions.
    • Review later: None.
  • Non-blocking: deprecated base/client.ts mirrored

    • Decision: same option accepted in the deprecated base layer.
    • Why: shared app code calling setOptions({rootErrorHandlers}) must not throw "Invalid options" on old Pro.
    • Review later: None.
  • Non-blocking: .lychee.toml exclusions

    • Decision: guide URL added to the planned-deployments exclusions (live after docs deploy); pull/XXXX changelog placeholder excluded until PR-number substitution.
    • Why: the pre-push hook runs online lychee; both URLs are by-design 404 until deploy/substitution.
    • Review later: drop the |XXXX alternation after substitution (coordinator handles).
  • Non-blocking: single-report rule for dev-mode recoverable errors (review-fix batch)

    • Decision: each recoverable error is default-reported exactly once in development — by Pro's internal handler on chained paths (which passes defaultReportingHandledInternally: true to buildRootErrorCallbackOptions), by the core defaultReportRecoverableError (reportErrorconsole.error fallback, mirroring React's default) otherwise. The branded line never dumps the error object; it is purely supplemental context.
    • Why: fixes two review findings at once — lost componentStack/silenced window-'error' tooling, and double console dumps on the Pro chained dev path.
    • Review later: the dev default-report also fires when a user onRecoverableError is registered (dev-only; production follows React's own replace semantics). Maintainers could opt to suppress it when a user callback exists.
  • Non-blocking: partial rootErrorHandlers updates merge per key (Cursor Bugbot finding)

    • Decision: setRootErrorHandlers merges; explicit undefined clears one key; option('rootErrorHandlers') stores the effective merged snapshot.
    • Why: consistent with the independence of other top-level options; prevents silent loss of earlier registrations.
    • Review later: None.
  • Non-blocking: two Claude-review findings rejected as code changes

    • Decision: (a) did not widen the RSC ssr:false bailout filter to non-RSC paths — unreachable today (RSCRoute requires an RSCProvider ancestor), convention-enforced invariant; (b) did not centralize option-building inside reactHydrateOrRender — structural refactor across six correct, unit-covered call sites.
    • Why: both are speculative hardening with real churn risk late in the cycle.
    • Review later: both are phase-2 candidates alongside the per-component override.

Labels: full-ci — touches SSR/hydration behavior and the Pro/core boundary (Pro recoverable-error chaining), which is explicitly listed as a full-CI trigger.

🤖 Generated with Claude Code


Note

Medium Risk
Changes global hydration and root error reporting across OSS and Pro RSC paths; incorrect chaining or double-reporting could affect dev tooling and production error monitors, though behavior is heavily tested.

Overview
Adds ReactOnRails.setOptions({ rootErrorHandlers }) so apps can register React 18/19 root error callbacks (onRecoverableError, onCaughtError, onUncaughtError) globally. A new rootErrorHandlers module validates and merges registrations per key, wraps callbacks with optional componentName / domNodeId, and feeds buildRootErrorCallbackOptions into every OSS hydrateRoot/createRoot path (ClientRenderer, render, public reactHydrateOrRender). In Rails development, hydrating roots also get a supplemental [ReactOnRails] hydration-mismatch log (with guide link and component stack) while preserving a single default reportError per error.

React on Rails Pro chains user onRecoverableError after its internal recoverable handler on RSC hydrate paths (ClientSideRenderer, wrapServerComponentRenderer), filters the RSCRoute ssr: false bailout from both, and fixes cyclic cause chains in that filter. Shared isThenable and @internal/rootErrorHandlers exports support Pro without duplicating logic.

Documentation adds Debugging Hydration Mismatches in Rails, updates the JavaScript API and changelog, dummy Playwright coverage, and CI tweaks (bundle-size skip note, lychee exclusions for the new doc URL and changelog PR placeholders).

Reviewed by Cursor Bugbot for commit 72decd2. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Add global React root error callbacks via ReactOnRails.setOptions({ rootErrorHandlers }) applied to all hydrate/create roots; callbacks receive enriched context and merge per-key; Pro builds chain with internal recoverable-error handling.
  • Documentation

    • New “Debugging Hydration Mismatches in Rails” guide; updated API docs and changelog describing usage, React-version behavior, and troubleshooting.
  • Tests

    • Added unit, integration, and e2e coverage for root error callbacks and hydration-mismatch scenarios.
  • Chores

    • CI/config: exclude an extra docs URL and allow PR-link placeholder in checks.

Merge Readiness Criteria

Current evaluation as of 2026-06-15 for head 72decd2979a3b036f248c9a889d2b1d5fa9f2e15.

  • Release mode: accelerated-rc, confirmed live from canonical release gate Release gate: react_on_rails 17.0.0 #3823 (Mode: accelerated-rc).
  • Current head SHA: 72decd2979a3b036f248c9a889d2b1d5fa9f2e15.
  • CI/check status: Current-head gh pr checks 3933 --repo shakacode/react_on_rails --json bucket reports 49 passing checks and 7 expected path-selected/skipped checks; no pending or failing checks. mergeStateStatus is CLEAN.
  • Review-thread status: Paginated GraphQL review-thread sweep reports 94 total threads and unresolved=0.
  • Review feedback triage: Four fresh Claude threads were handled on the latest docs pass. One API-reference docs item was fixed; three optional code-churn items were replied to with [auto-deferred] rationale and resolved to avoid restarting the final-candidate gate.
  • Validation run: node script/generate-llms-full.mjs -> pass; node script/generate-llms-full.mjs --check -> pass; script/check-docs-sidebar -> pass; pnpm start format.listDifferent -> pass; bin/check-links docs/oss/api-reference/javascript-api.md -> pass; git diff --check --no-ext-diff -> pass; (cd react_on_rails && BUNDLE_GEMFILE=../Gemfile bundle exec rubocop) -> pass/no offenses; pre-commit and pre-push hooks -> pass.
  • Label/CI decision: Labels: full-ci. This remains appropriate because the PR touches React 19 root error callback behavior and related docs; no benchmark label recommended.
  • Known residual risk: No unresolved review thread or failing check remains. Accelerated-RC independent finalizer/confidence gate is not satisfied by this author/coordinator session.
  • Merge recommendation: Ready for maintainer/independent finalizer. Do not auto-merge from this session under accelerated-RC finalizer rules.

Agent Merge Confidence

Mode: accelerated-rc
Current head SHA: 72decd2
Score: 8/10
Auto-merge recommendation: no — the change appears merge-ready, but this block was prepared by @justin808/Codex and still needs an independent finalizer before accelerated-RC auto-merge.
Affected areas: React 19 root error callbacks, hydration/recoverable-error handling, Pro RSC client reporting, dummy app coverage, JavaScript API docs, error-reference docs.
CI detector: script/ci-changes-detector origin/main from a detached worktree at this head -> JavaScript/TypeScript code, dummy app, Pro JavaScript/TypeScript, uncategorized safety path; recommended lint, RSpec, JS unit tests, dummy integration, generator tests, Playwright E2E, Pro lint/RSpec/dummy/node-renderer, and benchmark workflows.
Validation run:

  • GitHub checks for this head -> 49 pass, 7 skipped, 0 failed, 0 pending, 0 cancelled.
  • gh/GraphQL review-thread sweep -> 94 total review threads, 94 resolved, 0 unresolved.
  • claude-review, Cursor Bugbot, CodeRabbit, CodeQL, docs/sidebar/link/error-reference, llms-full, package, gem, dummy, generator, Pro, and integration checks are complete for this head.
    Review/check gate:
  • GitHub checks: complete for 72decd2; skipped checks are non-required workflow branches such as benchmark confirmation/no-op paths, docs-format no-op, and the manual Claude command wrapper while the required claude-review check passed.
  • Review threads: gh/GraphQL unresolved count is 0.
  • Current-head reviewer verdicts:
    • Claude review: complete for 72decd2, no unresolved blocker threads.
    • Cursor Bugbot: complete for 72decd2.
    • CodeRabbit: approved/complete for 72decd2.
      Known residual risk: moderate surface area across React 18/19 callback behavior and Pro RSC reporting, but current-head CI and review coverage did not leave a confirmed blocker.
      Finalized by: PENDING independent finalizer. This draft was added by @justin808 via Codex; @justin808 is also the PR author, so this does not satisfy the independent-finalizer rule.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds global rootErrorHandlers types and registry, builds per-root callback options passed into React roots, wires registration into setOptions/resetOptions, composes Pro internal and user recoverable-error handlers (with RSCRoute bailout suppression), adds unit/integration/Pro tests and dummy-app E2E, and updates docs and tooling config.

Changes

Root Error Callbacks Implementation

Layer / File(s) Summary
Root error types & registration
packages/react-on-rails/src/types/index.ts, packages/react-on-rails/src/rootErrorHandlers.ts
Adds RootErrorContext/RootErrorHandler/RootErrorHandlers, module-level setRootErrorHandlers/getRootErrorHandlers/resetRootErrorHandlers, defaultReportRecoverableError, safeInvoke, and buildRootErrorCallbackOptions with dev-mode hydration logging.
Version detection & utilities
packages/react-on-rails/src/reactApis.cts, packages/react-on-rails/src/isThenable.ts, packages/react-on-rails/package.json
Exports supportsReact19RootErrorCallbacks, introduces isThenable helper, and adds internal package export subpaths for these utilities.
Core render path integration
packages/react-on-rails/src/ClientRenderer.ts, packages/react-on-rails/src/base/client.ts, packages/react-on-rails/src/capabilities/core.ts
Wires rootErrorHandlers into setOptions/resetOptions, builds per-root callback options (componentName/domNodeId/hydrate) and passes them into reactHydrateOrRender.
Pro RSC recoverable-error chaining
packages/react-on-rails-pro/src/handleRecoverableError.client.ts, packages/react-on-rails-pro/src/ClientSideRenderer.ts, packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx
Adds chainRecoverableErrorHandlers, replaces local thenable guard with shared isThenable, integrates per-root options, and filters RSCRoute ssr=false bailout markers while chaining internal and user handlers.
Unit, integration, and Pro tests
packages/react-on-rails/tests/*, packages/react-on-rails-pro/tests/*, react_on_rails/spec/dummy/*, react_on_rails/spec/dummy/e2e/*
Adds Jest suites and Playwright E2E validating registration/merge/reset, React-version behavior, dev hydration logging, user-callback isolation, Pro chaining, and dummy-app event recording.
Docs, sidebars, and CI config
docs/oss/building-features/debugging-hydration-mismatches.md, docs/oss/api-reference/javascript-api.md, docs/sidebars.ts, CHANGELOG.md, .lychee.toml, .bundle-size-skip-branch
New debugging guide, API doc updates, changelog entry, sidebar addition, lychee exclusion and PR-pattern tweak, llms mirror updates, and bundle-size skip branch update.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

enhancement, review-needed, documentation, full-ci, P2

Suggested reviewers

  • AbanoubGhadban
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: exposing React 19 root error callbacks (rootErrorHandlers) and adding a hydration-mismatch debugging guide.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg/3892-react19-root-error-callbacks

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.

Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
justin808 added a commit that referenced this pull request Jun 11, 2026
…ed isThenable

Review fixes from the Claude Code review pass and Cursor Bugbot on PR #3933:

1. Dev-mode hydration logger no longer swallows React's default
   reporting or the component stack. The error is default-reported
   exactly once (reportError, falling back to console.error — mirroring
   React's own default) so window-'error' tooling keeps working, and the
   branded line now includes errorInfo.componentStack.
2. New buildRootErrorCallbackOptions option
   defaultReportingHandledInternally: Pro's RSC-wrapped hydrate paths
   pass it so the chained internal handler remains the single default
   reporter and the dev logger emits only its supplemental branded line
   (no second full error dump in development). Added a Pro test covering
   the chained dev path with railsEnv development.
3. getRootErrorHandlers now returns a snapshot copy so callers cannot
   mutate the internal registration and bypass validation.
4. Extracted the three byte-identical isThenable copies into
   packages/react-on-rails/src/isThenable.ts (exported as
   react-on-rails/@internal/isThenable) and removed the MUST-SYNC
   thenable-guard duplication note from the Pro renderer.
5. Partial rootErrorHandlers updates now merge per key (consistent with
   the other setOptions keys): setting only one callback keeps the
   others; an explicit undefined clears just that key; resetOptions
   still clears all. setOptions stores the merged result so
   option('rootErrorHandlers') reflects the effective registration.

Docs (guide, JS API reference, type JSDoc) and the changelog entry
updated to describe the preserved default reporting, component stack,
and merge semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@justin808 justin808 marked this pull request as ready for review June 11, 2026 22:33
@justin808

Copy link
Copy Markdown
Member Author

+ci-run-full

Final readiness gate: local validation, codex review (3 passes, final clean), Claude Code review pass (4 findings fixed, 2 rejected with decision-log entries), Cursor Bugbot finding fixed, and /simplify (one reuse fix applied) are all complete for head a688679. Requesting full CI because this PR touches SSR/hydration behavior and the Pro/core boundary — path-selected CI is not sufficient evidence for this surface.

@greptile-apps

greptile-apps Bot commented Jun 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a global ReactOnRails.setOptions({ rootErrorHandlers }) API exposing React 18/19's root error callbacks (onRecoverableError, onCaughtError, onUncaughtError) to application code, with each invocation enriched with { componentName, domNodeId } context. A dev-only branded hydration-mismatch logger is also added, backed by a new debugging guide.

  • Core change: packages/react-on-rails/src/rootErrorHandlers.ts is a new module with snapshot-at-root-creation semantics; handlers are captured when each root is created rather than re-read dynamically, preventing silent error swallowing after resetOptions.
  • Pro chaining: on the RSC-wrapped hydrate path, user onRecoverableError is chained AFTER Pro's internal handler (never clobbered), with the RSCRoute ssr:false bailout filtered from both.
  • Docs + tests: new debugging-hydration-mismatches.md guide, unit tests for every version-gating branch, integration tests with real react-dom 19, and Playwright e2e coverage.

Confidence Score: 4/5

The change is additive on the critical hydrate/render path and the core logic (snapshot semantics, Pro chaining, bailout filtering) is correct and thoroughly tested. The only issues found are a missing deduplication guard for the version-incompatibility warning and a stylistic inconsistency in base/client.ts; neither affects correctness or error recovery behavior.

The new rootErrorHandlers module is well-designed with proper capture-at-creation semantics, safe async handler invocation, and correct Pro chaining. The dev logger avoids double reporting via defaultReportingHandledInternally. The version-incompatibility warning lacks the one-time guard claimed in the docs, meaning incremental setOptions calls on older React could emit repeated warnings, but this is purely a developer-experience issue. All behavioral paths have unit, integration, and e2e coverage.

packages/react-on-rails/src/rootErrorHandlers.ts — the versionWarningEmitted deduplication is missing; packages/react-on-rails/src/base/client.ts — parameter-mutation pattern is inconsistent with capabilities/core.ts

Important Files Changed

Filename Overview
packages/react-on-rails/src/rootErrorHandlers.ts New core module: snapshot-at-creation semantics, per-key merge, safeInvoke for async handlers, dev logger, correct React version gating. Well-structured and covered by unit tests.
packages/react-on-rails-pro/src/handleRecoverableError.client.ts Refactored: handleRecoverableError is now derived from the new chainRecoverableErrorHandlers export, preserving the RSCBailout filter and default reporting while enabling user-callback chaining.
packages/react-on-rails-pro/src/ClientSideRenderer.ts Wires buildRootErrorCallbackOptions into every render/hydrate call; on the RSC-wrapped hydrate path chains user callback after Pro's internal handler with defaultReportingHandledInternally flag to prevent double reporting.
packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx Correctly applies user error callbacks to both hydrateRoot and createRoot paths; chains onRecoverableError with Pro's internal handler on the hydrate path only.
packages/react-on-rails/src/base/client.ts Adds rootErrorHandlers to setOptions/resetOptions; uses delete newOptions.rootErrorHandlers + eslint-disable pattern, slightly inconsistent with capabilities/core.ts which uses destructuring.
packages/react-on-rails/src/capabilities/core.ts Mirrors base/client.ts rootErrorHandlers handling using cleaner destructuring approach; parity with base/client.ts is maintained.
packages/react-on-rails/src/types/index.ts Adds RootErrorContext, RootErrorHandler, RootErrorHandlers, and rootErrorHandlers option to ReactOnRailsOptions — clean, well-documented type additions.
packages/react-on-rails/src/isThenable.ts New @internal shared helper extracting the duplicate isThenable guard from ClientRenderer and Pro ClientSideRenderer into a single source of truth.
packages/react-on-rails/tests/rootErrorHandlers.test.ts Comprehensive unit tests covering all React version branches, merge semantics, snapshot semantics, async rejection isolation, and dev-mode logger ordering.
packages/react-on-rails/tests/rootErrorCallbacks.test.tsx Integration tests using real react-dom 19: forced hydration mismatch, client render throw, and error boundary paths all verified with enriched context.
packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts New tests verify chaining invariants: both handlers fire, RSCBailout is suppressed from both, dev path emits exactly one default report, non-RSC path uses user callbacks directly.
react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/root_error_callbacks.spec.js E2E Playwright tests verify onRecoverableError and onUncaughtError fire with correct componentName/domNodeId on real browser renders.
docs/oss/building-features/debugging-hydration-mismatches.md New guide covering Rails-specific hydration mismatch causes, fix patterns, and callback registration with Sentry example.

Sequence Diagram

sequenceDiagram
    participant App as App (setOptions)
    participant RootEH as rootErrorHandlers module
    participant Builder as buildRootErrorCallbackOptions
    participant React as React (hydrateRoot/createRoot)
    participant Pro as Pro chainRecoverableErrorHandlers

    App->>RootEH: setOptions rootErrorHandlers
    Note over RootEH: Merge per key, store in module-level registeredHandlers
    App->>Builder: buildRootErrorCallbackOptions(context, shouldHydrate)
    Note over Builder: Snapshot handlers from registeredHandlers
    Builder-->>React: onRecoverableError wrapper + onCaughtError + onUncaughtError
    React->>React: hydrateRoot or createRoot with options
    rect rgb(200, 230, 255)
        Note over React,Pro: OSS non-RSC path
        React->>Builder: onRecoverableError fires
        Builder->>Builder: defaultReportRecoverableError + logDevHydrationError
        Builder->>App: safeInvoke userHandler with enriched context
    end
    rect rgb(230, 200, 255)
        Note over React,Pro: Pro RSC hydrate path
        React->>Pro: chainRecoverableErrorHandlers fires
        Pro->>Pro: "filter RSCRoute ssr=false bailout"
        Pro->>Pro: defaultReportRecoverableError internal
        Pro->>Builder: call next with error and errorInfo
        Builder->>Builder: logDevHydrationError only no double defaultReport
        Builder->>App: safeInvoke userHandler with enriched context
    end
Loading

Reviews (1): Last reviewed commit: "Reuse OSS defaultReportRecoverableError ..." | Re-trigger Greptile

Comment thread packages/react-on-rails/src/rootErrorHandlers.ts Outdated
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
Comment thread packages/react-on-rails/src/base/client.ts Outdated
justin808 added a commit that referenced this pull request Jun 12, 2026
…ed isThenable

Review fixes from the Claude Code review pass and Cursor Bugbot on PR #3933:

1. Dev-mode hydration logger no longer swallows React's default
   reporting or the component stack. The error is default-reported
   exactly once (reportError, falling back to console.error — mirroring
   React's own default) so window-'error' tooling keeps working, and the
   branded line now includes errorInfo.componentStack.
2. New buildRootErrorCallbackOptions option
   defaultReportingHandledInternally: Pro's RSC-wrapped hydrate paths
   pass it so the chained internal handler remains the single default
   reporter and the dev logger emits only its supplemental branded line
   (no second full error dump in development). Added a Pro test covering
   the chained dev path with railsEnv development.
3. getRootErrorHandlers now returns a snapshot copy so callers cannot
   mutate the internal registration and bypass validation.
4. Extracted the three byte-identical isThenable copies into
   packages/react-on-rails/src/isThenable.ts (exported as
   react-on-rails/@internal/isThenable) and removed the MUST-SYNC
   thenable-guard duplication note from the Pro renderer.
5. Partial rootErrorHandlers updates now merge per key (consistent with
   the other setOptions keys): setting only one callback keeps the
   others; an explicit undefined clears just that key; resetOptions
   still clears all. setOptions stores the merged result so
   option('rootErrorHandlers') reflects the effective registration.

Docs (guide, JS API reference, type JSDoc) and the changelog entry
updated to describe the preserved default reporting, component stack,
and merge semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@justin808 justin808 force-pushed the jg/3892-react19-root-error-callbacks branch from a688679 to 6d8c7cc Compare June 12, 2026 06:15
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.16 KB (+1.23% 🔺)
react-on-rails/client bundled (gzip) (time) 62.16 KB (+1.23% 🔺)
react-on-rails/client bundled (brotli) 53.37 KB (+1.42% 🔺)
react-on-rails/client bundled (brotli) (time) 53.37 KB (+1.42% 🔺)
react-on-rails-pro/client bundled (gzip) 63.32 KB (+1.19% 🔺)
react-on-rails-pro/client bundled (gzip) (time) 63.32 KB (+1.19% 🔺)
react-on-rails-pro/client bundled (brotli) 54.38 KB (+1.29% 🔺)
react-on-rails-pro/client bundled (brotli) (time) 54.38 KB (+1.29% 🔺)
registerServerComponent/client bundled (gzip) 73.51 KB (+1.07% 🔺)
registerServerComponent/client bundled (gzip) (time) 73.51 KB (+1.07% 🔺)
registerServerComponent/client bundled (brotli) 63.31 KB (+1.25% 🔺)
registerServerComponent/client bundled (brotli) (time) 63.31 KB (+1.25% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 66.68 KB (+0.92% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 66.68 KB (+0.92% 🔺)
wrapServerComponentRenderer/client bundled (brotli) 57.19 KB (+0.95% 🔺)
wrapServerComponentRenderer/client bundled (brotli) (time) 57.19 KB (+0.95% 🔺)

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This PR adds a global rootErrorHandlers API that wires React's root error callbacks (onRecoverableError, onCaughtError, onUncaughtError) into every hydrateRoot/createRoot call React on Rails makes. The core implementation is solid, the test coverage is excellent, and the new debugging guide is genuinely valuable for Rails teams.


Architecture assessment

Core module (rootErrorHandlers.ts) — well-designed. Handler snapshot semantics (line 208: capturing from registeredHandlers at root-creation rather than reading lazily on every error) is the correct choice given that attaching a root callback permanently replaces React's own default reporting for that root. The invariant is explained clearly in the JSDoc, and a regression test guards against it (keeps the handlers captured at root creation when handlers are later reset).

safeInvoke isolation — correct and thorough. The isThenable guard + Promise.resolve().catch() pattern correctly handles async handlers assignable to the void-typed signature before TypeScript can catch them. Protects React's error recovery from user handler failures.

Pro chaining (chainRecoverableErrorHandlers) — the bailout filter → internal defaultReportRecoverableError → user-enriched wrapper ordering is correct, and defaultReportingHandledInternally cleanly prevents the double-report on chained dev paths. Both the OSS and Pro test suites verify the single-report invariant explicitly.


Issues found

1. Minor: setOptions style inconsistency between base/client.ts and capabilities/core.ts

base/client.ts handles rootErrorHandlers using the mutation-and-delete pattern that pre-exists in that file. capabilities/core.ts uses the cleaner destructuring pattern. Both are correct today, but they can diverge when future options are added — one file requires keeping the destructure list in sync, the other requires an explicit delete. Not a blocker, but worth noting.

2. Trivial: falsy guard in logDevHydrationError

context.componentName || 'unknown' would display 'unknown' for an empty-string componentName. In practice componentName is always a registered component name or undefined, never '', so this is harmless. A strict null check (?? 'unknown') would be more precise.

3. Observation: wrapServerComponentRenderer/client.tsx spread-then-override pattern

Lines 140–142: { ...userErrorCallbackOptions, onRecoverableError: chainRecoverableErrorHandlers(...) } correctly overrides only onRecoverableError while letting onCaughtError/onUncaughtError from the spread pass through. The test file wrapServerComponentRendererClient.errorCallbacks.test.tsx verifies onUncaughtError survives this path, which confirms the intent.

4. Pre-existing: MUST SYNC comment for invokeRendererTeardown

The comment on the duplicated invokeRendererTeardown helper (in both ClientRenderer.ts and ClientSideRenderer.ts) is a pre-existing maintenance risk, not introduced by this PR. Worth tracking for a future cleanup but not a blocker here.


Test coverage

Comprehensive. Highlights:

  • Version-gated unit tests (16/17/18/19 with mocked react-dom) cover warnings, dev-default attach rules, and the snapshot-on-reset regression.
  • Real react-dom integration tests (rootErrorCallbacks.test.tsx) force an actual hydration mismatch and assert callback invocation + enriched context.
  • Pro chaining tests verify that both the internal handler AND the user callback fire, that the RSC bailout is filtered from both, and that the single-report invariant holds on the chained dev path.
  • Playwright e2e covers the two critical paths (server-rendered mismatch → onRecoverableError, client-rendered throw → onUncaughtError) with window.__ROOT_ERROR_CALLBACK_EVENTS__ as the assertion bridge.

One gap worth noting: there is no test for setOptions({ rootErrorHandlers: {} }) (empty object). By the merge semantics this is a no-op (keeps existing registrations), which is consistent, but a user might expect it to clear all handlers. The documentation covers this implicitly (resetOptions() is the clear mechanism), but an explicit test or a doc note for the {} edge case would help.


Documentation

The new hydration-mismatch guide is excellent — Rails-specific causes (timezones, current_user-conditional ERB, I18n drift, CSRF tokens, asset hosts, nondeterminism), fix patterns, a Sentry example, and the double-reporting note are all practical and well-phrased. The suppressHydrationWarning note ("last resort, narrowest possible scope") is the right framing.


Summary

No correctness bugs or security issues found. The two minor style/precision nits (mutation vs destructuring in setOptions, || vs ?? in the dev logger) are non-blocking. The empty-{} edge case in setOptions is worth a follow-up test or doc note. Ready to merge modulo maintainer sign-off on the Codex Decision Log items (RSC bailout filtering from user callbacks; dev-default scope for test env).

🤖 Reviewed by Claude Code

Comment thread packages/react-on-rails/src/rootErrorHandlers.ts Outdated
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
Comment thread packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx Outdated
Comment thread packages/react-on-rails/src/base/client.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/oss/building-features/debugging-hydration-mismatches.md (1)

1-219: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ensure the file ends with a newline character.

Per coding guidelines, all files must end with a newline. The annotation shows the file ending at line 219 without a visible newline marker.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/oss/building-features/debugging-hydration-mismatches.md` around lines 1
- 219, The file "Debugging Hydration Mismatches in Rails" is missing a trailing
newline; open the document (look near the end after the "Related documentation"
section / the last link to react.dev) and add a single newline character at EOF
so the file ends with '\n' (ensure your editor saves with a final newline or run
a formatting step that enforces it).

Source: Coding guidelines

🧹 Nitpick comments (1)
docs/oss/building-features/debugging-hydration-mismatches.md (1)

58-59: 💤 Low value

Tighten phrasing for conciseness.

Replace "at the moment it is created" with a shorter form like "when it is created" to reduce wordiness.

✏️ Suggested rephrase
- **Register before rendering.** Each root captures the callbacks registered at the moment it is created; register them in the same pack file where you call `ReactOnRails.register`.
+ **Register before rendering.** Each root captures the callbacks registered when it is created; register them in the same pack file where you call `ReactOnRails.register`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/oss/building-features/debugging-hydration-mismatches.md` around lines 58
- 59, Update the wording in the "Register before rendering." bullet to be more
concise by replacing "at the moment it is created" with "when it is created";
this is in the sentence that references ReactOnRails.register so search for the
phrase and change it to "when it is created" to tighten phrasing without
altering meaning.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/oss/building-features/debugging-hydration-mismatches.md`:
- Around line 27-52: The example for ReactOnRails.setOptions rootErrorHandlers
uses context.componentName and context.domNodeId directly but RootErrorContext
defines them as optional; update the callbacks (onRecoverableError,
onCaughtError, onUncaughtError) to guard against undefined and provide fallbacks
(e.g., use null/unknown/default values or conditional checks) before passing
into myErrorReporter.report so you never access undefined properties from
context.componentName or context.domNodeId.

In `@packages/react-on-rails/src/base/client.ts`:
- Around line 183-190: The code only deletes newOptions.rootErrorHandlers when
it's !== undefined, causing setOptions({ rootErrorHandlers: undefined }) to be
treated as an unknown option; change the logic to mirror capabilities/core.ts by
checking property presence instead of typeof—i.e., use if ('rootErrorHandlers'
in newOptions) { if (typeof newOptions.rootErrorHandlers !== 'undefined') {
setRootErrorHandlers(newOptions.rootErrorHandlers);
this.options.rootErrorHandlers = getRootErrorHandlers(); } delete
newOptions.rootErrorHandlers; } so undefined is treated as a no-op and the key
is removed; reference symbols: newOptions.rootErrorHandlers,
setRootErrorHandlers, getRootErrorHandlers, this.options.rootErrorHandlers.

In `@packages/react-on-rails/src/rootErrorHandlers.ts`:
- Around line 49-63: The warnings in rootErrorHandlers fire on every call (e.g.,
from setRootErrorHandlers) because there's no guard; add a module-level boolean
flag (e.g., warnedUnsupportedRootErrorHandlers) and check it before calling
console.warn in both branches that reference supportsRootApi and
supportsReact19RootErrorCallbacks; set the flag to true after the first warn so
subsequent calls (even with different providedKeys/HANDLER_KEYS) are no-ops and
only the first warning is emitted. Ensure you update the branches that compute
providedKeys and react19OnlyKeys and reference HANDLER_KEYS, supportsRootApi,
and supportsReact19RootErrorCallbacks when applying the guard.

---

Outside diff comments:
In `@docs/oss/building-features/debugging-hydration-mismatches.md`:
- Around line 1-219: The file "Debugging Hydration Mismatches in Rails" is
missing a trailing newline; open the document (look near the end after the
"Related documentation" section / the last link to react.dev) and add a single
newline character at EOF so the file ends with '\n' (ensure your editor saves
with a final newline or run a formatting step that enforces it).

---

Nitpick comments:
In `@docs/oss/building-features/debugging-hydration-mismatches.md`:
- Around line 58-59: Update the wording in the "Register before rendering."
bullet to be more concise by replacing "at the moment it is created" with "when
it is created"; this is in the sentence that references ReactOnRails.register so
search for the phrase and change it to "when it is created" to tighten phrasing
without altering meaning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c113314f-b0f1-4d57-8a8a-e07d5fd11a17

📥 Commits

Reviewing files that changed from the base of the PR and between f806caf and 6d8c7cc.

📒 Files selected for processing (27)
  • .lychee.toml
  • CHANGELOG.md
  • docs/oss/api-reference/javascript-api.md
  • docs/oss/building-features/debugging-hydration-mismatches.md
  • docs/sidebars.ts
  • packages/react-on-rails-pro/src/ClientSideRenderer.ts
  • packages/react-on-rails-pro/src/handleRecoverableError.client.ts
  • packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx
  • packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts
  • packages/react-on-rails-pro/tests/wrapServerComponentRendererClient.errorCallbacks.test.tsx
  • packages/react-on-rails/package.json
  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/src/base/client.ts
  • packages/react-on-rails/src/capabilities/core.ts
  • packages/react-on-rails/src/isThenable.ts
  • packages/react-on-rails/src/reactApis.cts
  • packages/react-on-rails/src/rootErrorHandlers.ts
  • packages/react-on-rails/src/types/index.ts
  • packages/react-on-rails/tests/ReactOnRails.test.jsx
  • packages/react-on-rails/tests/rootErrorCallbacks.test.tsx
  • packages/react-on-rails/tests/rootErrorHandlers.test.ts
  • react_on_rails/spec/dummy/app/views/pages/root_error_callbacks.html.erb
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.ts
  • react_on_rails/spec/dummy/client/app/startup/HydrationMismatchComponent.tsx
  • react_on_rails/spec/dummy/client/app/startup/RootErrorThrower.tsx
  • react_on_rails/spec/dummy/config/routes.rb
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/root_error_callbacks.spec.js

Comment thread docs/oss/building-features/debugging-hydration-mismatches.md
Comment thread packages/react-on-rails/src/base/client.ts Outdated
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
@justin808

Copy link
Copy Markdown
Member Author

Bundle-size audit — head 6d8c7cc6d (check-bundle-size failure triage)

CI numbers reproduced locally (size-limit, same method): react-on-rails/client +771 B gzip (+1.23%) / +760 B brotli vs base f806cafc; react-on-rails-pro/client +761 B gzip.

Module-level attribution (esbuild metafile diff of the client entry, production-minified, both heads):

Module minified delta
src/rootErrorHandlers.ts (new) +2,396 B (91%)
src/capabilities/core.ts (setOptions/resetOptions wiring) +127 B
src/isThenable.ts (new shared util, reused by Pro) +101 B
src/reactApis.cts (React 19 callback support detection) +60 B
src/ClientRenderer.ts (logic moved out) −69 B
react-dom-client.production.js +20 B (minifier jitter; file byte-identical, cmp-verified)

Verdict: all growth is intentional feature code. No new third-party modules, no duplicated modules, no barrel-import accidents; node_modules bit-identical.

One design note, surfaced for completeness: the dev hydration-mismatch reporting is gated at runtime via railsEnv (per the review-round commit), not process.env.NODE_ENV, so its message strings (~300–400 B of the gzip delta) ship in consumers' production bundles. That is a deliberate trade-off (dev reporting works regardless of how the consumer's bundler sets NODE_ENV); NODE_ENV-gating could claw back ~0.3 KB gzip at the cost of losing the dev messages in NODE_ENV=production dev setups.

justin808 added a commit that referenced this pull request Jun 12, 2026
…ed isThenable

Review fixes from the Claude Code review pass and Cursor Bugbot on PR #3933:

1. Dev-mode hydration logger no longer swallows React's default
   reporting or the component stack. The error is default-reported
   exactly once (reportError, falling back to console.error — mirroring
   React's own default) so window-'error' tooling keeps working, and the
   branded line now includes errorInfo.componentStack.
2. New buildRootErrorCallbackOptions option
   defaultReportingHandledInternally: Pro's RSC-wrapped hydrate paths
   pass it so the chained internal handler remains the single default
   reporter and the dev logger emits only its supplemental branded line
   (no second full error dump in development). Added a Pro test covering
   the chained dev path with railsEnv development.
3. getRootErrorHandlers now returns a snapshot copy so callers cannot
   mutate the internal registration and bypass validation.
4. Extracted the three byte-identical isThenable copies into
   packages/react-on-rails/src/isThenable.ts (exported as
   react-on-rails/@internal/isThenable) and removed the MUST-SYNC
   thenable-guard duplication note from the Pro renderer.
5. Partial rootErrorHandlers updates now merge per key (consistent with
   the other setOptions keys): setting only one callback keeps the
   others; an explicit undefined clears just that key; resetOptions
   still clears all. setOptions stores the merged result so
   option('rootErrorHandlers') reflects the effective registration.

Docs (guide, JS API reference, type JSDoc) and the changelog entry
updated to describe the preserved default reporting, component stack,
and merge semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
justin808 added a commit that referenced this pull request Jun 12, 2026
The rebase replayed the changelog entry into the now-stamped 17.0.0.rc.3
section; this PR is unmerged so the entry belongs under Unreleased.

The bundle-size skip file records the maintainer-approved waiver: the
+0.75KB gzip client growth is the rootErrorHandlers feature itself (91%
of the module-level delta) with no accidental imports, per the audit
posted on PR #3933.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@justin808 justin808 force-pushed the jg/3892-react19-root-error-callbacks branch from 6d8c7cc to f1217ed Compare June 12, 2026 10:07
@justin808

Copy link
Copy Markdown
Member Author

Rebased onto current main (4f0b02fd0) and pushed f1217ed15. Two notes:

  1. Bundle-size gate waived per maintainer decision.bundle-size-skip-branch committed, referencing the audit above (+0.75KB gzip is 91% the rootErrorHandlers feature module; no accidental imports). The skip file should be cleared in a follow-up after this PR merges.
  2. Changelog placement fixed — the rebase had replayed this PR's entry into the now-stamped 17.0.0.rc.3 section (Update CHANGELOG.md for 17.0.0.rc.3 #3966); moved it back under Unreleased.

Only conflict this round was the docs/sidebars.ts keep-both (this PR's debugging-hydration-mismatches entry + the merged web-vitals entry). Validated locally: prettier, check-docs-sidebar, full react-on-rails jest suite green.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Code Review: PR #3933 — Expose React 19 root error callbacks + hydration-mismatch guide

Overview

This PR wires React 18/19 root error callbacks (onRecoverableError, onCaughtError, onUncaughtError) into every hydrateRoot/createRoot call React on Rails makes, and exposes a global registration API via ReactOnRails.setOptions({ rootErrorHandlers }). It also adds a dev-mode supplemental hydration-mismatch log and a new debugging guide. The scope is large (OSS client renderer, Pro ClientSideRenderer, Pro RSC wrap, types, tests, docs) but well-structured.


Code Quality and Design

Strengths:

  • Snapshot semantics (capturing handlers at root-creation time, not re-reading on each error) is the right call. React permanently replaces its default reporting when a root callback is registered, and the code documents this contract clearly.
  • Per-key merge semantics for setRootErrorHandlers are consistent with how other setOptions keys behave, with a deliberate explicit-undefined-to-clear escape hatch — tested thoroughly.
  • safeInvoke correctly handles both synchronous throws and async rejections (non-native thenables too, via Promise.resolve().catch()). The comment explaining the void-return-type-but-async-handler quirk is valuable.
  • Pro chaining order (bailout filter → internal defaultReportRecoverableError → user callback) is correct, and the defaultReportingHandledInternally flag correctly prevents double-reporting on the RSC hydrate path.
  • The isThenable extraction into a shared @internal module is a good deduplication.

Findings

1. null in rootErrorHandlers throws instead of clearing — undocumented

In setRootErrorHandlers, passing { onRecoverableError: null } throws "must be a function, got object". Only explicit undefined clears a key. This is arguably correct (null should not be a valid callback), but it is not documented in the RootErrorHandlers interface JSDoc. Users who write setOptions({ rootErrorHandlers: { onRecoverableError: enabled ? handler : null } }) will hit a confusing runtime error. Worth a note in the type docs.

2. HYDRATION_MISMATCH_GUIDE_URL is exported but not in public types

The constant is exported from rootErrorHandlers.ts primarily to allow the test assertion module.HYDRATION_MISMATCH_GUIDE_URL. It will not appear in the published API since it is not re-exported from a barrel, but any user who imports directly from the internal path gets it. Minor; alternatively the tests could hard-code the URL string and the export could be removed.

3. RootErrorHandler types errorInfo as unknown — consider a comment on the shape

This is safe for broad React version compatibility, but React 19 ships concrete types for each callback ({ componentStack?: string } on all three). Users who want structured access to componentStack without a cast would benefit from a comment pointing to the React createRoot/hydrateRoot option docs on the exact per-callback shapes. The JSDoc already mentions componentStack briefly — expanding that one sentence would help.

4. invokeRendererTeardown duplication carries a "MUST SYNC" comment — fragile

Both ClientRenderer.ts and ClientSideRenderer.ts carry identical invokeRendererTeardown implementations with a // MUST SYNC comment. The PR note explains why the export was intentionally not widened. One lower-risk alternative is moving it to an @internal module (like isThenable) without widening the public surface. Low priority for this PR since the duplication is pre-existing, but a follow-up issue would help.

5. wrapServerComponentRenderer/client.tsxdefaultReportingHandledInternally: shouldHydrate is always the RSC case

In ClientSideRenderer.ts the flag is correctly conditioned on wrappedByDefaultRSCProvider && shouldHydrate. In wrapServerComponentRenderer/client.tsx it is simply shouldHydrate, which is correct because this file is only ever used for RSC-wrapped rendering. The asymmetry between the two call sites may confuse a future reader. A brief comment — // always RSC-wrapped: chainRecoverableErrorHandlers always runs on this path — would remove the ambiguity.

6. Missing test: explicit undefined for all three keys at once

There is no test for setRootErrorHandlers({ onRecoverableError: undefined, onCaughtError: undefined, onUncaughtError: undefined }). The existing tests cover clearing one key and resetRootErrorHandlers(), but this edge case (all three keys explicitly present-but-undefined) exercises the hasOwnProperty branch for every key simultaneously. Very minor gap.

7. Dev-mode default attaches to every hydrating root, not just mismatching ones

When railsEnv === 'development' and hydrating, a custom onRecoverableError is unconditionally attached to every root. The wrapper preserves React's default reporting via defaultReportRecoverableError, so behavior is unchanged for roots with no mismatch. Worth a sentence in the debugging guide noting that the branded log only fires when React actually triggers a recoverable error, not on every mount.


Security

No concerns. User callbacks are wrapped in safeInvoke, so throwing or async-rejecting user code cannot escape into React's error recovery path. The branded dev log deliberately omits the error object (documented). No HTML serialization.

Performance

inDevelopmentEnv() reads the DOM (getRailsContext()) but only at root-creation time, not on each error. getRootErrorHandlers() returning a copy is also root-creation only. No production-path performance impact.


Summary

Well-designed, well-tested PR. Snapshot semantics, merge semantics, Pro chaining, and version gating are all correct. Test coverage is comprehensive (unit + integration + E2E + Pro). The main actionable items are: (1) document the null vs undefined distinction for clearing keys in RootErrorHandlers JSDoc, (2) add a clarifying comment in wrapServerComponentRenderer/client.tsx for the defaultReportingHandledInternally asymmetry, and (3) add the missing test for clearing all three keys explicitly at once. None of these are blocking.

Comment thread packages/react-on-rails/src/types/index.ts
Comment thread packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx Outdated
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
Comment thread packages/react-on-rails/tests/rootErrorHandlers.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
docs/oss/building-features/debugging-hydration-mismatches.md (2)

27-52: ⚡ Quick win

Consider adding fallback values to match the internal default logger pattern.

The doc example passes context.componentName and context.domNodeId directly to myErrorReporter.report(). Both properties are optional per the RootErrorContext type, so they may be undefined. React on Rails' own default hydration logger (when no user callback is registered) applies fallbacks like 'unknown' when these values are absent. The doc example should either show explicit fallbacks (context.componentName || 'unknown') or clarify that the reporter may receive undefined values.

💡 Proposed example with fallback pattern
     onRecoverableError: (error, errorInfo, context) => {
       myErrorReporter.report(error, {
         level: 'warning',
-        componentName: context.componentName,
-        domNodeId: context.domNodeId,
+        componentName: context.componentName || 'unknown',
+        domNodeId: context.domNodeId || 'unknown',
       });
     },

Apply the same pattern to the other callbacks and the Sentry example (lines 64–85) for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/oss/building-features/debugging-hydration-mismatches.md` around lines 27
- 52, The example in ReactOnRails.setOptions.rootErrorHandlers passes optional
context fields directly (e.g., context.componentName, context.domNodeId) which
may be undefined; update the onRecoverableError, onCaughtError, and
onUncaughtError callbacks to supply explicit fallbacks when calling
myErrorReporter.report (e.g., use componentName || 'unknown' and domNodeId ||
'unknown' or similar defaults) and apply the same fallback pattern to the Sentry
example so the reporter always receives concrete values rather than undefined.

58-58: 💤 Low value

Minor: Simplify phrasing for conciseness.

"At the moment it is created" can be shortened to "when it is created" for better readability.

✏️ Suggested edit
-Each root captures the callbacks registered at the moment it is created; register them in the same pack file where you call `ReactOnRails.register`.
+Each root captures the callbacks registered when it is created; register them in the same pack file where you call `ReactOnRails.register`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/oss/building-features/debugging-hydration-mismatches.md` at line 58,
Update the sentence to use more concise phrasing: replace "at the moment it is
created" with "when it is created" in the line that discusses registration
before rendering (the sentence referencing ReactOnRails.register and root
callback capture) so it reads "Each root captures the callbacks registered when
it is created; register them in the same pack file where you call
ReactOnRails.register."
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@docs/oss/building-features/debugging-hydration-mismatches.md`:
- Around line 27-52: The example in ReactOnRails.setOptions.rootErrorHandlers
passes optional context fields directly (e.g., context.componentName,
context.domNodeId) which may be undefined; update the onRecoverableError,
onCaughtError, and onUncaughtError callbacks to supply explicit fallbacks when
calling myErrorReporter.report (e.g., use componentName || 'unknown' and
domNodeId || 'unknown' or similar defaults) and apply the same fallback pattern
to the Sentry example so the reporter always receives concrete values rather
than undefined.
- Line 58: Update the sentence to use more concise phrasing: replace "at the
moment it is created" with "when it is created" in the line that discusses
registration before rendering (the sentence referencing ReactOnRails.register
and root callback capture) so it reads "Each root captures the callbacks
registered when it is created; register them in the same pack file where you
call ReactOnRails.register."

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 95d0d51a-55e1-4f06-9599-40167988a586

📥 Commits

Reviewing files that changed from the base of the PR and between 6d8c7cc and f1217ed.

📒 Files selected for processing (28)
  • .bundle-size-skip-branch
  • .lychee.toml
  • CHANGELOG.md
  • docs/oss/api-reference/javascript-api.md
  • docs/oss/building-features/debugging-hydration-mismatches.md
  • docs/sidebars.ts
  • packages/react-on-rails-pro/src/ClientSideRenderer.ts
  • packages/react-on-rails-pro/src/handleRecoverableError.client.ts
  • packages/react-on-rails-pro/src/wrapServerComponentRenderer/client.tsx
  • packages/react-on-rails-pro/tests/ClientSideRenderer.test.ts
  • packages/react-on-rails-pro/tests/wrapServerComponentRendererClient.errorCallbacks.test.tsx
  • packages/react-on-rails/package.json
  • packages/react-on-rails/src/ClientRenderer.ts
  • packages/react-on-rails/src/base/client.ts
  • packages/react-on-rails/src/capabilities/core.ts
  • packages/react-on-rails/src/isThenable.ts
  • packages/react-on-rails/src/reactApis.cts
  • packages/react-on-rails/src/rootErrorHandlers.ts
  • packages/react-on-rails/src/types/index.ts
  • packages/react-on-rails/tests/ReactOnRails.test.jsx
  • packages/react-on-rails/tests/rootErrorCallbacks.test.tsx
  • packages/react-on-rails/tests/rootErrorHandlers.test.ts
  • react_on_rails/spec/dummy/app/views/pages/root_error_callbacks.html.erb
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.ts
  • react_on_rails/spec/dummy/client/app/startup/HydrationMismatchComponent.tsx
  • react_on_rails/spec/dummy/client/app/startup/RootErrorThrower.tsx
  • react_on_rails/spec/dummy/config/routes.rb
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/root_error_callbacks.spec.js
✅ Files skipped from review due to trivial changes (2)
  • .lychee.toml
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (20)
  • docs/oss/api-reference/javascript-api.md
  • packages/react-on-rails/package.json
  • react_on_rails/spec/dummy/config/routes.rb
  • packages/react-on-rails/tests/ReactOnRails.test.jsx
  • react_on_rails/spec/dummy/client/app/packs/client-bundle.ts
  • packages/react-on-rails-pro/src/handleRecoverableError.client.ts
  • react_on_rails/spec/dummy/client/app/startup/HydrationMismatchComponent.tsx
  • packages/react-on-rails/src/isThenable.ts
  • docs/sidebars.ts
  • packages/react-on-rails/src/capabilities/core.ts
  • packages/react-on-rails/src/ClientRenderer.ts
  • react_on_rails/spec/dummy/client/app/startup/RootErrorThrower.tsx
  • packages/react-on-rails/src/reactApis.cts
  • packages/react-on-rails-pro/src/ClientSideRenderer.ts
  • packages/react-on-rails-pro/tests/wrapServerComponentRendererClient.errorCallbacks.test.tsx
  • react_on_rails/spec/dummy/e2e/playwright/e2e/react_on_rails/root_error_callbacks.spec.js
  • packages/react-on-rails/src/types/index.ts
  • packages/react-on-rails/src/rootErrorHandlers.ts
  • packages/react-on-rails/tests/rootErrorCallbacks.test.tsx
  • packages/react-on-rails/src/base/client.ts

@justin808

Copy link
Copy Markdown
Member Author

Pushed 92c779f74: regenerated llms-full.txt for the new debugging-hydration-mismatches guide — the drift guard from #3903 (merged after this branch's last rebase) now requires regeneration alongside docs changes.

Also triaged the examples (4.0, latest) failure on f1217ed15: infrastructure flake, not PR-related — @swc/core postinstall crashed with "Bus error (core dumped)" on the runner during npm install in the generated example app. This push restarts the job.

Comment thread packages/react-on-rails-pro/src/handleRecoverableError.client.ts
@justin808

Copy link
Copy Markdown
Member Author

Review triage for current head c4e6f3bd2:

Resolved these as non-blocking/stale review items without another code push.

  • PRRT_kwDOAnNnU86JbxjQ: console formatting nit; no behavior or validation risk.
  • PRRT_kwDOAnNnU86Jbxj3: internal export annotation preference. The value is exported from an internal module surface for package-internal reuse/testing; no public API behavior changes.
  • PRRT_kwDOAnNnU86JbxlG: stale/implemented by c4e6f3bd2; null handler values now report got null and have test coverage.
  • PRRT_kwDOAnNnU86JbxmD: already covered by the one-shot warning text and regression test for split registrations; the warning body names all React-19-only callbacks even when the prefix names the key provided in that call.
  • PRRT_kwDOAnNnU86JbxoQ: IDE/JSDoc ergonomics suggestion; unknown remains intentional to avoid React 19 type coupling.
  • PRRT_kwDOAnNnU86JbxpK: test failure-latency optimization, not a correctness issue; current test covers the behavior.
  • PRRT_kwDOAnNnU86JbxrY: docs callout suggestion for Pro RSC double-reporting. The source JSDoc and PR decision log already record this caveat; adding another guide note is optional polish.

No confirmed blocker, failing test, API break, security issue, or required validation gap identified.

@claude

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Code Review - PR 3933

Overall: Well-architected feature. Snapshot semantics (handlers captured at root-creation time), safe-invoke wrapping both sync throws and async rejections, per-key merge with explicit-undefined-clears-one semantics, and the bailout filter in the Pro RSC chain are all correctly implemented. Test coverage is thorough, the JSDoc is unusually complete, and the decision log is genuinely useful for reviewers.

Four observations follow:

1. Behavioral divergence for Pro RSC hydrate roots (medium - worth documenting in the guide)

On Pro RSC hydrate paths, chainRecoverableErrorHandlers always calls defaultReportRecoverableError before the user callback, so a user-registered onRecoverableError does not replace React's default reporting — both fire. This differs from React's documented semantics where a registered callback replaces the default. The JSDoc on chainRecoverableErrorHandlers mentions it ("a user onRecoverableError callback on Pro RSC hydrate roots should not call reportError(error) again"), but the public debugging guide and API docs describe only the core behavior. A note in the guide under the Sentry example — or in the API docs under onRecoverableError — would prevent a double-reporting surprise for Pro RSC users.

2. MUST SYNC duplication in setOptions (minor - maintenance risk)

The rootErrorHandlers block in setOptions is byte-identical in capabilities/core.ts:107-119 and base/client.ts:181-193, enforced only by a comment. The decision log rejected centralizing reactHydrateOrRender call sites (they have different surrounding context), but this block is pure utility — a one-line applyRootErrorHandlersOption(newOptions, this.options) helper extracted to rootErrorHandlers.ts would remove the only remaining structural duplication without touching the render call sites.

3. Validation loop iterates all HANDLER_KEYS, not just provided keys (nit)

setRootErrorHandlers (lines 66-75) iterates every member of HANDLER_KEYS to type-check values, even when the caller provided only one or two keys. For absent keys, handlers[key] is undefined, the typeof value !== 'undefined' guard short-circuits, and the loop is a no-op — no bug. But iterating Object.keys(handlers) would make the intent clearer and avoid the implicit reliance on that short-circuit.

4. buildRootErrorCallbackOptionsWithInternalRecoverableErrorReporting on createRoot paths (nit - naming)

The function name implies internal recoverable-error reporting is always active, but it passes defaultReportingHandledInternally: hydrating, so on createRoot (non-hydrate) paths it is identical to buildRootErrorCallbackOptions. The JSDoc covers this clearly, but a call site reading only the function name could infer a behavioral difference where none exists.

Comment thread packages/react-on-rails-pro/src/handleRecoverableError.client.ts
Comment thread packages/react-on-rails/src/capabilities/core.ts
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
@justin808

Copy link
Copy Markdown
Member Author

Final Claude-review triage for current head c4e6f3bd2:

Claude review completed successfully. Remaining unresolved threads are non-blocking nits or already-triaged maintainability/docs points; resolving without code churn.

  • PRRT_kwDOAnNnU86Jb39B: Pro RSC hydrate roots intentionally default-report before user callback; source JSDoc and PR decision log document the caveat. Public guide callout is optional polish, not a blocker.
  • PRRT_kwDOAnNnU86Jb3-A: helper extraction for duplicated rootErrorHandlers option handling is cleanup. The current code has MUST SYNC comments and covered behavior.
  • PRRT_kwDOAnNnU86Jb4AP: iterating all handler keys is an explicit fixed-key validation style; absent keys are a harmless no-op.
  • PRRT_kwDOAnNnU86Jb4A6: hydrate-only helper naming/readability concern is already documented in JSDoc; no behavior risk.

No confirmed correctness regression, failing test, API break, security issue, or required validation gap remains from Claude review.

@justin808

Copy link
Copy Markdown
Member Author

Address-review summary

Scan scope: live unresolved current-head Claude threads on 72decd2979a3b036f248c9a889d2b1d5fa9f2e15.

Mattered

  • Fixed PRRT_kwDOAnNnU86Jb39B: added the public JavaScript API note that Pro RSC hydration chains onRecoverableError after Pro default reporting, so users should not report the same error again there.

Optional

  • PRRT_kwDOAnNnU86Jb3-A auto-deferred: helper extraction would be optional refactor churn on a full-CI PR.
  • PRRT_kwDOAnNnU86Jb4AP auto-deferred: provided-key iteration is clarity-only; existing all-key validation is correct.
  • PRRT_kwDOAnNnU86Jb4A6 auto-deferred: helper JSDoc already documents the non-hydrate behavior; rename/call-site comments would churn current-head review.

Skipped

  • None.

Validation: docs/LLMS/sidebar/link/format checks and OSS RuboCop passed locally; pre-commit and pre-push hooks passed. Review threads are resolved with GraphQL unresolved count 0.

Next default scan starts after this comment. Say check all reviews to rescan the full PR.

@claude

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #3933 (rootErrorHandlers + hydration-mismatch guide)

The architecture is solid and the test coverage is thorough. A few observations below.

WHAT IS WELL-DONE

  • Snapshot semantics — capturing handlers at root-creation time (not re-reading on each error) is the correct call given that React permanently replaces its default per-root callback once you supply one. The rationale in the JSDoc is clear.
  • Per-key merge semantics — setRootErrorHandlers using Object.prototype.hasOwnProperty to distinguish "omitted" from "explicit undefined" is the right approach and avoids silent registration loss on partial updates.
  • safeInvoke + async rejection guard — swallowing thenable rejections via Promise.resolve().catch() is the right defensive pattern; a callback typed as void-returning can still be async in practice.
  • isThenable extraction — good refactoring; consistent handling of non-native thenables in core, rootErrorHandlers, and Pro.
  • Pro chaining correctness — the defaultReportingHandledInternally flag preventing double default-reporting on Pro RSC hydrate paths is clearly documented and the logic in buildRootErrorCallbackOptionsWithInternalRecoverableErrorReporting is correct.

CONCERNS

  1. setOptions rootErrorHandlers block is fully duplicated (MUST SYNC)

The 12-line block that handles rootErrorHandlers inside setOptions is byte-for-byte the same in capabilities/core.ts and the deprecated base/client.ts, tied together only by a comment. Any future tweak to reset or merge semantics must be remembered and applied twice. Extracting to a shared internal helper (e.g. applyRootErrorHandlersOption) would eliminate the sync risk without widening the public API.

  1. Branded dev log message is over-specific for non-mismatch recoverable errors

The message in logDevHydrationError says "The server-rendered HTML did not match what React rendered on the client" and links to the hydration-mismatch guide. But onRecoverableError on a hydrateRoot can be invoked for other recoverable errors too (e.g. a lazy component throwing mid-hydration and React re-rendering from scratch to recover). In those cases the message is inaccurate. Worth softening to "A recoverable error occurred during hydration" with the hydration-mismatch causes listed as the most likely culprit rather than the asserted definite one.

  1. Dev-mode double-track for apps using both a global error handler and a registered callback

When logDevDefault=true (dev + hydrate) and the user has also registered onRecoverableError, the path is: defaultReportRecoverableError to branded log to user callback. defaultReportRecoverableError dispatches a DOM ErrorEvent via globalThis.reportError, which fires any global window.onerror/addEventListener listener (including Sentry's global handler). If the user's registered callback also reports to Sentry, the error is captured twice in development. The PR decision log flags this as deferred, which is reasonable, but the guide's Sentry example should include a note to deduplicate or conditionally call reportError in the callback.

Comment thread packages/react-on-rails/src/capabilities/core.ts
Comment thread packages/react-on-rails/src/rootErrorHandlers.ts
@justin808

Copy link
Copy Markdown
Member Author

Claude-review closeout triage for current head 72decd297:

  • PRRT_kwDOAnNnU86JcBqe: duplicate of the shared-helper extraction suggestion. This is maintainability cleanup; current behavior is covered and the sibling code paths now carry MUST SYNC comments.
  • PRRT_kwDOAnNnU86JcBtB: dev-message/Sentry docs polish. The possible dev double-reporting asymmetry is already documented in the PR decision log as a maintainer review-later item; production semantics are unchanged. Softening the hydration message wording or adding dedup guidance is optional documentation/DX follow-up, not a correctness blocker.

No confirmed blocker remains from Claude review.

@justin808

Copy link
Copy Markdown
Member Author

Gate readiness update for Batch 5 launch dependency on current head 72decd2979a3b036f248c9a889d2b1d5fa9f2e15:

  • Release mode: accelerated-rc from live Release gate: react_on_rails 17.0.0 #3823.
  • GitHub checks: full current-head check list is complete; 49 pass, 7 skipped, 0 pending, 0 failed. Skips are expected selector/benchmark/doc paths (claude, docs-format, bundle-size/benchmark confirmation/report rows).
  • Review threads: GraphQL unresolved count is 0 for current head.
  • Mergeability: MERGEABLE / CLEAN.
  • Labels/CI: keep full-ci; no benchmark label. The PR touches SSR/hydration/root error handling and Pro/core boundary behavior.
  • Remaining gate: accelerated-RC auto-merge still needs a current-head Agent Merge Confidence block finalized by an independent finalizer. This account is justin808, the PR author, so I am not an independent finalizer for this PR.

Merge recommendation: ready for independent accelerated-RC finalization / maintainer merge review, but not agent-auto-mergeable until that finalizer requirement is satisfied. Batch 5 issue lanes remain blocked until this PR is merged into origin/main.

@justin808 justin808 merged commit 0b794fc into main Jun 15, 2026
67 checks passed
@justin808 justin808 deleted the jg/3892-react19-root-error-callbacks branch June 15, 2026 03:55
justin808 added a commit that referenced this pull request Jun 15, 2026
* origin/main:
  Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
  Add post-merge audit scope resolver (#4029)
  Document continuous agent-run evaluation loop (#4028)
  Tools: add PR batch merge ledger (#3996)
  Add RSC FOUC acceptance coverage (#4033)
  Keep plan-pr-batch goal prompts under 4000 chars (#4025)
  docs(skills): file-collision check + goal-prompt size discipline for plan-pr-batch (#3914)
  Verify React 19.2 <Activity> with react_component (CSR + SSR-hydrate) + docs (#3938)

# Conflicts:
#	llms-full.txt
justin808 added a commit that referenced this pull request Jun 15, 2026
* origin/main:
  Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
  Add post-merge audit scope resolver (#4029)
  Document continuous agent-run evaluation loop (#4028)
  Tools: add PR batch merge ledger (#3996)
  Add RSC FOUC acceptance coverage (#4033)
  Keep plan-pr-batch goal prompts under 4000 chars (#4025)
  docs(skills): file-collision check + goal-prompt size discipline for plan-pr-batch (#3914)
  Verify React 19.2 <Activity> with react_component (CSR + SSR-hydrate) + docs (#3938)

# Conflicts:
#	CHANGELOG.md
#	llms-full.txt
justin808 added a commit that referenced this pull request Jun 15, 2026
…ter-slice

* origin/main:
  Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
  Add post-merge audit scope resolver (#4029)
  Document continuous agent-run evaluation loop (#4028)
  Tools: add PR batch merge ledger (#3996)
  Add RSC FOUC acceptance coverage (#4033)
  Keep plan-pr-batch goal prompts under 4000 chars (#4025)
  docs(skills): file-collision check + goal-prompt size discipline for plan-pr-batch (#3914)
  Verify React 19.2 <Activity> with react_component (CSR + SSR-hydrate) + docs (#3938)

# Conflicts:
#	llms-full.txt
justin808 added a commit that referenced this pull request Jun 15, 2026
…e-demo-cost-docs

* origin/main:
  Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
  Add post-merge audit scope resolver (#4029)
  Document continuous agent-run evaluation loop (#4028)
  Tools: add PR batch merge ledger (#3996)

# Conflicts:
#	llms-full.txt
justin808 added a commit that referenced this pull request Jun 15, 2026
…ce-maps

* origin/main:
  Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
  Add post-merge audit scope resolver (#4029)
  Document continuous agent-run evaluation loop (#4028)
  Tools: add PR batch merge ledger (#3996)
  Add RSC FOUC acceptance coverage (#4033)
  Keep plan-pr-batch goal prompts under 4000 chars (#4025)
  docs(skills): file-collision check + goal-prompt size discipline for plan-pr-batch (#3914)
  Verify React 19.2 <Activity> with react_component (CSR + SSR-hydrate) + docs (#3938)

# Conflicts:
#	llms-full.txt
justin808 added a commit that referenced this pull request Jun 15, 2026
…c-streaming

* origin/main:
  Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
  Add post-merge audit scope resolver (#4029)
  Document continuous agent-run evaluation loop (#4028)
  Tools: add PR batch merge ledger (#3996)
  Add RSC FOUC acceptance coverage (#4033)

# Conflicts:
#	llms-full.txt
justin808 added a commit that referenced this pull request Jun 15, 2026
…style Rails bridge (#3872) (#3942)

## Summary

Implements the maintainer-approved v1 of `useRailsForm` (approval
comment 2026-06-11 on the issue; descoped per its "Triage — 2026-06-11"
section). Closes #3872.

An Inertia `useForm`-style bridge from React forms to plain Rails
controller actions:

- **New hook `useRailsForm`** (`react-on-rails/useRailsForm`):
`data`/`setData`, per-field `errors`/`hasErrors`, `processing`,
`wasSuccessful`, submit verbs (`post`/`put`/`patch`/`delete`/`submit`),
`reset`/`clearErrors`/`setError`, automatic CSRF attachment from the
Rails csrf-token meta tag (with a loud pre-fetch error if the tag is
missing), JSON request/response handling, and mapping of `422` + `{
errors: { field: ["message"] } }` onto per-field error state.
Non-2xx/non-422 responses reject with `RailsFormRequestError`.
- **Opt-in gem concern**
`ReactOnRails::Controller::FormResponders#render_model_errors(record)`
renders ActiveModel errors in exactly that shape — per the triage AC
correction, the documented contract is "a controller using the provided
concern", not "unmodified controller". RBS signatures included.
- **Redirect handling is minimal and forward-compatible with #3873**:
success results expose `redirectTo` from safe same-origin JSON
`redirect_to`/`redirectTo` hints, with a defensive custom-fetch
redirected-response fallback; native Rails redirects reject because
fetch uses `redirect: 'error'`. The hook never navigates.
- **Dummy app example**: `/rails_form` (CSR component +
`ContactMessagesController` + `ContactMessage` ActiveModel) exercising
the full 422 round-trip.
- **Docs**: new `docs/oss/building-features/forms.md` (quick start,
Inertia `useForm` mapping table, error-shape contract, Server Functions
#3867 cross-link, fetch-vs-XHR decision note).
- **v1 is fetch-only**; `transform`, `recentlySuccessful`, and
file-upload `progress` (the XHR fork) are deferred per triage.

## Codex Decision Log

- **Non-blocking:** transport — **Decision:** fetch-only in v1 per the
batch goal; the triage's fetch-vs-XHR criterion is documented (upload
progress requires XHR/duplex, deferred).
- **Non-blocking:** codex review P2 — same-tick `setData(...);
submit(...)` posted stale data (React batches the state update).
**Fixed:** `commitData` updates the data ref eagerly; regression test
added.
- **Non-blocking:** codex review P2 — a superseded submission could
still fire `onSuccess` (e.g. navigate on `redirectTo`) after a newer one
completed. **Fixed:** callbacks now use the same current-submission
guard as state updates; regression test added.
- **Non-blocking:** codex review P3 — docs quick start used
`params.require(:contact_message)`, which raises for flat JSON bodies
when JSON params wrapping is off. **Fixed:** docs match the dummy
controller's flat-or-wrapped pattern.
- **Non-blocking:** concern lives at
`ReactOnRails::Controller::FormResponders` (room for future form
helpers) with `render_model_errors` as the method name from the issue.

## Coordination

Open PR #3933 touches the same npm package; overlap is limited to
additive, line-disjoint changes in `CHANGELOG.md`, `docs/sidebars.ts`,
`packages/react-on-rails/package.json` (one export line each), and
`react_on_rails/spec/dummy/config/routes.rb` — trivially mergeable in
either order.

## Test plan

- Jest `tests/useRailsForm.test.tsx`: 40 tests — verbs, CSRF/headers,
same-tick setData+submit, processing lifecycle, 422 mapping (incl.
malformed/empty-shape rejection), non-browser URL-guard rejection,
success/redirect, superseded-submission suppression,
reset/clearErrors/setError, DOM render of per-field errors. Earlier full
package suite: 199/199.
- RSpec `spec/react_on_rails/controller/form_responders_spec.rb` (4
examples) and dummy `spec/requests/contact_messages_spec.rb` (6
examples: 422 shape for invalid submissions, 201 success, form page
render): all pass.
- `pnpm run build`, `type-check`, `lint`, Prettier, RuboCop (changed
files), `rake rbs:validate`, `script/check-docs-sidebar`: all pass.
- `codex review --base origin/main`: 3 findings (2×P2, 1×P3), all fixed
with regression tests (see decision log).

Labels: full-ci — new public API surface in both the npm package and the
gem.

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



## Merge Readiness Criteria

Current evaluation as of 2026-06-15 for head
`ba57cc63f0280d3970c46e1dbf18a763be138b6b`.

- **Release mode:** `accelerated-rc`, from canonical release gate #3823
(`Mode: accelerated-rc`).
- **Current head SHA:** `ba57cc63f0280d3970c46e1dbf18a763be138b6b`.
- **CI/check status:** Complete for current head. No required checks are
reported for this branch, so the full check list is the gate: 50
passing, 5 expected skips (`docs-format-check`,
benchmark/report/confirmation no-op jobs, and manual wrapper no-op
paths), 0 failed, 0 pending.
- **Review-thread status:** GraphQL sweep immediately before readiness
refresh: unresolved review threads = 0.
- **Review feedback triage:** Prior Claude/Codex/Cursor/CodeRabbit
feedback has been fixed or explicitly triaged in the decision log and
review-thread replies. The latest push only merged current `main` and
resolved generated `llms-full` state after already-addressed
useRailsForm feedback.
- **Validation run:** `node script/generate-llms-full.mjs --check` ->
passed; `git diff --check` -> passed; `pnpm start format.listDifferent`
-> passed; `pnpm --dir packages/react-on-rails exec jest
tests/useRailsForm.test.tsx --runInBand` -> passed, 49 tests; latest
merge-update validation also passed the same focused
Jest/LLMS/format/whitespace checks.
- **Label/CI decision:** Labels: `full-ci`. New public npm hook, gem
concern, RBS, dummy app, and docs justify full CI; full current-head
GitHub gate is complete.
- **Known residual risk:** Moderate new-public-API risk around form
error and redirect edge cases. Existing local and CI coverage exercises
the intended contract; no confirmed blocker remains.
- **Merge recommendation:** Merge. Maintainer approval verified via
#3942 (comment).

Confidence note:
- Validated: current-head full GitHub checks, 0 unresolved review
threads, LLMS check, whitespace, Prettier, and focused `useRailsForm`
Jest checks.
- Evidence: GitHub checks for
`ba57cc63f0280d3970c46e1dbf18a763be138b6b`; maintainer approval comment
above; latest local validation commands recorded in this description.
- UNKNOWN: none for the merge gate.
- Residual risk: moderate public-API adoption risk, covered by focused
tests and full CI.
- Decision points: 1 (maintainer approval comment on 2026-06-15).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> New public npm and gem APIs on form submission paths (CSRF,
same-origin, error JSON); behavior is guarded and heavily tested but
apps will rely on the 422 contract and verbatim ActiveModel messages.
> 
> **Overview**
> Adds an **Inertia `useForm`-style** bridge so React forms can post
JSON to ordinary Rails controllers without a separate API layer.
> 
> **Client:** New `useRailsForm` export at `react-on-rails/useRailsForm`
(Pro re-exports the same path). The hook handles `data`/`setData`,
`processing`, `wasSuccessful`, HTTP verbs, CSRF from the Rails meta tag,
same-origin enforcement, `redirect: 'error'` on fetch, 422 → per-field
`errors`, stale-submit suppression, and optional `redirectTo` from JSON
hints (no auto-navigation). v1 is fetch-only.
> 
> **Server:** Opt-in `ReactOnRails::Controller::FormResponders` with
`render_model_errors(record)` returning `{ errors: { field: [messages] }
}` at HTTP 422, plus RBS.
> 
> **Shipped with:** `docs/oss/building-features/forms.md`, sidebar +
`llms-full.txt`, CHANGELOG, dummy `/rails_form` +
`ContactMessagesController`, and broad Jest/RSpec/request specs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ba57cc6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





## Agent Merge Confidence

Mode: accelerated-rc
Current head SHA: ba57cc6
Score: 8/10
Auto-merge recommendation: yes; maintainer-directed merge requested
after conflict resolution and current-head review fixes
Affected areas: useRailsForm hook/API, Rails form responder concern,
dummy app form example, docs, generated llms reference
CI detector: `script/ci-changes-detector origin/main` -> Ruby core,
JS/TS, RSpec, dummy app, Pro JS/TS, uncategorized; recommends full
lint/test/e2e/generator/Pro/node-renderer/benchmark coverage.
Validation run:
- `jest tests/useRailsForm.test.tsx --runInBand` -> passed locally on
the final change set (49 tests).
- `pnpm run type-check` in `packages/react-on-rails` -> passed locally.
- `prettier --check` on touched hook/test/docs files -> passed locally.
- `node script/generate-llms-full.mjs && node
script/generate-llms-full.mjs --check` -> passed after docs/conflict
updates.
- `git diff --check` -> passed after latest conflict resolution.
- Pre-push hooks on resolved heads -> RuboCop branch lint passed;
Markdown link checks passed with lychee 0.23.0.
Review/check gate:
- GitHub checks: complete for
`ba57cc63f0280d3970c46e1dbf18a763be138b6b`; 50 passed, 5 expected
selector/placeholder skips, 0 failed.
- Review threads: GraphQL unresolved count is 0. Two current-head Codex
review findings were fixed and resolved: stale failure rejections and
stale success results.
- Current-head reviewer verdicts:
  - Claude review: passed for current head.
  - Cursor Bugbot: passed for current head.
  - CodeRabbit: passed for current head.
Known residual risk: moderate-low; this is new API surface plus Rails
integration, but full current-head CI, focused stale-submission
regression coverage, docs drift checks, and resolved review findings
reduce the known risk.
Finalized by: maintainer-directed merge requested by @justin808 in Codex
session on 2026-06-15; this is not an autonomous accelerated-RC merge.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
justin808 added a commit that referenced this pull request Jun 15, 2026
## Summary

Stamps the **17.0.0.rc.4** changelog header and reconciles the post-rc.3
entries.

### What changed
- **Stamped `### [17.0.0.rc.4] - 2026-06-14`** with the standard
compare-link updates (`v17.0.0.rc.3...v17.0.0.rc.4`, `unreleased →
rc.4...main`). Prior RC sections are left intact per the prerelease
convention.
- **Moved the source-mapped stack traces entry (PR 3940) into rc.4.** It
had been placed inside the already-tagged `rc.3` section, but `#3940`
merged *after* `v17.0.0.rc.3` was tagged (verified: `d9ac060a0` is not
an ancestor of the rc.3 tag).
- **Added the missing `[PR 4026]` attribution** to the RSC
peer-compatibility (React 19.2 floor) entry, which previously linked
only the issue.
- **Merged the two duplicate `#### Added` headings** in the section into
one.

### rc.4 contents
- **Added**: cache_tags revalidation (#3964, Pro), React 19 root error
callbacks (#3933), `useRailsForm` + `render_model_errors` (#3942),
node-renderer `/health` & `/ready` endpoints (#3939, Pro), source-mapped
stack traces (#3940, Pro)
- **Changed**: RSC peer compatibility for the React 19.2 floor (#4026,
Pro)
- **Fixed**: Rspack generated apps start in HMR mode (#3926)

Other merged PRs since rc.3 were docs / CI / tooling / test-infra and
were intentionally not given entries (e.g. #3949 only touches
`spec/support`; the user-facing generator hook is explicitly unaffected;
#3953 adds zero runtime surface; #3934/#3938 are verify+docs).

### Notes
- Verified clean under the repo's pinned `prettier@3.6.2`. The local
pre-commit hook could not run prettier (binary not installed in this
workspace), so the commit used `--no-verify`; CI prettier will run
normally.

After merge, run `rake release` (no args) to publish `v17.0.0.rc.4` and
auto-create the GitHub release from this section.

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

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation-only changelog edits with no runtime or API surface
changes.
> 
> **Overview**
> Adds the **`17.0.0.rc.4` (2026-06-14)** release section to
`CHANGELOG.md` and updates the bottom compare links so
**`[unreleased]`** points at `v17.0.0.rc.4...main` and
**`[17.0.0.rc.4]`** covers `v17.0.0.rc.3...v17.0.0.rc.4`.
> 
> The edit **re-sorts notes that landed after the rc.3 tag**: the Pro
**source-mapped Node renderer stack traces** entry moves out of the rc.3
block into rc.4, and the duplicate **`#### Added`** under the RSC
peer-compatibility **Changed** item is folded into a single Added list
(health/ready probes stay documented under rc.4 Added). The React **19.2
floor** peer-compat line gains **[PR 4026]** attribution alongside the
existing issue link.
> 
> No application or library code changes—release documentation only.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
40c7a88. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Introduced `useRailsForm` hook for improved form handling
* Added `/health` and `/ready` endpoints to Node renderer for better
observability
  * Expanded React 19 support with root error callback registration
  * Enhanced Pro cache tag revalidation capabilities

* **Changed**
* Improved error diagnostics with source-mapped stack traces in Node
renderer
  * Extended RSC peer compatibility for React 19.2.7+

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@justin808

Copy link
Copy Markdown
Member Author

Manual verification: reproduced the gap and confirmed the feature ✅

Reproduced via the merged jest suites (rootErrorHandlers.test.ts, rootErrorCallbacks.test.tsx). Issue #3892: ReactHydrateOptions was typed end-to-end but never constructed — the OSS ClientRenderer called reactHydrateOrRender(domNode, element, shouldHydrate) with no options, so apps could not observe recoverable hydration errors at all. This PR adds rootErrorHandlers and wires it into every hydrateRoot/createRoot.

Reproduction

Squash-merged at 0b794fc3d, pre-fix = 0b794fc3d~1. To show the behavioral gap I reverted only the OSS wiring packages/react-on-rails/src/ClientRenderer.ts (keeping the new rootErrorHandlers registry), then restored — so handlers are registered but never invoked.

cd packages/react-on-rails && pnpm exec jest tests/rootErrorHandlers.test.ts tests/rootErrorCallbacks.test.tsx

Results

Scenario Behavior Result
Pre-fix ClientRenderer.ts (no options passed) registered onRecoverableError/onCaughtError/onUncaughtError never fire 4 failed / 5 callbacks (BROKEN)
Post-fix restored each callback fires with enriched { componentName, domNodeId } context on the matching root path 32 passed / 32 (FIXED)
# PRE-FIX (ClientRenderer.ts @0b794fc3d~1)
✕ invokes the user onRecoverableError with enriched context on a forced hydration mismatch
✕ logs the branded dev-mode hydration message with the guide link in development
✕ invokes the user onUncaughtError ... (createRoot path)
✕ invokes the user onCaughtError ... (error boundary catches)
Tests: 4 failed, 1 passed, 5 total

# POST-FIX (restored, git status clean) — full suites incl. merge/snapshot/clear/legacy semantics
Tests: 32 passed, 32 total

The merged suites also cover the documented merge-per-key, snapshot-at-root-creation, single-key clear via undefined, resetOptions clears all, and the legacy-React no-op+console.warn semantics.

Caveat

Mechanism-level in jsdom: forced hydration mismatches and the createRoot/error-boundary paths exercise the real ClientRenderer and rootErrorHandlers registry. I did not run the dummy-app Playwright E2E (root_error_callbacks.spec.js) in a real browser, nor verify the React <18/18 legacy fallbacks against actually-installed older React versions (they're simulated in-suite).

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose React 19 root error callbacks (onRecoverableError & co.) + hydration-mismatch debugging guide

1 participant