Commit 0b794fc
Expose React 19 root error callbacks (rootErrorHandlers) + hydration-mismatch debugging guide (#3933)
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:
```js
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](https://github.com/shakacode/react_on_rails/blob/jg/3892-react19-root-error-callbacks/docs/oss/building-features/debugging-hydration-mismatches.md)
— 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)
- **Per-component override** of root error handlers (triage: mechanism
vague). This PR ships global-only and documents the global API as the
blessed route. No follow-up issue created per repo policy; tracked in
#3892's thread.
- **Stable error codes/reference URLs** depend on #3894 (unmerged). The
guide link is a single swappable constant `HYDRATION_MISMATCH_GUIDE_URL`
in `rootErrorHandlers.ts` with a `TODO(#3894)`.
## 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` (`reportError` → `console.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](https://claude.com/claude-code)
<!-- CURSOR_SUMMARY -->
---
> [!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).
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
72decd2. 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**
* 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Merge Readiness Criteria
Current evaluation as of 2026-06-15 for head
`72decd2979a3b036f248c9a889d2b1d5fa9f2e15`.
- **Release mode:** `accelerated-rc`, confirmed live from canonical
release gate #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.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>1 parent f983e66 commit 0b794fc
31 files changed
Lines changed: 2483 additions & 75 deletions
File tree
- docs
- oss
- api-reference
- building-features
- packages
- react-on-rails-pro
- src
- wrapServerComponentRenderer
- tests
- react-on-rails
- src
- base
- capabilities
- types
- tests
- react_on_rails/spec/dummy
- app/views/pages
- client/app
- packs
- startup
- config
- e2e/playwright/e2e/react_on_rails
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
2 | | - | |
3 | | - | |
4 | | - | |
5 | | - | |
6 | | - | |
7 | | - | |
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| 88 | + | |
88 | 89 | | |
89 | 90 | | |
90 | 91 | | |
| |||
120 | 121 | | |
121 | 122 | | |
122 | 123 | | |
123 | | - | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
124 | 127 | | |
125 | 128 | | |
126 | 129 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
27 | 31 | | |
28 | 32 | | |
29 | 33 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
78 | 78 | | |
79 | 79 | | |
80 | 80 | | |
81 | | - | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
82 | 93 | | |
83 | 94 | | |
84 | 95 | | |
| |||
0 commit comments