Skip to content

Commit 0b794fc

Browse files
justin808claude
andauthored
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

.bundle-size-skip-branch

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
# This file allows skipping the bundle size CI check for a specific branch.
2-
# When a branch name in this file matches the PR branch, the size check is skipped.
3-
#
4-
# Usage: Run `bin/skip-bundle-size-check` to set the current branch, then commit and push.
5-
#
6-
# This is useful when you have an intentional size increase that exceeds the 0.5KB threshold.
7-
jg-conductor/3209-fix
1+
# Audited intentional growth for PR #3933 (rootErrorHandlers feature, +0.75KB gzip client):
2+
# 91% of the module-level delta is the new feature module; no accidental imports.
3+
# Audit evidence: https://github.com/shakacode/react_on_rails/pull/3933#issuecomment-4688270487
4+
# Clear this file in a follow-up after the PR merges.
5+
jg/3892-react19-root-error-callbacks

.lychee.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ exclude = [
8585
'^https://reactonrails\.com/docs/reference/error-reference(#.*)?$', # New generated docs page - live after docs deploy
8686
'^https://reactonrails\.com/docs/pro/react-server-components/rsc-payload-route-data/?$', # #3783 docs page - live after docs deploy
8787
'^https://www\.reactonrails\.com/llms(-full(-pro)?)?\.txt$', # Machine-readable docs endpoints; serving tracked in reactonrails.com#126
88+
'^https://reactonrails\.com/docs/building-features/debugging-hydration-mismatches/?$', # #3892 docs page - live after docs deploy
8889

8990
# ============================================================================
9091
# SITES FROM PROJECTS.MD THAT BLOCK BOTS OR ARE UNRELIABLE
@@ -120,7 +121,9 @@ exclude = [
120121
# auto-generated by changelog tooling. GitHub PR pages also intermittently
121122
# return 5xx responses to lychee in CI, even when the pages are live.
122123
'^https://github\.com/shakacode/react_on_rails/compare/',
123-
'^https://github\.com/shakacode/react_on_rails/pull/[0-9]+$', # Intermittent 5xx from GitHub PR pages in CI
124+
# PR pages intermittently return 5xx from CI; XXXX is the changelog placeholder used by
125+
# in-flight batch PRs until the coordinator substitutes the real PR number.
126+
'^https://github\.com/shakacode/react_on_rails/pull/([0-9]+|XXXX)$',
124127
'^https://github\.com/shakacode/react_on_rails/actions/workflows/pro-test-package-and-gem\.yml$', # Intermittent 502 from GitHub Actions workflow pages in CI
125128
'^https://github\.com/shakacode/react_on_rails/issues/2766$', # Intermittent 502 from GitHub issue pages in CI
126129
'^https://github\.com/rack/rack/blob/main/SPEC\.rdoc$', # Intermittent 502 from GitHub blob pages in CI

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
2424

2525
### [Unreleased]
2626

27+
#### Added
28+
29+
- **React 19 root error callbacks**: `ReactOnRails.setOptions({ rootErrorHandlers: { onRecoverableError, onCaughtError, onUncaughtError } })` registers React's root error callbacks globally; React on Rails applies them to every `hydrateRoot`/`createRoot` call it makes and invokes them with an extra context argument whose `componentName` and `domNodeId` fields are optional. In development, recoverable hydration errors now log an actionable React on Rails message (component name, dom id, component stack, and a link to the new [Debugging Hydration Mismatches guide](https://reactonrails.com/docs/building-features/debugging-hydration-mismatches)) alongside React's default error reporting, which stays intact so window-'error'-based tooling keeps working. Partial `rootErrorHandlers` updates merge per key, so registering one callback later does not drop the others. On React <19 (and <18 for `onRecoverableError`), React on Rails retains registrations for future upgrades, but the current runtime cannot invoke unsupported callbacks and logs a one-time console warning. On React on Rails Pro RSC/streaming hydration paths, user callbacks chain with (never replace) Pro's internal recoverable-error handler. Addresses [Issue 3892](https://github.com/shakacode/react_on_rails/issues/3892). [PR 3933](https://github.com/shakacode/react_on_rails/pull/3933) by [justin808](https://github.com/justin808).
30+
2731
#### Changed
2832

2933
- **[Pro]** **RSC peer compatibility accepts the coordinated React 19.2 floor**: The Pro node renderer now allows React and React DOM `19.2.x` starting at `19.2.7`, matching the floor required by the `react-on-rails-rsc` 19.2 package line while preserving the existing React `19.0.x` support window. Refs [Issue 3865](https://github.com/shakacode/react_on_rails/issues/3865).

docs/oss/api-reference/javascript-api.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,18 @@ reactHydrateOrRender(domNode, reactElement, hydrate);
7878
/**
7979
* Set options for ReactOnRails, typically before you call ReactOnRails.register
8080
* Available Options:
81-
* `traceTurbolinks: true|false Gives you debugging messages on Turbolinks events
81+
* `traceTurbolinks: true|false` Gives you debugging messages on Turbolinks events
82+
* `rootErrorHandlers: { onRecoverableError, onCaughtError, onUncaughtError }` React root error
83+
* callbacks applied to every React root created by React on Rails. Each callback receives
84+
* React's (error, errorInfo) plus a context object whose componentName and domNodeId fields
85+
* are optional.
86+
* Partial updates merge per key (setting one callback later keeps the others).
87+
* onRecoverableError requires React 18+; onCaughtError/onUncaughtError require React 19.
88+
* Unsupported React versions retain registrations for future upgrades, but the current runtime
89+
* cannot invoke unsupported callbacks and logs a one-time warning.
90+
* On React on Rails Pro RSC hydration roots, onRecoverableError is chained after Pro's
91+
* internal default reporting instead of replacing it; do not report the same error again there.
92+
* See https://reactonrails.com/docs/building-features/debugging-hydration-mismatches
8293
*/
8394
setOptions(options);
8495

0 commit comments

Comments
 (0)