UNPKG

react-on-rails

Version:

react-on-rails JavaScript for react_on_rails Ruby gem

430 lines 18.1 kB
/** * useRailsForm — a small, Inertia `useForm`-style hook for submitting React * forms to plain Rails controller actions. * * The hook keeps Rails as the mutation layer: it wires up `fetch`, attaches the * CSRF token from the standard Rails `<meta name="csrf-token">` tag (via the * existing `authenticityToken` utility), sends/receives JSON, and maps the * blessed 422 validation-error shape — `{ errors: { field: ["message"] } }` — * onto per-field client state. The matching server side is the opt-in * `ReactOnRails::Controller::FormResponders#render_model_errors` concern in the * react_on_rails gem, but the hook works against any endpoint that returns the * documented shape. * * v1 scope (https://github.com/shakacode/react_on_rails/issues/3872): * submit verbs, `data`/`setData`, `errors`, `processing`, CSRF auto-attach, and * 422 error mapping. Deferred to a follow-up: `transform`, * `recentlySuccessful`, and file-upload `progress` (which requires an * XMLHttpRequest or duplex-stream transport; v1 is fetch-only). * * Success/redirect handling is intentionally minimal and forward-compatible * with the client-routing work in issue #3873: the hook never navigates on its * own. It surfaces safe JSON `redirect_to` hints through `onSuccess` / the * resolved submit result so the app — or a future router integration — decides * what to do. */ import * as React from 'react'; import { authenticityToken } from "./Authenticity.js"; /** Thrown (as a promise rejection) for non-2xx responses other than a mappable 422. */ export class RailsFormRequestError extends Error { constructor(response, responseBody = undefined) { super(`useRailsForm request failed with status ${response.status}`); this.name = 'RailsFormRequestError'; this.response = response; this.responseBody = responseBody; } } const PINNED_RAILS_FORM_HEADER_NAMES = new Set([ 'accept', 'content-type', 'x-csrf-token', 'x-requested-with', ]); const REQUIRED_REACT_HOOK_NAMES = ['useCallback', 'useEffect', 'useRef', 'useState']; const assertReactHooksAvailable = () => { const missingHooks = REQUIRED_REACT_HOOK_NAMES.filter((hookName) => typeof React[hookName] !== 'function'); if (missingHooks.length > 0) { throw new Error(`useRailsForm requires React 16.8 or newer because it uses React hooks. Missing React exports: ${missingHooks.join(', ')}.`); } }; assertReactHooksAvailable(); const railsFormJsonHeaders = (customHeaders = {}) => { const filteredCustomHeaders = Object.fromEntries(Object.entries(customHeaders).filter(([headerName]) => !PINNED_RAILS_FORM_HEADER_NAMES.has(headerName.toLowerCase()))); return { ...filteredCustomHeaders, Accept: 'application/json', 'Content-Type': 'application/json', }; }; const railsFormHeaders = (csrfToken, customHeaders) => ({ ...railsFormJsonHeaders(customHeaders), 'X-CSRF-Token': csrfToken, 'X-Requested-With': 'XMLHttpRequest', }); const validationMessageToString = (value) => { switch (typeof value) { case 'string': return value; case 'number': case 'boolean': case 'bigint': case 'symbol': return value.toString(); default: { try { return JSON.stringify(value) ?? Object.prototype.toString.call(value); } catch { return Object.prototype.toString.call(value); } } } }; const toMessageArray = (value) => { if (Array.isArray(value)) { return value.filter((message) => message != null).map(validationMessageToString); } if (value == null) { return []; } // Preserve unexpected custom-endpoint values visibly instead of silently // dropping them, but ignore nullish values that cannot be displayed helpfully. return [validationMessageToString(value)]; }; /** * Normalizes a 422 response body into per-field errors. Returns `null` when the * body doesn't match the documented `{ errors: { field: messages } }` shape. * An empty `errors` object is still a handled validation response. */ const mapValidationErrors = (body) => { if (typeof body !== 'object' || body === null) { return null; } const { errors } = body; if (typeof errors !== 'object' || errors === null || Array.isArray(errors)) { return null; } const errorEntries = Object.entries(errors); const mapped = {}; for (const [field, messages] of errorEntries) { const fieldMessages = toMessageArray(messages); if (fieldMessages.length > 0) { mapped[field] = fieldMessages; } } return mapped; }; const parseJsonBody = async (response) => { try { return (await response.json()); } catch { return null; } }; const safeJsonRedirectHint = (redirectTo) => { const normalizedRedirect = redirectTo.trim(); if (normalizedRedirect.length === 0) { return null; } const currentLocation = typeof window === 'undefined' ? null : window.location; if (currentLocation === null) { return null; } try { // Match browser relative-URL behavior: query-only hints update the current // page query, while root-relative hints like `/posts/1` stay root-relative. const parsedRedirect = new URL(normalizedRedirect, currentLocation.href); if ((parsedRedirect.protocol === 'http:' || parsedRedirect.protocol === 'https:') && parsedRedirect.origin === currentLocation.origin) { if (/^https?:\/\//i.test(normalizedRedirect)) { return parsedRedirect.href; } return `${parsedRedirect.pathname}${parsedRedirect.search}${parsedRedirect.hash}`; } } catch { return null; } return null; }; const resolveSameOriginRequestUrl = (url) => { const currentLocation = typeof window === 'undefined' ? null : window.location; if (currentLocation === null || typeof document === 'undefined') { // No browser origin/document is available in SSR/Node, so same-origin and // CSRF guards cannot be enforced. Refuse the submit instead of guessing. return null; } try { const resolvedUrl = new URL(url, document.baseURI); if ((resolvedUrl.protocol === 'http:' || resolvedUrl.protocol === 'https:') && resolvedUrl.origin === currentLocation.origin) { return resolvedUrl.href; } } catch { return null; } return null; }; const extractRedirectTo = (response, responseData) => { // Native fetch never reaches this when `redirect: 'error'` is set; it throws // before returning a redirected Response. Keep the filter for custom fetch // implementations and tests that return pre-followed responses. if (response.redirected && response.url) { return safeJsonRedirectHint(response.url); } if (typeof responseData === 'object' && responseData !== null) { const { redirect_to: redirectSnake, redirectTo: redirectCamel } = responseData; if (typeof redirectSnake === 'string') { return safeJsonRedirectHint(redirectSnake); } if (typeof redirectCamel === 'string') { return safeJsonRedirectHint(redirectCamel); } } return null; }; const warnOnPossibleRedirectFetchError = (fetchError) => { // Keep this development-only: browsers surface `redirect: "error"` failures // as opaque TypeErrors, and warning on every production network failure would // be noisy without giving end users an actionable recovery path. if (process.env.NODE_ENV === 'production' || !(fetchError instanceof TypeError)) { return; } if (!/failed to fetch|networkerror|load failed/i.test(fetchError.message)) { return; } console.warn('[useRailsForm] The request may have been rejected because the server responded with a redirect. ' + 'useRailsForm requires `render json:` for success responses; Rails `redirect_to` is not supported in v1.'); }; const staleSubmitResult = (response, error) => { const result = { ok: false, stale: true }; if (response) { result.response = response; } if (error !== undefined) { result.error = error; } return result; }; /** * React hook for submitting form data to a Rails controller action. * * ```tsx * const form = useRailsForm({ name: '', email: '' }); * // <input value={form.data.name} onChange={(e) => form.setData('name', e.target.value)} /> * // {form.errors.name?.[0]} * // <form onSubmit={(e) => { e.preventDefault(); void form.post('/contacts'); }}> * ``` * * Submissions send `Content-Type: application/json` / `Accept: application/json` * with the CSRF token from the Rails csrf-token meta tag. A 422 response with a * `{ errors: { field: ["message"] } }` body (the shape rendered by the * `render_model_errors` controller concern) populates `errors`; other non-2xx * responses reject with `RailsFormRequestError`. */ export function useRailsForm(initialData) { // Captured once on first render (Inertia useForm semantics): reset() restores // these mount-time values even if the initialData prop changes later. const initialDataRef = React.useRef(initialData); const [data, setDataState] = React.useState(initialData); const [errors, setErrors] = React.useState({}); const [processing, setProcessing] = React.useState(false); const [wasSuccessful, setWasSuccessful] = React.useState(false); // Latest data for submit(). Updated eagerly by commitData (not on render) so // `setData(...); submit(...)` in the same tick posts the just-set values — // React batches the state update, so `data` itself is stale until re-render. const dataRef = React.useRef(data); const commitData = React.useCallback((updater) => { dataRef.current = updater(dataRef.current); setDataState(dataRef.current); }, []); // Guards against state updates from stale (superseded) or unmounted submissions. const submissionIdRef = React.useRef(0); const pendingSubmissionsRef = React.useRef(0); const mountedRef = React.useRef(true); React.useEffect(() => { // Re-assigning true is NOT redundant: under React StrictMode (and Fast // Refresh) the cleanup runs and the effect re-runs on the same component // instance, so without this the ref would stay false after the replay. mountedRef.current = true; // If a submission settled during the StrictMode cleanup/replay window, // finishSubmission could have skipped the visible state update while // mountedRef was false. Resync the flag when the same instance remounts. if (pendingSubmissionsRef.current === 0) { setProcessing(false); } return () => { mountedRef.current = false; }; }, []); const setData = React.useCallback((keyOrValues, value) => { if (typeof keyOrValues === 'function') { commitData(keyOrValues); } else if (typeof keyOrValues === 'object') { commitData((previousData) => ({ ...previousData, ...keyOrValues })); } else { commitData((previousData) => ({ ...previousData, [keyOrValues]: value })); } }, [commitData]); const clearErrors = React.useCallback((...fields) => { if (fields.length === 0) { setErrors({}); return; } setErrors((previousErrors) => Object.fromEntries(Object.entries(previousErrors).filter(([field]) => !fields.includes(field)))); }, []); const setError = React.useCallback((field, messages) => { setErrors((previousErrors) => ({ ...previousErrors, [field]: toMessageArray(messages) })); }, []); const reset = React.useCallback((...fields) => { // A reset starts a fresh editing cycle: a pristine form should not still // report the previous submission as successful. setWasSuccessful(false); if (fields.length === 0) { commitData(() => initialDataRef.current); clearErrors(); return; } commitData((previousData) => { const nextData = { ...previousData }; fields.forEach((field) => { nextData[field] = initialDataRef.current[field]; }); return nextData; }); clearErrors(...fields); }, [clearErrors, commitData]); const submit = React.useCallback(async (method, url, options = {}) => { submissionIdRef.current += 1; const submissionId = submissionIdRef.current; const isCurrent = () => mountedRef.current && submissionId === submissionIdRef.current; const finishSubmission = () => { pendingSubmissionsRef.current = Math.max(0, pendingSubmissionsRef.current - 1); if (mountedRef.current && pendingSubmissionsRef.current === 0) { setProcessing(false); } }; if (mountedRef.current && typeof window !== 'undefined') { setWasSuccessful(false); setErrors({}); // Safety valve: a prior submission can settle during a StrictMode // cleanup window, leaving processing true even with no in-flight work. if (pendingSubmissionsRef.current === 0) { setProcessing(false); } } const requestUrl = resolveSameOriginRequestUrl(url); if (requestUrl === null) { throw new Error('useRailsForm can only submit to same-origin URLs.'); } const csrfToken = authenticityToken(); if (csrfToken === null) { throw new Error('useRailsForm requires a <meta name="csrf-token"> tag before submitting. ' + 'Add <%= csrf_meta_tags %> to your Rails layout.'); } pendingSubmissionsRef.current += 1; setProcessing(true); let response; try { response = await fetch(requestUrl, { method: method.toUpperCase(), credentials: 'same-origin', // Never follow redirects while carrying explicit CSRF headers; an // open redirect could otherwise leak the token to another origin. redirect: 'error', headers: railsFormHeaders(csrfToken, options.headers), // DELETE bodies are legal per RFC 9110 but are stripped or rejected by // many proxies/CDNs in practice — identify the resource in the URL. body: method === 'delete' ? undefined : JSON.stringify(dataRef.current), }); } catch (fetchError) { finishSubmission(); if (!isCurrent()) { return staleSubmitResult(undefined, fetchError); } warnOnPossibleRedirectFetchError(fetchError); throw fetchError; } if (response.status === 422) { // Parse a clone so `response` stays readable if we end up throwing // RailsFormRequestError below (e.g. the body doesn't match the shape). const body = await parseJsonBody(response.clone()); const validationErrors = mapValidationErrors(body); if (validationErrors !== null) { if (isCurrent()) { setErrors(validationErrors); finishSubmission(); options.onError?.(validationErrors); } else { finishSubmission(); return staleSubmitResult(response); } return { ok: false, errors: validationErrors, response }; } finishSubmission(); if (!isCurrent()) { return staleSubmitResult(response); } throw new RailsFormRequestError(response, body); } if (!response.ok) { finishSubmission(); if (!isCurrent()) { return staleSubmitResult(response); } throw new RailsFormRequestError(response); } const responseData = await parseJsonBody(response.clone()); const result = { ok: true, responseData, redirectTo: extractRedirectTo(response, responseData), response, }; if (isCurrent()) { setErrors({}); setWasSuccessful(true); finishSubmission(); // Guarded like the state updates: a superseded submission must not // fire callbacks (e.g. navigate on redirectTo) after a newer one. options.onSuccess?.(result); } else { finishSubmission(); return staleSubmitResult(response); } return result; }, // Intentionally empty: everything read inside satisfies // react-hooks/exhaustive-deps — refs (dataRef, submissionIdRef, mountedRef), // useState setters, and module-level imports. If you add a render-scoped // value here, it must go in this array. []); const post = React.useCallback((url, options) => submit('post', url, options), [submit]); const put = React.useCallback((url, options) => submit('put', url, options), [submit]); const patch = React.useCallback((url, options) => submit('patch', url, options), [submit]); const destroy = React.useCallback((url, options) => submit('delete', url, options), [submit]); return { data, setData, errors, hasErrors: Object.keys(errors).length > 0, processing, wasSuccessful, submit, post, put, patch, delete: destroy, reset, clearErrors, setError, }; } //# sourceMappingURL=useRailsForm.js.map