UNPKG

react-on-rails

Version:

react-on-rails JavaScript for react_on_rails Ruby gem

288 lines 15.7 kB
import { supportsRootApi, supportsReact19RootErrorCallbacks } from "./reactApis.cjs"; import { getRailsContext } from "./context.js"; import { isThenable } from "./isThenable.js"; import captureReactOwnerStack, { isOwnerStackSupported } from "./captureReactOwnerStack.js"; /** * Guide linked from the development-mode hydration-mismatch message. * TODO(#3894): swap to the stable error-reference URL once error codes and reference pages land. * @internal */ export const HYDRATION_MISMATCH_GUIDE_URL = 'https://reactonrails.com/docs/building-features/debugging-hydration-mismatches'; const HANDLER_KEYS = [ 'onRecoverableError', 'onCaughtError', 'onUncaughtError', ]; const REACT_19_ONLY_HANDLER_KEYS = [ 'onCaughtError', 'onUncaughtError', ]; // Registered through `ReactOnRails.setOptions({ rootErrorHandlers })`; module-level so both the // core ClientRenderer and the Pro ClientSideRenderer (which imports this module from the same // `react-on-rails` package instance) read the same registration. let registeredHandlers = {}; let warnedMissingRootApi = false; // One-shot per reset cycle: the warning body names all React-19-only keys so split // registrations in the same cycle do not need repeated warnings. let warnedMissingReact19Callbacks = false; /** * Validates and stores the user's root error callbacks. Called by `ReactOnRails.setOptions`. * * Updates MERGE per key (matching how the other `setOptions` keys update independently): passing * only `onCaughtError` keeps a previously registered `onRecoverableError`/`onUncaughtError`. * Passing an explicit `undefined` for a key clears that key; `resetRootErrorHandlers` (via * `ReactOnRails.resetOptions`) clears all of them. Combined with the capture-at-root-creation * semantics in `buildRootErrorCallbackOptions`, changes only affect roots created afterwards. * * On React runtimes without root error callback support this still stores the handlers (so a * later React upgrade picks them up) but warns that they will never be called. */ export function setRootErrorHandlers(handlers) { if (handlers == null) { throw new Error(`Invalid ReactOnRails rootErrorHandlers option: expected an object, got ${handlers}. ` + 'Use undefined (or omit the key) to clear all handlers.'); } const unknownKeys = Object.keys(handlers).filter((key) => !HANDLER_KEYS.includes(key)); if (unknownKeys.length > 0) { throw new Error(`Invalid ReactOnRails rootErrorHandlers option: unknown key(s) ${unknownKeys.join(', ')}. ` + `Valid keys are: ${HANDLER_KEYS.join(', ')}.`); } HANDLER_KEYS.forEach((key) => { const value = handlers[key]; if (typeof value !== 'undefined' && typeof value !== 'function') { throw new Error(`Invalid ReactOnRails rootErrorHandlers option: ${key} must be a function, got ${value === null ? 'null' : typeof value}.`); } }); const providedKeys = HANDLER_KEYS.filter((key) => typeof handlers[key] === 'function'); if (providedKeys.length > 0 && !supportsRootApi) { if (!warnedMissingRootApi) { console.warn(`[ReactOnRails] rootErrorHandlers (${providedKeys.join(', ')}) require the React 18+ root APIs ` + '(hydrateRoot/createRoot). The registered callbacks will never be called with the current React version.'); warnedMissingRootApi = true; } } else if (!supportsReact19RootErrorCallbacks) { const react19OnlyKeys = providedKeys.filter((key) => REACT_19_ONLY_HANDLER_KEYS.includes(key)); if (react19OnlyKeys.length > 0 && !warnedMissingReact19Callbacks) { console.warn(`[ReactOnRails] rootErrorHandlers (${react19OnlyKeys.join(', ')}) require React 19. ` + 'Only onRecoverableError is supported on React 18; React 19-only callbacks ' + `(${REACT_19_ONLY_HANDLER_KEYS.join(', ')}) will never be called with the current React version.`); warnedMissingReact19Callbacks = true; } } // Per-key merge: keys absent from `handlers` keep their previous registration; keys explicitly // set to `undefined` are cleared. const merged = {}; HANDLER_KEYS.forEach((key) => { const next = Object.prototype.hasOwnProperty.call(handlers, key) ? handlers[key] : registeredHandlers[key]; if (typeof next === 'function') { merged[key] = next; } }); registeredHandlers = merged; } /** Clears the registered root error callbacks. Called by `ReactOnRails.resetOptions`. */ export function resetRootErrorHandlers() { registeredHandlers = {}; warnedMissingRootApi = false; warnedMissingReact19Callbacks = false; } /** * Returns a snapshot copy of the currently registered root error callbacks. A copy is returned so * callers cannot mutate the internal registration and bypass `setRootErrorHandlers` validation. */ export function getRootErrorHandlers() { return { ...registeredHandlers }; } // A failing user callback must not break React's own error recovery, so failures are logged // rather than propagated. Handlers are typed to return void, but an `async` handler (or one // returning a rejecting thenable) is still assignable to that type, so adopt any returned // thenable and swallow its rejection too — otherwise a root error could surface as an unhandled // promise rejection from the very callback meant to report it. function safeInvoke(handler, key, error, errorInfo, context) { const logHandlerFailure = (handlerError) => { console.error(`[ReactOnRails] The registered rootErrorHandlers.${key} callback threw while handling a root error:`, handlerError, 'Original root error:', error); }; // Re-type the void-returning handler so an async handler's returned promise can be inspected. const invoke = handler; try { const result = invoke(error, errorInfo, context); if (isThenable(result)) { // `Promise.resolve(...)` adopts non-native thenables that may lack `.catch`. Promise.resolve(result).catch(logHandlerFailure); } } catch (handlerError) { logHandlerFailure(handlerError); } } function inDevelopmentEnv() { // Called from client render paths after #js-react-on-rails-context is in the DOM; keep this // development-only so test suites opt into reporter assertions instead of getting noisy logs. if (typeof document === 'undefined') { return false; } return getRailsContext()?.railsEnv === 'development'; } /** * Mirrors React's own default `onRecoverableError` (`reportError` where available, else * `console.error`). Attaching a root callback replaces React's default reporting, so the * dev-mode logger must re-emit it itself — otherwise window-'error'-based tooling (dev overlays, * error trackers) goes silent in development. */ export function defaultReportRecoverableError(error) { if (typeof globalThis.reportError === 'function') { globalThis.reportError(error); } else { console.error(error); } } function extractComponentStack(errorInfo) { const componentStack = errorInfo?.componentStack; return typeof componentStack === 'string' && componentStack.length > 0 ? componentStack : undefined; } /** * React 19.2+ includes the owner stack on the `errorInfo` passed to `onCaughtError`/`onUncaughtError` * (and to `onRecoverableError` for hydration mismatches) via `errorInfo.ownerStack`. Prefer it when * present; callers fall back to a live `captureReactOwnerStack()` call for React 19.1, which exposes * the API but not the `errorInfo` field. */ function extractOwnerStack(errorInfo) { const ownerStack = errorInfo?.ownerStack; return typeof ownerStack === 'string' && ownerStack.trim().length > 0 ? ownerStack : undefined; } /** * Builds the supplemental "Owner stack" suffix for dev-mode error logs (issue #3887). * * MUST be called synchronously from inside React's error callback. `precomputedOwnerStack` is the * owner stack React already captured for this error (e.g. `errorInfo.ownerStack` on React 19.2+), * when available; otherwise we fall back to a live `captureReactOwnerStack()` call, which only * returns a value while React is still handling the error. Returns an empty string when no owner * stack is available — in particular on React < 19.1 and in production builds, where * `captureReactOwnerStack` is a strict no-op. */ function ownerStackSuffix(precomputedOwnerStack) { const ownerStack = (typeof precomputedOwnerStack === 'string' && precomputedOwnerStack.trim().length > 0 ? precomputedOwnerStack : undefined) ?? captureReactOwnerStack(); return ownerStack ? `\nOwner stack (the components that rendered this one):${ownerStack}` : ''; } /** * Branded, supplemental development-mode line: component name, dom id, component stack (when * React provides one), the owner stack (React >= 19.1 dev builds, issue #3887), and the * debugging-guide link. Deliberately does NOT dump the error object itself — the error is * default-reported exactly once elsewhere (by `defaultReportRecoverableError` on core paths, or by * Pro's internal recoverable-error handler on chained paths). */ function logDevHydrationError(context, errorInfo) { const componentName = context.componentName ?? 'unknown'; const domNodeId = context.domNodeId ?? 'unknown'; const componentStack = extractComponentStack(errorInfo); const componentStackSuffix = componentStack ? `\nComponent stack:${componentStack}` : ''; console.error(`[ReactOnRails] Recoverable hydration error in component "${componentName}" (dom id: "${domNodeId}"). The server-rendered HTML did not match what React rendered on the client, so React threw away the server HTML and re-rendered on the client. Common Rails-specific causes and fixes: ${HYDRATION_MISMATCH_GUIDE_URL}${componentStackSuffix}${ownerStackSuffix(extractOwnerStack(errorInfo))}`); } /** * Development-only supplemental line for render-path errors React reports through an app-registered * `onCaughtError`/`onUncaughtError` handler (issue #3887). Names the failing component/dom id and * appends the owner stack when React provides one. The error itself is reported by the app's own * handler (which we forward to), so this line is purely additive context. */ function logDevRenderError(kind, context, errorInfo) { const suffix = ownerStackSuffix(extractOwnerStack(errorInfo)); if (!suffix) { return; } const componentName = context.componentName ?? 'unknown'; const domNodeId = context.domNodeId ?? 'unknown'; const caughtNote = kind === 'onCaughtError' ? ' (caught by an error boundary)' : ''; console.error(`[ReactOnRails] Render error in component "${componentName}" (dom id: "${domNodeId}")${caughtNote}.${suffix}`); } /** * Builds the `hydrateRoot`/`createRoot` error callback options for one React root, wrapping the * user's registered handlers so they also receive `context` (component name and dom id). * * The handlers registered at root-creation time are CAPTURED into the returned wrappers (not * re-read on every error): attaching a root callback permanently replaces React's default * reporting for that callback on that root, so a wrapper that later re-read cleared handlers * would silently swallow errors. Roots therefore keep the handlers they were created with; * re-registering affects only roots created afterwards. * * When hydrating in Rails development mode, a React on Rails-branded hydration-mismatch line * (component name, dom id, component stack, guide link) is attached in addition to (and before) * any user `onRecoverableError`. React's default reporting is preserved: the error itself is * still default-reported once — via `defaultReportRecoverableError` here, or by the caller's own * reporting when `defaultReportingHandledInternally` is set. * * Returns `{}` when nothing needs to be attached so React's default error reporting stays * untouched, and on React <18 (the legacy `hydrate`/`render` APIs have no such options). */ export function buildRootErrorCallbackOptions(context, hydrating, { defaultReportingHandledInternally = false } = {}) { if (!supportsRootApi) { return {}; } const options = {}; const { onRecoverableError, onCaughtError, onUncaughtError } = registeredHandlers; // Capture once at root creation; the callback does not re-check the Rails env per error. const logDevDefault = hydrating && inDevelopmentEnv(); if (logDevDefault || onRecoverableError) { options.onRecoverableError = (error, errorInfo) => { if (logDevDefault) { if (!defaultReportingHandledInternally) { defaultReportRecoverableError(error); } logDevHydrationError(context, errorInfo); } if (onRecoverableError) { safeInvoke(onRecoverableError, 'onRecoverableError', error, errorInfo, context); } }; } if (supportsReact19RootErrorCallbacks) { // Owner-stack enrichment for client render errors (issue #3887). We only enrich when the app has // registered its own onCaughtError/onUncaughtError handler: providing one already replaces // React's default reporting for that callback, so prepending our supplemental dev owner-stack // line is purely additive. We deliberately do NOT auto-attach a wrapper when the app registered // no handler — that would displace React's built-in dev diagnostics (component stack, // error-boundary hints) that we cannot faithfully reproduce, a net loss. Owner stacks still reach // users automatically on the two paths React on Rails already owns: SSR errors (the Pro streaming // onError path) and hydration mismatches (the onRecoverableError path above). // // The owner-stack line is only emitted on React >= 19.1 dev builds (`isOwnerStackSupported()`); // otherwise the wrapper just forwards to the app handler unchanged. const enrichDevOwnerStack = inDevelopmentEnv() && isOwnerStackSupported(); if (onCaughtError) { options.onCaughtError = (error, errorInfo) => { if (enrichDevOwnerStack) { logDevRenderError('onCaughtError', context, errorInfo); } safeInvoke(onCaughtError, 'onCaughtError', error, errorInfo, context); }; } if (onUncaughtError) { options.onUncaughtError = (error, errorInfo) => { if (enrichDevOwnerStack) { logDevRenderError('onUncaughtError', context, errorInfo); } safeInvoke(onUncaughtError, 'onUncaughtError', error, errorInfo, context); }; } } return options; } /** * Pro RSC hydration wraps the returned `onRecoverableError` with an internal handler that has already * performed React's default recoverable-error reporting. Keep that invariant in one named helper so * Pro call sites do not need to remember the lower-level `defaultReportingHandledInternally` flag. * * On non-hydrate (`createRoot`) paths, `defaultReportingHandledInternally` is false, so this * degrades to `buildRootErrorCallbackOptions` with no reporting-behavior change. */ export function buildRootErrorCallbackOptionsWithInternalRecoverableErrorReporting(context, hydrating) { return buildRootErrorCallbackOptions(context, hydrating, { defaultReportingHandledInternally: hydrating, }); } //# sourceMappingURL=rootErrorHandlers.js.map