react-on-rails 17.0.0-rc.3 → 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.
- package/lib/ClientRenderer.js +6 -7
- package/lib/base/client.js +33 -22
- package/lib/capabilities/core.js +21 -3
- package/lib/isThenable.d.ts +8 -0
- package/lib/isThenable.js +13 -0
- package/lib/reactApis.cjs +4 -1
- package/lib/reactApis.d.cts +1 -0
- package/lib/rootErrorHandlers.d.ts +82 -0
- package/lib/rootErrorHandlers.js +222 -0
- package/lib/serverRenderUtils.js +22 -1
- package/lib/types/index.d.ts +40 -0
- package/lib/useRailsForm.d.ts +137 -0
- package/lib/useRailsForm.js +430 -0
- package/package.json +8 -1
package/lib/ClientRenderer.js
CHANGED
|
@@ -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
|
}
|
package/lib/base/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
85
|
-
this.options.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
|
|
90
|
-
this.options.debugMode =
|
|
91
|
-
if (
|
|
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
|
|
98
|
-
this.options.logComponentRegistration =
|
|
99
|
-
if (
|
|
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.
|
|
106
|
-
|
|
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
|
|
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
|
package/lib/capabilities/core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/lib/reactApis.d.cts
CHANGED
|
@@ -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
|
package/lib/serverRenderUtils.js
CHANGED
|
@@ -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) {
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|