react-on-rails 17.0.0-rc.2 → 17.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,13 +7,9 @@ import { isServerRenderHash } from "./isServerRenderResult.js";
7
7
  import { onPageUnloaded } from "./pageLifecycle.js";
8
8
  import { supportsRootApi, unmountComponentAtNode } from "./reactApis.cjs";
9
9
  import { isRendererTeardownResult } from "./rendererTeardown.js";
10
+ import { buildRootErrorCallbackOptions } from "./rootErrorHandlers.js";
11
+ import { isThenable } from "./isThenable.js";
10
12
  const REACT_ON_RAILS_STORE_ATTRIBUTE = 'data-js-react-on-rails-store';
11
- /** Narrows an unknown value to a thenable (has a callable `.then`) without assuming a native Promise. */
12
- function isThenable(value) {
13
- return (value != null &&
14
- (typeof value === 'object' || typeof value === 'function') &&
15
- typeof value.then === 'function');
16
- }
17
13
  // Track all rendered roots for cleanup
18
14
  const renderedRoots = new Map();
19
15
  /**
@@ -229,7 +225,10 @@ You returned a server side type of react-router error: ${JSON.stringify(reactEle
229
225
  You should return a React.Component always for the client side entry point.`);
230
226
  }
231
227
  else {
232
- const root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate);
228
+ const root = reactHydrateOrRender(domNode, reactElementOrRouterResult, shouldHydrate,
229
+ // Attach user-registered root error callbacks (and the dev-mode hydration-mismatch
230
+ // logger) to every root, enriched with this mount's component name and dom id.
231
+ buildRootErrorCallbackOptions({ componentName: name || undefined, domNodeId: domNodeId || undefined }, shouldHydrate));
233
232
  // Track the root for cleanup
234
233
  renderedRoots.set(domNodeId, { kind: 'react', root, domNode });
235
234
  }
@@ -7,6 +7,7 @@ import buildConsoleReplay, { consoleReplay } from "../buildConsoleReplay.js";
7
7
  import reactHydrateOrRender from "../reactHydrateOrRender.js";
8
8
  import createReactOutput from "../createReactOutput.js";
9
9
  import componentRegistrationMetric from "../componentRegistrationMetric.js";
10
+ import { buildRootErrorCallbackOptions, getRootErrorHandlers, resetRootErrorHandlers, setRootErrorHandlers, } from "../rootErrorHandlers.js";
10
11
  const DEFAULT_OPTIONS = {
11
12
  traceTurbolinks: false,
12
13
  turbo: false,
@@ -73,37 +74,46 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
73
74
  return Authenticity.authenticityHeaders(otherHeaders);
74
75
  },
75
76
  reactHydrateOrRender(domNode, reactElement, hydrate) {
76
- return reactHydrateOrRender(domNode, reactElement, hydrate);
77
+ // The component name is unknown on this low-level path; the dom id still ties errors to a mount.
78
+ const rootErrorCallbackOptions = buildRootErrorCallbackOptions({ domNodeId: domNode.id || undefined }, hydrate);
79
+ return reactHydrateOrRender(domNode, reactElement, hydrate, rootErrorCallbackOptions);
77
80
  },
78
81
  setOptions(newOptions) {
79
- if (typeof newOptions.traceTurbolinks !== 'undefined') {
80
- this.options.traceTurbolinks = newOptions.traceTurbolinks;
81
- // eslint-disable-next-line no-param-reassign
82
- delete newOptions.traceTurbolinks;
82
+ const { traceTurbolinks, turbo, debugMode, logComponentRegistration, rootErrorHandlers, ...rest } = newOptions;
83
+ if (typeof traceTurbolinks !== 'undefined') {
84
+ this.options.traceTurbolinks = traceTurbolinks;
83
85
  }
84
- if (typeof newOptions.turbo !== 'undefined') {
85
- this.options.turbo = newOptions.turbo;
86
- // eslint-disable-next-line no-param-reassign
87
- delete newOptions.turbo;
86
+ if (typeof turbo !== 'undefined') {
87
+ this.options.turbo = turbo;
88
88
  }
89
- if (typeof newOptions.debugMode !== 'undefined') {
90
- this.options.debugMode = newOptions.debugMode;
91
- if (newOptions.debugMode) {
89
+ if (typeof debugMode !== 'undefined') {
90
+ this.options.debugMode = debugMode;
91
+ if (debugMode) {
92
92
  console.log('[ReactOnRails] Debug mode enabled');
93
93
  }
94
- // eslint-disable-next-line no-param-reassign
95
- delete newOptions.debugMode;
96
94
  }
97
- if (typeof newOptions.logComponentRegistration !== 'undefined') {
98
- this.options.logComponentRegistration = newOptions.logComponentRegistration;
99
- if (newOptions.logComponentRegistration) {
95
+ if (typeof logComponentRegistration !== 'undefined') {
96
+ this.options.logComponentRegistration = logComponentRegistration;
97
+ if (logComponentRegistration) {
100
98
  console.log('[ReactOnRails] Component registration logging enabled');
101
99
  }
102
- // eslint-disable-next-line no-param-reassign
103
- delete newOptions.logComponentRegistration;
104
100
  }
105
- if (Object.keys(newOptions).length > 0) {
106
- throw new Error(`Invalid options passed to ReactOnRails.options: ${JSON.stringify(newOptions)}`);
101
+ if (Object.prototype.hasOwnProperty.call(newOptions, 'rootErrorHandlers')) {
102
+ // MUST SYNC: sibling implementation exists in packages/react-on-rails/src/capabilities/core.ts.
103
+ // Validates and merges the handlers per key (partial updates keep previously registered
104
+ // callbacks); warns when the React runtime cannot support them. Store the merged result so
105
+ // `option('rootErrorHandlers')` reflects the effective registration.
106
+ if (typeof rootErrorHandlers === 'undefined') {
107
+ resetRootErrorHandlers();
108
+ this.options.rootErrorHandlers = undefined;
109
+ }
110
+ else {
111
+ setRootErrorHandlers(rootErrorHandlers);
112
+ this.options.rootErrorHandlers = getRootErrorHandlers();
113
+ }
114
+ }
115
+ if (Object.keys(rest).length > 0) {
116
+ throw new Error(`Invalid options passed to ReactOnRails.options: ${JSON.stringify(rest)}`);
107
117
  }
108
118
  },
109
119
  option(key) {
@@ -117,6 +127,7 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
117
127
  },
118
128
  resetOptions() {
119
129
  this.options = { ...DEFAULT_OPTIONS };
130
+ resetRootErrorHandlers();
120
131
  },
121
132
  // ===================================================================
122
133
  // REGISTRY METHOD IMPLEMENTATIONS - Using provided registries
@@ -181,7 +192,7 @@ Fix: Use only react-on-rails OR react-on-rails-pro, not both.`);
181
192
  render(name, props, domNodeId, hydrate) {
182
193
  const componentObj = ComponentRegistry.get(name);
183
194
  const reactElement = createReactOutput({ componentObj, props, domNodeId });
184
- return this.reactHydrateOrRender(document.getElementById(domNodeId), reactElement, hydrate);
195
+ return reactHydrateOrRender(document.getElementById(domNodeId), reactElement, hydrate, buildRootErrorCallbackOptions({ componentName: name || undefined, domNodeId: domNodeId || undefined }, hydrate));
185
196
  },
186
197
  // ===================================================================
187
198
  // CLIENT-SIDE RENDERING STUBS - To be overridden by createReactOnRails
@@ -3,6 +3,7 @@ import buildConsoleReplay, { consoleReplay } from "../buildConsoleReplay.js";
3
3
  import reactHydrateOrRender from "../reactHydrateOrRender.js";
4
4
  import createReactOutput from "../createReactOutput.js";
5
5
  import componentRegistrationMetric from "../componentRegistrationMetric.js";
6
+ import { buildRootErrorCallbackOptions, getRootErrorHandlers, resetRootErrorHandlers, setRootErrorHandlers, } from "../rootErrorHandlers.js";
6
7
  const DEFAULT_OPTIONS = {
7
8
  traceTurbolinks: false,
8
9
  turbo: false,
@@ -28,10 +29,12 @@ export function createCoreCapability(registries) {
28
29
  return Authenticity.authenticityHeaders(otherHeaders);
29
30
  },
30
31
  reactHydrateOrRender(domNode, reactElement, hydrate) {
31
- return reactHydrateOrRender(domNode, reactElement, hydrate);
32
+ // The component name is unknown on this low-level path; the dom id still ties errors to a mount.
33
+ const rootErrorCallbackOptions = buildRootErrorCallbackOptions({ domNodeId: domNode.id || undefined }, hydrate);
34
+ return reactHydrateOrRender(domNode, reactElement, hydrate, rootErrorCallbackOptions);
32
35
  },
33
36
  setOptions(newOptions) {
34
- const { traceTurbolinks, turbo, debugMode, logComponentRegistration, ...rest } = newOptions;
37
+ const { traceTurbolinks, turbo, debugMode, logComponentRegistration, rootErrorHandlers, ...rest } = newOptions;
35
38
  if (typeof traceTurbolinks !== 'undefined') {
36
39
  this.options.traceTurbolinks = traceTurbolinks;
37
40
  }
@@ -50,6 +53,20 @@ export function createCoreCapability(registries) {
50
53
  console.log('[ReactOnRails] Component registration logging enabled');
51
54
  }
52
55
  }
56
+ if (Object.prototype.hasOwnProperty.call(newOptions, 'rootErrorHandlers')) {
57
+ // MUST SYNC: sibling implementation exists in packages/react-on-rails/src/base/client.ts.
58
+ // Validates and merges the handlers per key (partial updates keep previously registered
59
+ // callbacks); warns when the React runtime cannot support them. Store the merged result so
60
+ // `option('rootErrorHandlers')` reflects the effective registration.
61
+ if (typeof rootErrorHandlers === 'undefined') {
62
+ resetRootErrorHandlers();
63
+ this.options.rootErrorHandlers = undefined;
64
+ }
65
+ else {
66
+ setRootErrorHandlers(rootErrorHandlers);
67
+ this.options.rootErrorHandlers = getRootErrorHandlers();
68
+ }
69
+ }
53
70
  if (Object.keys(rest).length > 0) {
54
71
  throw new Error(`Invalid options passed to ReactOnRails.options: ${JSON.stringify(rest)}`);
55
72
  }
@@ -65,6 +82,7 @@ export function createCoreCapability(registries) {
65
82
  },
66
83
  resetOptions() {
67
84
  this.options = { ...DEFAULT_OPTIONS };
85
+ resetRootErrorHandlers();
68
86
  },
69
87
  // ===================================================================
70
88
  // REGISTRY METHOD IMPLEMENTATIONS - Using provided registries
@@ -129,7 +147,7 @@ export function createCoreCapability(registries) {
129
147
  render(name, props, domNodeId, hydrate) {
130
148
  const componentObj = ComponentRegistry.get(name);
131
149
  const reactElement = createReactOutput({ componentObj, props, domNodeId });
132
- return this.reactHydrateOrRender(document.getElementById(domNodeId), reactElement, hydrate);
150
+ return reactHydrateOrRender(document.getElementById(domNodeId), reactElement, hydrate, buildRootErrorCallbackOptions({ componentName: name || undefined, domNodeId: domNodeId || undefined }, hydrate));
133
151
  },
134
152
  // ===================================================================
135
153
  // SSR STUBS — overridden by createSSRCapability() in full/node bundles
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Narrows an unknown value to a thenable (has a callable `.then`) without assuming a native
3
+ * Promise. Shared by the core ClientRenderer, rootErrorHandlers, and the Pro ClientSideRenderer
4
+ * (via `react-on-rails/@internal/isThenable`) so non-native thenables are handled identically
5
+ * everywhere.
6
+ */
7
+ export declare function isThenable(value: unknown): value is PromiseLike<unknown>;
8
+ //# sourceMappingURL=isThenable.d.ts.map
@@ -0,0 +1,13 @@
1
+ /* eslint-disable import/prefer-default-export -- named export for grep-ability and consistency with other @internal helpers */
2
+ /**
3
+ * Narrows an unknown value to a thenable (has a callable `.then`) without assuming a native
4
+ * Promise. Shared by the core ClientRenderer, rootErrorHandlers, and the Pro ClientSideRenderer
5
+ * (via `react-on-rails/@internal/isThenable`) so non-native thenables are handled identically
6
+ * everywhere.
7
+ */
8
+ export function isThenable(value) {
9
+ return (value != null &&
10
+ (typeof value === 'object' || typeof value === 'function') &&
11
+ typeof value.then === 'function');
12
+ }
13
+ //# sourceMappingURL=isThenable.js.map
package/lib/reactApis.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensureReactUseAvailable = exports.unmountComponentAtNode = exports.reactHydrate = exports.supportsHydrate = exports.supportsRootApi = void 0;
3
+ exports.ensureReactUseAvailable = exports.unmountComponentAtNode = exports.reactHydrate = exports.supportsReact19RootErrorCallbacks = exports.supportsHydrate = exports.supportsRootApi = void 0;
4
4
  exports.reactRender = reactRender;
5
5
  /* eslint-disable global-require,@typescript-eslint/no-require-imports */
6
6
  const React = require("react");
@@ -9,6 +9,9 @@ const reactMajorVersion = Number(ReactDOM.version?.split('.')[0]) || 16;
9
9
  // TODO: once we require React 18, we can remove this and inline everything guarded by it.
10
10
  exports.supportsRootApi = reactMajorVersion >= 18;
11
11
  exports.supportsHydrate = exports.supportsRootApi || 'hydrate' in ReactDOM;
12
+ // React 19 added the `onCaughtError`/`onUncaughtError` root options. React 18's root API only
13
+ // supports `identifierPrefix` and `onRecoverableError`.
14
+ exports.supportsReact19RootErrorCallbacks = reactMajorVersion >= 19;
12
15
  // TODO: once React dependency is updated to >= 18, we can remove this and just
13
16
  // import ReactDOM from 'react-dom/client';
14
17
  let reactDomClient;
@@ -2,6 +2,7 @@ import type { ReactElement } from 'react';
2
2
  import type { RenderReturnType } from './types/index.ts' with { 'resolution-mode': 'import' };
3
3
  export declare const supportsRootApi: boolean;
4
4
  export declare const supportsHydrate: boolean;
5
+ export declare const supportsReact19RootErrorCallbacks: boolean;
5
6
  export type ReactHydrateOptions = {
6
7
  identifierPrefix?: string;
7
8
  onCaughtError?: (error: unknown, errorInfo: unknown) => void;
@@ -0,0 +1,82 @@
1
+ import type { RootErrorContext, RootErrorHandlers } from './types/index.ts';
2
+ import type { ReactHydrateOptions } from './reactApis.cts';
3
+ /**
4
+ * Guide linked from the development-mode hydration-mismatch message.
5
+ * TODO(#3894): swap to the stable error-reference URL once error codes and reference pages land.
6
+ * @internal
7
+ */
8
+ export declare const HYDRATION_MISMATCH_GUIDE_URL = "https://reactonrails.com/docs/building-features/debugging-hydration-mismatches";
9
+ /**
10
+ * Validates and stores the user's root error callbacks. Called by `ReactOnRails.setOptions`.
11
+ *
12
+ * Updates MERGE per key (matching how the other `setOptions` keys update independently): passing
13
+ * only `onCaughtError` keeps a previously registered `onRecoverableError`/`onUncaughtError`.
14
+ * Passing an explicit `undefined` for a key clears that key; `resetRootErrorHandlers` (via
15
+ * `ReactOnRails.resetOptions`) clears all of them. Combined with the capture-at-root-creation
16
+ * semantics in `buildRootErrorCallbackOptions`, changes only affect roots created afterwards.
17
+ *
18
+ * On React runtimes without root error callback support this still stores the handlers (so a
19
+ * later React upgrade picks them up) but warns that they will never be called.
20
+ */
21
+ export declare function setRootErrorHandlers(handlers: RootErrorHandlers): void;
22
+ /** Clears the registered root error callbacks. Called by `ReactOnRails.resetOptions`. */
23
+ export declare function resetRootErrorHandlers(): void;
24
+ /**
25
+ * Returns a snapshot copy of the currently registered root error callbacks. A copy is returned so
26
+ * callers cannot mutate the internal registration and bypass `setRootErrorHandlers` validation.
27
+ */
28
+ export declare function getRootErrorHandlers(): RootErrorHandlers;
29
+ /**
30
+ * Mirrors React's own default `onRecoverableError` (`reportError` where available, else
31
+ * `console.error`). Attaching a root callback replaces React's default reporting, so the
32
+ * dev-mode logger must re-emit it itself — otherwise window-'error'-based tooling (dev overlays,
33
+ * error trackers) goes silent in development.
34
+ */
35
+ export declare function defaultReportRecoverableError(error: unknown): void;
36
+ type RootErrorCallbackOptions = Pick<ReactHydrateOptions, 'onRecoverableError' | 'onCaughtError' | 'onUncaughtError'>;
37
+ /** @internal Used by Pro via the `@internal/rootErrorHandlers` alias; not part of the public API. */
38
+ export interface BuildRootErrorCallbackOptionsExtras {
39
+ /**
40
+ * Set by Pro callers that chain their own default reporting around the returned
41
+ * `onRecoverableError` via `chainRecoverableErrorHandlers` (see
42
+ * `handleRecoverableError.client.ts`). When true, the dev-mode logger emits only its branded
43
+ * supplemental line and skips `defaultReportRecoverableError`, so each recoverable error is
44
+ * default-reported exactly once.
45
+ *
46
+ * New Pro hydrate paths that call `chainRecoverableErrorHandlers` should use
47
+ * `buildRootErrorCallbackOptionsWithInternalRecoverableErrorReporting` instead of setting this
48
+ * low-level flag directly; omitting it causes double-reporting in development.
49
+ */
50
+ defaultReportingHandledInternally?: boolean;
51
+ }
52
+ /**
53
+ * Builds the `hydrateRoot`/`createRoot` error callback options for one React root, wrapping the
54
+ * user's registered handlers so they also receive `context` (component name and dom id).
55
+ *
56
+ * The handlers registered at root-creation time are CAPTURED into the returned wrappers (not
57
+ * re-read on every error): attaching a root callback permanently replaces React's default
58
+ * reporting for that callback on that root, so a wrapper that later re-read cleared handlers
59
+ * would silently swallow errors. Roots therefore keep the handlers they were created with;
60
+ * re-registering affects only roots created afterwards.
61
+ *
62
+ * When hydrating in Rails development mode, a React on Rails-branded hydration-mismatch line
63
+ * (component name, dom id, component stack, guide link) is attached in addition to (and before)
64
+ * any user `onRecoverableError`. React's default reporting is preserved: the error itself is
65
+ * still default-reported once — via `defaultReportRecoverableError` here, or by the caller's own
66
+ * reporting when `defaultReportingHandledInternally` is set.
67
+ *
68
+ * Returns `{}` when nothing needs to be attached so React's default error reporting stays
69
+ * untouched, and on React <18 (the legacy `hydrate`/`render` APIs have no such options).
70
+ */
71
+ export declare function buildRootErrorCallbackOptions(context: RootErrorContext, hydrating: boolean, { defaultReportingHandledInternally }?: BuildRootErrorCallbackOptionsExtras): RootErrorCallbackOptions;
72
+ /**
73
+ * Pro RSC hydration wraps the returned `onRecoverableError` with an internal handler that has already
74
+ * performed React's default recoverable-error reporting. Keep that invariant in one named helper so
75
+ * Pro call sites do not need to remember the lower-level `defaultReportingHandledInternally` flag.
76
+ *
77
+ * On non-hydrate (`createRoot`) paths, `defaultReportingHandledInternally` is false, so this
78
+ * degrades to `buildRootErrorCallbackOptions` with no reporting-behavior change.
79
+ */
80
+ export declare function buildRootErrorCallbackOptionsWithInternalRecoverableErrorReporting(context: RootErrorContext, hydrating: boolean): RootErrorCallbackOptions;
81
+ export {};
82
+ //# sourceMappingURL=rootErrorHandlers.d.ts.map
@@ -0,0 +1,222 @@
1
+ import { supportsRootApi, supportsReact19RootErrorCallbacks } from "./reactApis.cjs";
2
+ import { getRailsContext } from "./context.js";
3
+ import { isThenable } from "./isThenable.js";
4
+ /**
5
+ * Guide linked from the development-mode hydration-mismatch message.
6
+ * TODO(#3894): swap to the stable error-reference URL once error codes and reference pages land.
7
+ * @internal
8
+ */
9
+ export const HYDRATION_MISMATCH_GUIDE_URL = 'https://reactonrails.com/docs/building-features/debugging-hydration-mismatches';
10
+ const HANDLER_KEYS = [
11
+ 'onRecoverableError',
12
+ 'onCaughtError',
13
+ 'onUncaughtError',
14
+ ];
15
+ const REACT_19_ONLY_HANDLER_KEYS = [
16
+ 'onCaughtError',
17
+ 'onUncaughtError',
18
+ ];
19
+ // Registered through `ReactOnRails.setOptions({ rootErrorHandlers })`; module-level so both the
20
+ // core ClientRenderer and the Pro ClientSideRenderer (which imports this module from the same
21
+ // `react-on-rails` package instance) read the same registration.
22
+ let registeredHandlers = {};
23
+ let warnedMissingRootApi = false;
24
+ // One-shot per reset cycle: the warning body names all React-19-only keys so split
25
+ // registrations in the same cycle do not need repeated warnings.
26
+ let warnedMissingReact19Callbacks = false;
27
+ /**
28
+ * Validates and stores the user's root error callbacks. Called by `ReactOnRails.setOptions`.
29
+ *
30
+ * Updates MERGE per key (matching how the other `setOptions` keys update independently): passing
31
+ * only `onCaughtError` keeps a previously registered `onRecoverableError`/`onUncaughtError`.
32
+ * Passing an explicit `undefined` for a key clears that key; `resetRootErrorHandlers` (via
33
+ * `ReactOnRails.resetOptions`) clears all of them. Combined with the capture-at-root-creation
34
+ * semantics in `buildRootErrorCallbackOptions`, changes only affect roots created afterwards.
35
+ *
36
+ * On React runtimes without root error callback support this still stores the handlers (so a
37
+ * later React upgrade picks them up) but warns that they will never be called.
38
+ */
39
+ export function setRootErrorHandlers(handlers) {
40
+ if (handlers == null) {
41
+ throw new Error(`Invalid ReactOnRails rootErrorHandlers option: expected an object, got ${handlers}. ` +
42
+ 'Use undefined (or omit the key) to clear all handlers.');
43
+ }
44
+ const unknownKeys = Object.keys(handlers).filter((key) => !HANDLER_KEYS.includes(key));
45
+ if (unknownKeys.length > 0) {
46
+ throw new Error(`Invalid ReactOnRails rootErrorHandlers option: unknown key(s) ${unknownKeys.join(', ')}. ` +
47
+ `Valid keys are: ${HANDLER_KEYS.join(', ')}.`);
48
+ }
49
+ HANDLER_KEYS.forEach((key) => {
50
+ const value = handlers[key];
51
+ if (typeof value !== 'undefined' && typeof value !== 'function') {
52
+ throw new Error(`Invalid ReactOnRails rootErrorHandlers option: ${key} must be a function, got ${value === null ? 'null' : typeof value}.`);
53
+ }
54
+ });
55
+ const providedKeys = HANDLER_KEYS.filter((key) => typeof handlers[key] === 'function');
56
+ if (providedKeys.length > 0 && !supportsRootApi) {
57
+ if (!warnedMissingRootApi) {
58
+ console.warn(`[ReactOnRails] rootErrorHandlers (${providedKeys.join(', ')}) require the React 18+ root APIs ` +
59
+ '(hydrateRoot/createRoot). The registered callbacks will never be called with the current React version.');
60
+ warnedMissingRootApi = true;
61
+ }
62
+ }
63
+ else if (!supportsReact19RootErrorCallbacks) {
64
+ const react19OnlyKeys = providedKeys.filter((key) => REACT_19_ONLY_HANDLER_KEYS.includes(key));
65
+ if (react19OnlyKeys.length > 0 && !warnedMissingReact19Callbacks) {
66
+ console.warn(`[ReactOnRails] rootErrorHandlers (${react19OnlyKeys.join(', ')}) require React 19. ` +
67
+ 'Only onRecoverableError is supported on React 18; React 19-only callbacks ' +
68
+ `(${REACT_19_ONLY_HANDLER_KEYS.join(', ')}) will never be called with the current React version.`);
69
+ warnedMissingReact19Callbacks = true;
70
+ }
71
+ }
72
+ // Per-key merge: keys absent from `handlers` keep their previous registration; keys explicitly
73
+ // set to `undefined` are cleared.
74
+ const merged = {};
75
+ HANDLER_KEYS.forEach((key) => {
76
+ const next = Object.prototype.hasOwnProperty.call(handlers, key)
77
+ ? handlers[key]
78
+ : registeredHandlers[key];
79
+ if (typeof next === 'function') {
80
+ merged[key] = next;
81
+ }
82
+ });
83
+ registeredHandlers = merged;
84
+ }
85
+ /** Clears the registered root error callbacks. Called by `ReactOnRails.resetOptions`. */
86
+ export function resetRootErrorHandlers() {
87
+ registeredHandlers = {};
88
+ warnedMissingRootApi = false;
89
+ warnedMissingReact19Callbacks = false;
90
+ }
91
+ /**
92
+ * Returns a snapshot copy of the currently registered root error callbacks. A copy is returned so
93
+ * callers cannot mutate the internal registration and bypass `setRootErrorHandlers` validation.
94
+ */
95
+ export function getRootErrorHandlers() {
96
+ return { ...registeredHandlers };
97
+ }
98
+ // A failing user callback must not break React's own error recovery, so failures are logged
99
+ // rather than propagated. Handlers are typed to return void, but an `async` handler (or one
100
+ // returning a rejecting thenable) is still assignable to that type, so adopt any returned
101
+ // thenable and swallow its rejection too — otherwise a root error could surface as an unhandled
102
+ // promise rejection from the very callback meant to report it.
103
+ function safeInvoke(handler, key, error, errorInfo, context) {
104
+ const logHandlerFailure = (handlerError) => {
105
+ console.error(`[ReactOnRails] The registered rootErrorHandlers.${key} callback threw while handling a root error:`, handlerError, 'Original root error:', error);
106
+ };
107
+ // Re-type the void-returning handler so an async handler's returned promise can be inspected.
108
+ const invoke = handler;
109
+ try {
110
+ const result = invoke(error, errorInfo, context);
111
+ if (isThenable(result)) {
112
+ // `Promise.resolve(...)` adopts non-native thenables that may lack `.catch`.
113
+ Promise.resolve(result).catch(logHandlerFailure);
114
+ }
115
+ }
116
+ catch (handlerError) {
117
+ logHandlerFailure(handlerError);
118
+ }
119
+ }
120
+ function inDevelopmentEnv() {
121
+ // Called from client render paths after #js-react-on-rails-context is in the DOM; keep this
122
+ // development-only so test suites opt into reporter assertions instead of getting noisy logs.
123
+ if (typeof document === 'undefined') {
124
+ return false;
125
+ }
126
+ return getRailsContext()?.railsEnv === 'development';
127
+ }
128
+ /**
129
+ * Mirrors React's own default `onRecoverableError` (`reportError` where available, else
130
+ * `console.error`). Attaching a root callback replaces React's default reporting, so the
131
+ * dev-mode logger must re-emit it itself — otherwise window-'error'-based tooling (dev overlays,
132
+ * error trackers) goes silent in development.
133
+ */
134
+ export function defaultReportRecoverableError(error) {
135
+ if (typeof globalThis.reportError === 'function') {
136
+ globalThis.reportError(error);
137
+ }
138
+ else {
139
+ console.error(error);
140
+ }
141
+ }
142
+ function extractComponentStack(errorInfo) {
143
+ const componentStack = errorInfo?.componentStack;
144
+ return typeof componentStack === 'string' && componentStack.length > 0 ? componentStack : undefined;
145
+ }
146
+ /**
147
+ * Branded, supplemental development-mode line: component name, dom id, component stack (when
148
+ * React provides one), and the debugging-guide link. Deliberately does NOT dump the error object
149
+ * itself — the error is default-reported exactly once elsewhere (by `defaultReportRecoverableError`
150
+ * on core paths, or by Pro's internal recoverable-error handler on chained paths).
151
+ */
152
+ function logDevHydrationError(context, errorInfo) {
153
+ const componentName = context.componentName ?? 'unknown';
154
+ const domNodeId = context.domNodeId ?? 'unknown';
155
+ const componentStack = extractComponentStack(errorInfo);
156
+ const componentStackSuffix = componentStack ? `\nComponent stack:${componentStack}` : '';
157
+ 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}`);
158
+ }
159
+ /**
160
+ * Builds the `hydrateRoot`/`createRoot` error callback options for one React root, wrapping the
161
+ * user's registered handlers so they also receive `context` (component name and dom id).
162
+ *
163
+ * The handlers registered at root-creation time are CAPTURED into the returned wrappers (not
164
+ * re-read on every error): attaching a root callback permanently replaces React's default
165
+ * reporting for that callback on that root, so a wrapper that later re-read cleared handlers
166
+ * would silently swallow errors. Roots therefore keep the handlers they were created with;
167
+ * re-registering affects only roots created afterwards.
168
+ *
169
+ * When hydrating in Rails development mode, a React on Rails-branded hydration-mismatch line
170
+ * (component name, dom id, component stack, guide link) is attached in addition to (and before)
171
+ * any user `onRecoverableError`. React's default reporting is preserved: the error itself is
172
+ * still default-reported once — via `defaultReportRecoverableError` here, or by the caller's own
173
+ * reporting when `defaultReportingHandledInternally` is set.
174
+ *
175
+ * Returns `{}` when nothing needs to be attached so React's default error reporting stays
176
+ * untouched, and on React <18 (the legacy `hydrate`/`render` APIs have no such options).
177
+ */
178
+ export function buildRootErrorCallbackOptions(context, hydrating, { defaultReportingHandledInternally = false } = {}) {
179
+ if (!supportsRootApi) {
180
+ return {};
181
+ }
182
+ const options = {};
183
+ const { onRecoverableError, onCaughtError, onUncaughtError } = registeredHandlers;
184
+ // Capture once at root creation; the callback does not re-check the Rails env per error.
185
+ const logDevDefault = hydrating && inDevelopmentEnv();
186
+ if (logDevDefault || onRecoverableError) {
187
+ options.onRecoverableError = (error, errorInfo) => {
188
+ if (logDevDefault) {
189
+ if (!defaultReportingHandledInternally) {
190
+ defaultReportRecoverableError(error);
191
+ }
192
+ logDevHydrationError(context, errorInfo);
193
+ }
194
+ if (onRecoverableError) {
195
+ safeInvoke(onRecoverableError, 'onRecoverableError', error, errorInfo, context);
196
+ }
197
+ };
198
+ }
199
+ if (supportsReact19RootErrorCallbacks) {
200
+ if (onCaughtError) {
201
+ options.onCaughtError = (error, errorInfo) => safeInvoke(onCaughtError, 'onCaughtError', error, errorInfo, context);
202
+ }
203
+ if (onUncaughtError) {
204
+ options.onUncaughtError = (error, errorInfo) => safeInvoke(onUncaughtError, 'onUncaughtError', error, errorInfo, context);
205
+ }
206
+ }
207
+ return options;
208
+ }
209
+ /**
210
+ * Pro RSC hydration wraps the returned `onRecoverableError` with an internal handler that has already
211
+ * performed React's default recoverable-error reporting. Keep that invariant in one named helper so
212
+ * Pro call sites do not need to remember the lower-level `defaultReportingHandledInternally` flag.
213
+ *
214
+ * On non-hydrate (`createRoot`) paths, `defaultReportingHandledInternally` is false, so this
215
+ * degrades to `buildRootErrorCallbackOptions` with no reporting-behavior change.
216
+ */
217
+ export function buildRootErrorCallbackOptionsWithInternalRecoverableErrorReporting(context, hydrating) {
218
+ return buildRootErrorCallbackOptions(context, hydrating, {
219
+ defaultReportingHandledInternally: hydrating,
220
+ });
221
+ }
222
+ //# sourceMappingURL=rootErrorHandlers.js.map
@@ -1,3 +1,19 @@
1
+ // Must match SOURCE_MAP_STACK_REMAPPER_CONTEXT_KEY in
2
+ // packages/react-on-rails-pro-node-renderer/src/worker/vmSourceMapSupport.ts.
3
+ const SOURCE_MAPPED_STACK_REMAPPER_KEY = '__reactOnRailsProRemapStackTrace';
4
+ function remapSourceMappedStack(stack) {
5
+ const remapper = globalThis[SOURCE_MAPPED_STACK_REMAPPER_KEY];
6
+ if (typeof remapper !== 'function') {
7
+ return stack;
8
+ }
9
+ try {
10
+ const remappedStack = remapper(stack);
11
+ return typeof remappedStack === 'string' ? remappedStack : stack;
12
+ }
13
+ catch {
14
+ return stack;
15
+ }
16
+ }
1
17
  export function buildRenderMetadata(consoleReplayScript, renderState) {
2
18
  return {
3
19
  consoleReplayScript,
@@ -5,7 +21,7 @@ export function buildRenderMetadata(consoleReplayScript, renderState) {
5
21
  hasErrors: renderState.hasErrors,
6
22
  renderingError: renderState.error && {
7
23
  message: renderState.error.message,
8
- stack: renderState.error.stack,
24
+ stack: remapSourceMappedStack(renderState.error.stack),
9
25
  },
10
26
  isShellReady: 'isShellReady' in renderState ? renderState.isShellReady : undefined,
11
27
  };
@@ -97,6 +113,11 @@ export function convertToError(e) {
97
113
  // tsconfig uses es2020 libs, which do not type Error.cause even though supported runtimes provide it.
98
114
  const error = new Error(message);
99
115
  error.cause = e;
116
+ if (isCrossRealmError(e) && typeof e.stack === 'string') {
117
+ // Prefer the cross-realm bundle stack over the host wrapping call site; the
118
+ // original thrown value remains available through `cause`.
119
+ error.stack = remapSourceMappedStack(e.stack);
120
+ }
100
121
  return error;
101
122
  }
102
123
  export function validateComponent(componentObj, componentName) {
@@ -250,6 +250,40 @@ export interface Root {
250
250
  unmount(): void;
251
251
  }
252
252
  export type RenderReturnType = void | Element | Component | Root;
253
+ /** Extra context React on Rails adds when invoking registered root error callbacks. */
254
+ export interface RootErrorContext {
255
+ /** Name of the registered component rendered into the affected React root, when known. */
256
+ componentName?: string;
257
+ /** DOM id of the element the affected React root was mounted in, when known. */
258
+ domNodeId?: string;
259
+ }
260
+ /**
261
+ * A root error callback registered through `ReactOnRails.setOptions({ rootErrorHandlers })`.
262
+ * Receives React's original `(error, errorInfo)` arguments plus React on Rails context about the
263
+ * affected root. `errorInfo` typically contains `componentStack` (see the `react-dom/client`
264
+ * `createRoot`/`hydrateRoot` option docs for the exact per-callback shape).
265
+ */
266
+ export type RootErrorHandler = (error: unknown, errorInfo: unknown, context: RootErrorContext) => void;
267
+ /**
268
+ * User-registered React root error callbacks, applied to every React root that React on Rails
269
+ * creates via `hydrateRoot`/`createRoot`. Register them before your components render (typically
270
+ * in the same pack file where you call `ReactOnRails.register`); each root captures the callbacks
271
+ * registered at the moment it is created. Partial updates merge per key: a later
272
+ * `setOptions({ rootErrorHandlers })` call that sets only one callback keeps the others; pass an
273
+ * explicit `undefined` for a key to clear just that callback. Passing `null` is invalid and
274
+ * throws at runtime; use `undefined` to deregister.
275
+ */
276
+ export interface RootErrorHandlers {
277
+ /**
278
+ * Called when React automatically recovers from an error, e.g. a hydration mismatch.
279
+ * Requires React 18+.
280
+ */
281
+ onRecoverableError?: RootErrorHandler;
282
+ /** Called for errors caught by an error boundary. Requires React 19+. */
283
+ onCaughtError?: RootErrorHandler;
284
+ /** Called for errors not caught by any error boundary. Requires React 19+. */
285
+ onUncaughtError?: RootErrorHandler;
286
+ }
253
287
  export interface ReactOnRailsOptions {
254
288
  /** Gives you debugging messages on Turbolinks events. */
255
289
  traceTurbolinks?: boolean;
@@ -259,6 +293,12 @@ export interface ReactOnRailsOptions {
259
293
  debugMode?: boolean;
260
294
  /** Log component registration details including timing and size information. */
261
295
  logComponentRegistration?: boolean;
296
+ /**
297
+ * React root error callbacks (`onRecoverableError`, `onCaughtError`, `onUncaughtError`)
298
+ * applied to every React root created by React on Rails.
299
+ * @see {RootErrorHandlers}
300
+ */
301
+ rootErrorHandlers?: RootErrorHandlers;
262
302
  }
263
303
  export interface ReactOnRails {
264
304
  /**
@@ -0,0 +1,137 @@
1
+ /**
2
+ * useRailsForm — a small, Inertia `useForm`-style hook for submitting React
3
+ * forms to plain Rails controller actions.
4
+ *
5
+ * The hook keeps Rails as the mutation layer: it wires up `fetch`, attaches the
6
+ * CSRF token from the standard Rails `<meta name="csrf-token">` tag (via the
7
+ * existing `authenticityToken` utility), sends/receives JSON, and maps the
8
+ * blessed 422 validation-error shape — `{ errors: { field: ["message"] } }` —
9
+ * onto per-field client state. The matching server side is the opt-in
10
+ * `ReactOnRails::Controller::FormResponders#render_model_errors` concern in the
11
+ * react_on_rails gem, but the hook works against any endpoint that returns the
12
+ * documented shape.
13
+ *
14
+ * v1 scope (https://github.com/shakacode/react_on_rails/issues/3872):
15
+ * submit verbs, `data`/`setData`, `errors`, `processing`, CSRF auto-attach, and
16
+ * 422 error mapping. Deferred to a follow-up: `transform`,
17
+ * `recentlySuccessful`, and file-upload `progress` (which requires an
18
+ * XMLHttpRequest or duplex-stream transport; v1 is fetch-only).
19
+ *
20
+ * Success/redirect handling is intentionally minimal and forward-compatible
21
+ * with the client-routing work in issue #3873: the hook never navigates on its
22
+ * own. It surfaces safe JSON `redirect_to` hints through `onSuccess` / the
23
+ * resolved submit result so the app — or a future router integration — decides
24
+ * what to do.
25
+ */
26
+ /** Per-field validation errors: `{ field: ["message", ...] }`. */
27
+ export type RailsFormErrors = Record<string, string[]>;
28
+ export type RailsFormMethod = 'post' | 'put' | 'patch' | 'delete';
29
+ export interface RailsFormSuccessResult {
30
+ ok: true;
31
+ /** Parsed JSON response body, or `null` when the body was empty or not JSON. */
32
+ responseData: unknown;
33
+ /**
34
+ * Redirect target when the server replied with a safe JSON
35
+ * `redirect_to`/`redirectTo` hint. Browser redirect following is disabled for
36
+ * CSRF-bearing submissions; a defensively filtered redirected Response URL is
37
+ * still accepted if a custom fetch implementation returns one.
38
+ * Hints are accepted only when they resolve to the current origin over HTTP(S);
39
+ * non-HTTP schemes such as `javascript:` are ignored.
40
+ * The hook never navigates — pass this to your router or `window.location`.
41
+ * Designed to compose with the client-routing integration in issue #3873.
42
+ */
43
+ redirectTo: string | null;
44
+ response: Response;
45
+ }
46
+ export interface RailsFormValidationErrorResult {
47
+ ok: false;
48
+ /** Per-field errors mapped from the 422 response body. */
49
+ errors: RailsFormErrors;
50
+ response: Response;
51
+ }
52
+ export interface RailsFormStaleResult {
53
+ ok: false;
54
+ /**
55
+ * True when this submit was superseded by a newer submit before it settled.
56
+ * Stale submissions do not update form state, run submit callbacks, or reject
57
+ * stale caller `.catch()` handlers after the newer submit has won.
58
+ */
59
+ stale: true;
60
+ response?: Response;
61
+ error?: unknown;
62
+ }
63
+ export type RailsFormSubmitResult = RailsFormSuccessResult | RailsFormValidationErrorResult | RailsFormStaleResult;
64
+ /** Thrown (as a promise rejection) for non-2xx responses other than a mappable 422. */
65
+ export declare class RailsFormRequestError extends Error {
66
+ /** The response, with its body stream unread — `.json()`/`.text()` work. */
67
+ readonly response: Response;
68
+ /**
69
+ * Parsed JSON body when the hook already read it (a 422 whose body didn't
70
+ * match the documented errors shape); `undefined` otherwise.
71
+ */
72
+ readonly responseBody: unknown;
73
+ constructor(response: Response, responseBody?: unknown);
74
+ }
75
+ export interface RailsFormSubmitOptions {
76
+ /** Extra request headers. JSON and CSRF headers are always applied on top. */
77
+ headers?: Record<string, string>;
78
+ /**
79
+ * Called after a 2xx response. If this callback throws, the exception
80
+ * propagates as the submit promise rejection after form state has settled.
81
+ */
82
+ onSuccess?: (result: RailsFormSuccessResult) => void;
83
+ /** Called after a 422 response whose body matched the documented errors shape. */
84
+ onError?: (errors: RailsFormErrors) => void;
85
+ }
86
+ export interface UseRailsForm<TData extends object> {
87
+ /** Current form data. */
88
+ data: TData;
89
+ /** Set a single field, merge a partial object, or apply an updater function. */
90
+ setData: {
91
+ <K extends keyof TData>(key: K, value: TData[K]): void;
92
+ (valuesOrUpdater: Partial<TData> | ((previousData: TData) => TData)): void;
93
+ };
94
+ /** Per-field validation errors from the last 422 response (or `setError`). */
95
+ errors: RailsFormErrors;
96
+ hasErrors: boolean;
97
+ /** True while a submission is in flight. */
98
+ processing: boolean;
99
+ /** True once the most recent submission succeeded. Reset when a new one starts. */
100
+ wasSuccessful: boolean;
101
+ /** Submit with an explicit HTTP method. */
102
+ submit: (method: RailsFormMethod, url: string, options?: RailsFormSubmitOptions) => Promise<RailsFormSubmitResult>;
103
+ post: (url: string, options?: RailsFormSubmitOptions) => Promise<RailsFormSubmitResult>;
104
+ put: (url: string, options?: RailsFormSubmitOptions) => Promise<RailsFormSubmitResult>;
105
+ patch: (url: string, options?: RailsFormSubmitOptions) => Promise<RailsFormSubmitResult>;
106
+ /** Named `delete` on the hook object; `delete` is reserved in some contexts. */
107
+ delete: (url: string, options?: RailsFormSubmitOptions) => Promise<RailsFormSubmitResult>;
108
+ /**
109
+ * Reset all data (no args) or the given fields to their initial values.
110
+ * Clears matching errors and `wasSuccessful`. "Initial values" are the
111
+ * `initialData` captured on first render (Inertia `useForm` semantics) —
112
+ * later prop changes are not tracked; remount the component to re-seed.
113
+ */
114
+ reset: (...fields: Extract<keyof TData, string>[]) => void;
115
+ /** Clear all errors (no args) or the errors for the given fields. */
116
+ clearErrors: (...fields: string[]) => void;
117
+ /** Manually set the errors for one field (e.g. client-side pre-checks). */
118
+ setError: (field: string, messages: string | string[]) => void;
119
+ }
120
+ /**
121
+ * React hook for submitting form data to a Rails controller action.
122
+ *
123
+ * ```tsx
124
+ * const form = useRailsForm({ name: '', email: '' });
125
+ * // <input value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} />
126
+ * // {form.errors.name?.[0]}
127
+ * // <form onSubmit={(e) => { e.preventDefault(); void form.post('/contacts'); }}>
128
+ * ```
129
+ *
130
+ * Submissions send `Content-Type: application/json` / `Accept: application/json`
131
+ * with the CSRF token from the Rails csrf-token meta tag. A 422 response with a
132
+ * `{ errors: { field: ["message"] } }` body (the shape rendered by the
133
+ * `render_model_errors` controller concern) populates `errors`; other non-2xx
134
+ * responses reject with `RailsFormRequestError`.
135
+ */
136
+ export declare function useRailsForm<TData extends object>(initialData: TData): UseRailsForm<TData>;
137
+ //# sourceMappingURL=useRailsForm.d.ts.map
@@ -0,0 +1,430 @@
1
+ /**
2
+ * useRailsForm — a small, Inertia `useForm`-style hook for submitting React
3
+ * forms to plain Rails controller actions.
4
+ *
5
+ * The hook keeps Rails as the mutation layer: it wires up `fetch`, attaches the
6
+ * CSRF token from the standard Rails `<meta name="csrf-token">` tag (via the
7
+ * existing `authenticityToken` utility), sends/receives JSON, and maps the
8
+ * blessed 422 validation-error shape — `{ errors: { field: ["message"] } }` —
9
+ * onto per-field client state. The matching server side is the opt-in
10
+ * `ReactOnRails::Controller::FormResponders#render_model_errors` concern in the
11
+ * react_on_rails gem, but the hook works against any endpoint that returns the
12
+ * documented shape.
13
+ *
14
+ * v1 scope (https://github.com/shakacode/react_on_rails/issues/3872):
15
+ * submit verbs, `data`/`setData`, `errors`, `processing`, CSRF auto-attach, and
16
+ * 422 error mapping. Deferred to a follow-up: `transform`,
17
+ * `recentlySuccessful`, and file-upload `progress` (which requires an
18
+ * XMLHttpRequest or duplex-stream transport; v1 is fetch-only).
19
+ *
20
+ * Success/redirect handling is intentionally minimal and forward-compatible
21
+ * with the client-routing work in issue #3873: the hook never navigates on its
22
+ * own. It surfaces safe JSON `redirect_to` hints through `onSuccess` / the
23
+ * resolved submit result so the app — or a future router integration — decides
24
+ * what to do.
25
+ */
26
+ import * as React from 'react';
27
+ import { authenticityToken } from "./Authenticity.js";
28
+ /** Thrown (as a promise rejection) for non-2xx responses other than a mappable 422. */
29
+ export class RailsFormRequestError extends Error {
30
+ constructor(response, responseBody = undefined) {
31
+ super(`useRailsForm request failed with status ${response.status}`);
32
+ this.name = 'RailsFormRequestError';
33
+ this.response = response;
34
+ this.responseBody = responseBody;
35
+ }
36
+ }
37
+ const PINNED_RAILS_FORM_HEADER_NAMES = new Set([
38
+ 'accept',
39
+ 'content-type',
40
+ 'x-csrf-token',
41
+ 'x-requested-with',
42
+ ]);
43
+ const REQUIRED_REACT_HOOK_NAMES = ['useCallback', 'useEffect', 'useRef', 'useState'];
44
+ const assertReactHooksAvailable = () => {
45
+ const missingHooks = REQUIRED_REACT_HOOK_NAMES.filter((hookName) => typeof React[hookName] !== 'function');
46
+ if (missingHooks.length > 0) {
47
+ throw new Error(`useRailsForm requires React 16.8 or newer because it uses React hooks. Missing React exports: ${missingHooks.join(', ')}.`);
48
+ }
49
+ };
50
+ assertReactHooksAvailable();
51
+ const railsFormJsonHeaders = (customHeaders = {}) => {
52
+ const filteredCustomHeaders = Object.fromEntries(Object.entries(customHeaders).filter(([headerName]) => !PINNED_RAILS_FORM_HEADER_NAMES.has(headerName.toLowerCase())));
53
+ return {
54
+ ...filteredCustomHeaders,
55
+ Accept: 'application/json',
56
+ 'Content-Type': 'application/json',
57
+ };
58
+ };
59
+ const railsFormHeaders = (csrfToken, customHeaders) => ({
60
+ ...railsFormJsonHeaders(customHeaders),
61
+ 'X-CSRF-Token': csrfToken,
62
+ 'X-Requested-With': 'XMLHttpRequest',
63
+ });
64
+ const validationMessageToString = (value) => {
65
+ switch (typeof value) {
66
+ case 'string':
67
+ return value;
68
+ case 'number':
69
+ case 'boolean':
70
+ case 'bigint':
71
+ case 'symbol':
72
+ return value.toString();
73
+ default: {
74
+ try {
75
+ return JSON.stringify(value) ?? Object.prototype.toString.call(value);
76
+ }
77
+ catch {
78
+ return Object.prototype.toString.call(value);
79
+ }
80
+ }
81
+ }
82
+ };
83
+ const toMessageArray = (value) => {
84
+ if (Array.isArray(value)) {
85
+ return value.filter((message) => message != null).map(validationMessageToString);
86
+ }
87
+ if (value == null) {
88
+ return [];
89
+ }
90
+ // Preserve unexpected custom-endpoint values visibly instead of silently
91
+ // dropping them, but ignore nullish values that cannot be displayed helpfully.
92
+ return [validationMessageToString(value)];
93
+ };
94
+ /**
95
+ * Normalizes a 422 response body into per-field errors. Returns `null` when the
96
+ * body doesn't match the documented `{ errors: { field: messages } }` shape.
97
+ * An empty `errors` object is still a handled validation response.
98
+ */
99
+ const mapValidationErrors = (body) => {
100
+ if (typeof body !== 'object' || body === null) {
101
+ return null;
102
+ }
103
+ const { errors } = body;
104
+ if (typeof errors !== 'object' || errors === null || Array.isArray(errors)) {
105
+ return null;
106
+ }
107
+ const errorEntries = Object.entries(errors);
108
+ const mapped = {};
109
+ for (const [field, messages] of errorEntries) {
110
+ const fieldMessages = toMessageArray(messages);
111
+ if (fieldMessages.length > 0) {
112
+ mapped[field] = fieldMessages;
113
+ }
114
+ }
115
+ return mapped;
116
+ };
117
+ const parseJsonBody = async (response) => {
118
+ try {
119
+ return (await response.json());
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ };
125
+ const safeJsonRedirectHint = (redirectTo) => {
126
+ const normalizedRedirect = redirectTo.trim();
127
+ if (normalizedRedirect.length === 0) {
128
+ return null;
129
+ }
130
+ const currentLocation = typeof window === 'undefined' ? null : window.location;
131
+ if (currentLocation === null) {
132
+ return null;
133
+ }
134
+ try {
135
+ // Match browser relative-URL behavior: query-only hints update the current
136
+ // page query, while root-relative hints like `/posts/1` stay root-relative.
137
+ const parsedRedirect = new URL(normalizedRedirect, currentLocation.href);
138
+ if ((parsedRedirect.protocol === 'http:' || parsedRedirect.protocol === 'https:') &&
139
+ parsedRedirect.origin === currentLocation.origin) {
140
+ if (/^https?:\/\//i.test(normalizedRedirect)) {
141
+ return parsedRedirect.href;
142
+ }
143
+ return `${parsedRedirect.pathname}${parsedRedirect.search}${parsedRedirect.hash}`;
144
+ }
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ return null;
150
+ };
151
+ const resolveSameOriginRequestUrl = (url) => {
152
+ const currentLocation = typeof window === 'undefined' ? null : window.location;
153
+ if (currentLocation === null || typeof document === 'undefined') {
154
+ // No browser origin/document is available in SSR/Node, so same-origin and
155
+ // CSRF guards cannot be enforced. Refuse the submit instead of guessing.
156
+ return null;
157
+ }
158
+ try {
159
+ const resolvedUrl = new URL(url, document.baseURI);
160
+ if ((resolvedUrl.protocol === 'http:' || resolvedUrl.protocol === 'https:') &&
161
+ resolvedUrl.origin === currentLocation.origin) {
162
+ return resolvedUrl.href;
163
+ }
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ return null;
169
+ };
170
+ const extractRedirectTo = (response, responseData) => {
171
+ // Native fetch never reaches this when `redirect: 'error'` is set; it throws
172
+ // before returning a redirected Response. Keep the filter for custom fetch
173
+ // implementations and tests that return pre-followed responses.
174
+ if (response.redirected && response.url) {
175
+ return safeJsonRedirectHint(response.url);
176
+ }
177
+ if (typeof responseData === 'object' && responseData !== null) {
178
+ const { redirect_to: redirectSnake, redirectTo: redirectCamel } = responseData;
179
+ if (typeof redirectSnake === 'string') {
180
+ return safeJsonRedirectHint(redirectSnake);
181
+ }
182
+ if (typeof redirectCamel === 'string') {
183
+ return safeJsonRedirectHint(redirectCamel);
184
+ }
185
+ }
186
+ return null;
187
+ };
188
+ const warnOnPossibleRedirectFetchError = (fetchError) => {
189
+ // Keep this development-only: browsers surface `redirect: "error"` failures
190
+ // as opaque TypeErrors, and warning on every production network failure would
191
+ // be noisy without giving end users an actionable recovery path.
192
+ if (process.env.NODE_ENV === 'production' || !(fetchError instanceof TypeError)) {
193
+ return;
194
+ }
195
+ if (!/failed to fetch|networkerror|load failed/i.test(fetchError.message)) {
196
+ return;
197
+ }
198
+ console.warn('[useRailsForm] The request may have been rejected because the server responded with a redirect. ' +
199
+ 'useRailsForm requires `render json:` for success responses; Rails `redirect_to` is not supported in v1.');
200
+ };
201
+ const staleSubmitResult = (response, error) => {
202
+ const result = { ok: false, stale: true };
203
+ if (response) {
204
+ result.response = response;
205
+ }
206
+ if (error !== undefined) {
207
+ result.error = error;
208
+ }
209
+ return result;
210
+ };
211
+ /**
212
+ * React hook for submitting form data to a Rails controller action.
213
+ *
214
+ * ```tsx
215
+ * const form = useRailsForm({ name: '', email: '' });
216
+ * // <input value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} />
217
+ * // {form.errors.name?.[0]}
218
+ * // <form onSubmit={(e) => { e.preventDefault(); void form.post('/contacts'); }}>
219
+ * ```
220
+ *
221
+ * Submissions send `Content-Type: application/json` / `Accept: application/json`
222
+ * with the CSRF token from the Rails csrf-token meta tag. A 422 response with a
223
+ * `{ errors: { field: ["message"] } }` body (the shape rendered by the
224
+ * `render_model_errors` controller concern) populates `errors`; other non-2xx
225
+ * responses reject with `RailsFormRequestError`.
226
+ */
227
+ export function useRailsForm(initialData) {
228
+ // Captured once on first render (Inertia useForm semantics): reset() restores
229
+ // these mount-time values even if the initialData prop changes later.
230
+ const initialDataRef = React.useRef(initialData);
231
+ const [data, setDataState] = React.useState(initialData);
232
+ const [errors, setErrors] = React.useState({});
233
+ const [processing, setProcessing] = React.useState(false);
234
+ const [wasSuccessful, setWasSuccessful] = React.useState(false);
235
+ // Latest data for submit(). Updated eagerly by commitData (not on render) so
236
+ // `setData(...); submit(...)` in the same tick posts the just-set values —
237
+ // React batches the state update, so `data` itself is stale until re-render.
238
+ const dataRef = React.useRef(data);
239
+ const commitData = React.useCallback((updater) => {
240
+ dataRef.current = updater(dataRef.current);
241
+ setDataState(dataRef.current);
242
+ }, []);
243
+ // Guards against state updates from stale (superseded) or unmounted submissions.
244
+ const submissionIdRef = React.useRef(0);
245
+ const pendingSubmissionsRef = React.useRef(0);
246
+ const mountedRef = React.useRef(true);
247
+ React.useEffect(() => {
248
+ // Re-assigning true is NOT redundant: under React StrictMode (and Fast
249
+ // Refresh) the cleanup runs and the effect re-runs on the same component
250
+ // instance, so without this the ref would stay false after the replay.
251
+ mountedRef.current = true;
252
+ // If a submission settled during the StrictMode cleanup/replay window,
253
+ // finishSubmission could have skipped the visible state update while
254
+ // mountedRef was false. Resync the flag when the same instance remounts.
255
+ if (pendingSubmissionsRef.current === 0) {
256
+ setProcessing(false);
257
+ }
258
+ return () => {
259
+ mountedRef.current = false;
260
+ };
261
+ }, []);
262
+ const setData = React.useCallback((keyOrValues, value) => {
263
+ if (typeof keyOrValues === 'function') {
264
+ commitData(keyOrValues);
265
+ }
266
+ else if (typeof keyOrValues === 'object') {
267
+ commitData((previousData) => ({ ...previousData, ...keyOrValues }));
268
+ }
269
+ else {
270
+ commitData((previousData) => ({ ...previousData, [keyOrValues]: value }));
271
+ }
272
+ }, [commitData]);
273
+ const clearErrors = React.useCallback((...fields) => {
274
+ if (fields.length === 0) {
275
+ setErrors({});
276
+ return;
277
+ }
278
+ setErrors((previousErrors) => Object.fromEntries(Object.entries(previousErrors).filter(([field]) => !fields.includes(field))));
279
+ }, []);
280
+ const setError = React.useCallback((field, messages) => {
281
+ setErrors((previousErrors) => ({ ...previousErrors, [field]: toMessageArray(messages) }));
282
+ }, []);
283
+ const reset = React.useCallback((...fields) => {
284
+ // A reset starts a fresh editing cycle: a pristine form should not still
285
+ // report the previous submission as successful.
286
+ setWasSuccessful(false);
287
+ if (fields.length === 0) {
288
+ commitData(() => initialDataRef.current);
289
+ clearErrors();
290
+ return;
291
+ }
292
+ commitData((previousData) => {
293
+ const nextData = { ...previousData };
294
+ fields.forEach((field) => {
295
+ nextData[field] = initialDataRef.current[field];
296
+ });
297
+ return nextData;
298
+ });
299
+ clearErrors(...fields);
300
+ }, [clearErrors, commitData]);
301
+ const submit = React.useCallback(async (method, url, options = {}) => {
302
+ submissionIdRef.current += 1;
303
+ const submissionId = submissionIdRef.current;
304
+ const isCurrent = () => mountedRef.current && submissionId === submissionIdRef.current;
305
+ const finishSubmission = () => {
306
+ pendingSubmissionsRef.current = Math.max(0, pendingSubmissionsRef.current - 1);
307
+ if (mountedRef.current && pendingSubmissionsRef.current === 0) {
308
+ setProcessing(false);
309
+ }
310
+ };
311
+ if (mountedRef.current && typeof window !== 'undefined') {
312
+ setWasSuccessful(false);
313
+ setErrors({});
314
+ // Safety valve: a prior submission can settle during a StrictMode
315
+ // cleanup window, leaving processing true even with no in-flight work.
316
+ if (pendingSubmissionsRef.current === 0) {
317
+ setProcessing(false);
318
+ }
319
+ }
320
+ const requestUrl = resolveSameOriginRequestUrl(url);
321
+ if (requestUrl === null) {
322
+ throw new Error('useRailsForm can only submit to same-origin URLs.');
323
+ }
324
+ const csrfToken = authenticityToken();
325
+ if (csrfToken === null) {
326
+ throw new Error('useRailsForm requires a <meta name="csrf-token"> tag before submitting. ' +
327
+ 'Add <%= csrf_meta_tags %> to your Rails layout.');
328
+ }
329
+ pendingSubmissionsRef.current += 1;
330
+ setProcessing(true);
331
+ let response;
332
+ try {
333
+ response = await fetch(requestUrl, {
334
+ method: method.toUpperCase(),
335
+ credentials: 'same-origin',
336
+ // Never follow redirects while carrying explicit CSRF headers; an
337
+ // open redirect could otherwise leak the token to another origin.
338
+ redirect: 'error',
339
+ headers: railsFormHeaders(csrfToken, options.headers),
340
+ // DELETE bodies are legal per RFC 9110 but are stripped or rejected by
341
+ // many proxies/CDNs in practice — identify the resource in the URL.
342
+ body: method === 'delete' ? undefined : JSON.stringify(dataRef.current),
343
+ });
344
+ }
345
+ catch (fetchError) {
346
+ finishSubmission();
347
+ if (!isCurrent()) {
348
+ return staleSubmitResult(undefined, fetchError);
349
+ }
350
+ warnOnPossibleRedirectFetchError(fetchError);
351
+ throw fetchError;
352
+ }
353
+ if (response.status === 422) {
354
+ // Parse a clone so `response` stays readable if we end up throwing
355
+ // RailsFormRequestError below (e.g. the body doesn't match the shape).
356
+ const body = await parseJsonBody(response.clone());
357
+ const validationErrors = mapValidationErrors(body);
358
+ if (validationErrors !== null) {
359
+ if (isCurrent()) {
360
+ setErrors(validationErrors);
361
+ finishSubmission();
362
+ options.onError?.(validationErrors);
363
+ }
364
+ else {
365
+ finishSubmission();
366
+ return staleSubmitResult(response);
367
+ }
368
+ return { ok: false, errors: validationErrors, response };
369
+ }
370
+ finishSubmission();
371
+ if (!isCurrent()) {
372
+ return staleSubmitResult(response);
373
+ }
374
+ throw new RailsFormRequestError(response, body);
375
+ }
376
+ if (!response.ok) {
377
+ finishSubmission();
378
+ if (!isCurrent()) {
379
+ return staleSubmitResult(response);
380
+ }
381
+ throw new RailsFormRequestError(response);
382
+ }
383
+ const responseData = await parseJsonBody(response.clone());
384
+ const result = {
385
+ ok: true,
386
+ responseData,
387
+ redirectTo: extractRedirectTo(response, responseData),
388
+ response,
389
+ };
390
+ if (isCurrent()) {
391
+ setErrors({});
392
+ setWasSuccessful(true);
393
+ finishSubmission();
394
+ // Guarded like the state updates: a superseded submission must not
395
+ // fire callbacks (e.g. navigate on redirectTo) after a newer one.
396
+ options.onSuccess?.(result);
397
+ }
398
+ else {
399
+ finishSubmission();
400
+ return staleSubmitResult(response);
401
+ }
402
+ return result;
403
+ },
404
+ // Intentionally empty: everything read inside satisfies
405
+ // react-hooks/exhaustive-deps — refs (dataRef, submissionIdRef, mountedRef),
406
+ // useState setters, and module-level imports. If you add a render-scoped
407
+ // value here, it must go in this array.
408
+ []);
409
+ const post = React.useCallback((url, options) => submit('post', url, options), [submit]);
410
+ const put = React.useCallback((url, options) => submit('put', url, options), [submit]);
411
+ const patch = React.useCallback((url, options) => submit('patch', url, options), [submit]);
412
+ const destroy = React.useCallback((url, options) => submit('delete', url, options), [submit]);
413
+ return {
414
+ data,
415
+ setData,
416
+ errors,
417
+ hasErrors: Object.keys(errors).length > 0,
418
+ processing,
419
+ wasSuccessful,
420
+ submit,
421
+ post,
422
+ put,
423
+ patch,
424
+ delete: destroy,
425
+ reset,
426
+ clearErrors,
427
+ setError,
428
+ };
429
+ }
430
+ //# sourceMappingURL=useRailsForm.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-on-rails",
3
- "version": "17.0.0-rc.2",
3
+ "version": "17.0.0-rc.4",
4
4
  "description": "react-on-rails JavaScript for react_on_rails Ruby gem",
5
5
  "main": "lib/ReactOnRails.full.js",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "./reactApis": "./lib/reactApis.cjs",
33
33
  "./webpackHelpers": "./lib/webpackHelpers.cjs",
34
34
  "./reactHydrateOrRender": "./lib/reactHydrateOrRender.js",
35
+ "./useRailsForm": "./lib/useRailsForm.js",
35
36
  "./turbolinksUtils": "./lib/turbolinksUtils.js",
36
37
  "./isRenderFunction": "./lib/isRenderFunction.js",
37
38
  "./ReactOnRails.client": "./lib/ReactOnRails.client.js",
@@ -44,6 +45,8 @@
44
45
  "./serverRenderReactComponent": "./lib/serverRenderReactComponent.js",
45
46
  "./@internal/sanitizeNonce": "./lib/sanitizeNonce.js",
46
47
  "./@internal/rendererTeardown": "./lib/rendererTeardown.js",
48
+ "./@internal/rootErrorHandlers": "./lib/rootErrorHandlers.js",
49
+ "./@internal/isThenable": "./lib/isThenable.js",
47
50
  "./@internal/base/client": "./lib/base/client.js",
48
51
  "./@internal/base/full": {
49
52
  "react-server": "./lib/base/full.rsc.js",
@@ -72,6 +75,10 @@
72
75
  "url": "https://github.com/shakacode/react_on_rails/issues"
73
76
  },
74
77
  "homepage": "https://reactonrails.com/docs/",
78
+ "devDependencies": {
79
+ "@testing-library/dom": "^10.4.0",
80
+ "@testing-library/react": "^16.2.0"
81
+ },
75
82
  "scripts": {
76
83
  "build": "pnpm run clean && tsc",
77
84
  "build-watch": "pnpm run clean && tsc --watch",