diff --git a/dist/controller/input/ArrayInputBase.cjs b/dist/controller/input/ArrayInputBase.cjs
index a6de40ea7ec4972300cd4d732004ee4bd7d6511a..f4408dbf97bc62e869fc81b900d096be72beec4d 100644
--- a/dist/controller/input/ArrayInputBase.cjs
+++ b/dist/controller/input/ArrayInputBase.cjs
@@ -94,18 +94,19 @@ const ArrayInputBase = (props) => {
: validate;
const getValidationErrorMessage = (0, useGetValidationErrorMessage_1.useGetValidationErrorMessage)();
const { getValues } = (0, react_hook_form_1.useFormContext)();
+ // Local patch: see dist/form/useInput.js for the rationale.
const fieldProps = (0, react_hook_form_1.useFieldArray)({
name: finalSource,
- rules: {
- validate: async (value) => {
- if (!sanitizedValidate)
- return true;
- const error = await sanitizedValidate(value, getValues(), props);
- if (!error)
- return true;
- return getValidationErrorMessage(error);
- },
- },
+ rules: sanitizedValidate
+ ? {
+ validate: async (value) => {
+ const error = await sanitizedValidate(value, getValues(), props);
+ if (!error)
+ return true;
+ return getValidationErrorMessage(error);
+ },
+ }
+ : undefined,
});
(0, react_1.useEffect)(() => {
if (formGroups && formGroupName != null) {
diff --git a/dist/controller/input/ArrayInputBase.js b/dist/controller/input/ArrayInputBase.js
index 47a1ea4fb3c30114a2e4799f9248eebbcefd6f4b..d8eddce3078f08342f7ff8f3f7f9476174fe7945 100644
--- a/dist/controller/input/ArrayInputBase.js
+++ b/dist/controller/input/ArrayInputBase.js
@@ -58,18 +58,20 @@ export const ArrayInputBase = (props) => {
: validate;
const getValidationErrorMessage = useGetValidationErrorMessage();
const { getValues } = useFormContext();
+ // Local patch: see useInput.js for the rationale — only register
+ // an async validate when there is an actual validator.
const fieldProps = useFieldArray({
name: finalSource,
- rules: {
- validate: async (value) => {
- if (!sanitizedValidate)
- return true;
- const error = await sanitizedValidate(value, getValues(), props);
- if (!error)
- return true;
- return getValidationErrorMessage(error);
- },
- },
+ rules: sanitizedValidate
+ ? {
+ validate: async (value) => {
+ const error = await sanitizedValidate(value, getValues(), props);
+ if (!error)
+ return true;
+ return getValidationErrorMessage(error);
+ },
+ }
+ : undefined,
});
useEffect(() => {
if (formGroups && formGroupName != null) {
diff --git a/dist/form/groups/useFormGroup.cjs b/dist/form/groups/useFormGroup.cjs
index c4c0b2f2195c46f6c0cb526178b27d2c8c24188b..4bb3ebd6ea5221ab39b3e575285e62231a6219cc 100644
--- a/dist/form/groups/useFormGroup.cjs
+++ b/dist/form/groups/useFormGroup.cjs
@@ -6,10 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.getFormGroupState = exports.useFormGroup = void 0;
const react_1 = require("react");
const get_js_1 = __importDefault(require("lodash/get.js"));
-const isEqual_js_1 = __importDefault(require("lodash/isEqual.js"));
const react_hook_form_1 = require("react-hook-form");
const useFormGroups_1 = require("./useFormGroups.cjs");
-const util_1 = require("../../util/index.cjs");
/**
* Retrieve a specific form group data such as its validation status (valid/invalid) or
* or whether its inputs have been updated (dirty/pristine)
@@ -58,22 +56,31 @@ const useFormGroup = (name) => {
// dirtyFields, touchedFields, validatingFields and errors are objects with keys being the field names
// Ex: { title: true }
// However, they are not correctly serialized when using JSON.stringify
- // To avoid our effects to not be triggered when they should, we extract the keys and use that as a dependency
+ // To avoid recomputing when they should not, we extract the keys and use that as a dependency
const dirtyFieldsNames = Object.keys(dirtyFields);
const touchedFieldsNames = Object.keys(touchedFields);
const validatingFieldsNames = Object.keys(validatingFields);
const errorsNames = Object.keys(errors);
const formGroups = (0, useFormGroups_1.useFormGroups)();
- const [state, setState] = (0, react_1.useState)({
- errors: undefined,
- isDirty: false,
- isTouched: false,
- isValid: true,
- isValidating: true,
- });
- const updateGroupState = (0, util_1.useEvent)(() => {
+ // Local patch: see dist/form/groups/useFormGroup.js for the rationale.
+ const [, forceRerenderOnGroupFieldsChange] = (0, react_1.useReducer)((c) => c + 1, 0);
+ (0, react_1.useEffect)(() => {
if (!formGroups)
return;
+ // Whenever the group content changes (input are added or removed)
+ // we must recompute its state
+ return formGroups.subscribe(name, forceRerenderOnGroupFieldsChange);
+ }, [formGroups, name]);
+ return (0, react_1.useMemo)(() => {
+ if (!formGroups) {
+ return {
+ errors: undefined,
+ isDirty: false,
+ isTouched: false,
+ isValid: true,
+ isValidating: true,
+ };
+ }
const fields = formGroups.getGroupFields(name);
const fieldStates = fields
.map(field => {
@@ -87,40 +94,16 @@ const useFormGroup = (name) => {
};
})
.filter(fieldState => fieldState != undefined); // eslint-disable-line
- const newState = (0, exports.getFormGroupState)(fieldStates);
- setState(oldState => {
- if (!(0, isEqual_js_1.default)(oldState, newState)) {
- return newState;
- }
- return oldState;
- });
- });
- (0, react_1.useEffect)(() => {
- updateGroupState();
- }, [
+ return (0, exports.getFormGroupState)(fieldStates);
// eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ name,
+ formGroups,
JSON.stringify(dirtyFieldsNames),
- // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(errorsNames),
- // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(touchedFieldsNames),
- // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(validatingFieldsNames),
- updateGroupState,
- name,
- formGroups,
]);
- (0, react_1.useEffect)(() => {
- if (!formGroups)
- return;
- // Whenever the group content changes (input are added or removed)
- // we must update its state
- const unsubscribe = formGroups.subscribe(name, () => {
- updateGroupState();
- });
- return unsubscribe;
- }, [formGroups, name, updateGroupState]);
- return state;
};
exports.useFormGroup = useFormGroup;
/**
diff --git a/dist/form/groups/useFormGroup.js b/dist/form/groups/useFormGroup.js
index 38cff4869ec03ff5ea758c3d2a5ee613232c0ff8..ee3a36c89c1b498ccfb3fc687e80195a0367219f 100644
--- a/dist/form/groups/useFormGroup.js
+++ b/dist/form/groups/useFormGroup.js
@@ -1,9 +1,7 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useReducer } from 'react';
import get from 'lodash/get.js';
-import isEqual from 'lodash/isEqual.js';
import { useFormState } from 'react-hook-form';
import { useFormGroups } from "./useFormGroups.js";
-import { useEvent } from "../../util/index.js";
/**
* Retrieve a specific form group data such as its validation status (valid/invalid) or
* or whether its inputs have been updated (dirty/pristine)
@@ -52,22 +50,43 @@ export const useFormGroup = (name) => {
// dirtyFields, touchedFields, validatingFields and errors are objects with keys being the field names
// Ex: { title: true }
// However, they are not correctly serialized when using JSON.stringify
- // To avoid our effects to not be triggered when they should, we extract the keys and use that as a dependency
+ // To avoid recomputing when they should not, we extract the keys and use that as a dependency
const dirtyFieldsNames = Object.keys(dirtyFields);
const touchedFieldsNames = Object.keys(touchedFields);
const validatingFieldsNames = Object.keys(validatingFields);
const errorsNames = Object.keys(errors);
const formGroups = useFormGroups();
- const [state, setState] = useState({
- errors: undefined,
- isDirty: false,
- isTouched: false,
- isValid: true,
- isValidating: true,
- });
- const updateGroupState = useEvent(() => {
+ // Local patch: the group state used to live in its own useState,
+ // recomputed from a useEffect that re-ran whenever useFormState()'s snapshot
+ // changed. That effect fires (and calls setState) in a *separate* commit from
+ // the one that already re-rendered this component for the new formState, i.e.
+ // an extra commit-phase update per validate/dirty/touch tick. With many fields
+ // resolving their (mostly async, mostly no-op) validation one microtask at a
+ // time during a full-form submit, and several of these hooks mounted at once
+ // (one per TabbedForm tab), those extra commits chain into each other. React 19
+ // counts this nesting far more aggressively than React 18 did, so what used to
+ // be merely wasteful now throws "Maximum update depth exceeded" on forms with
+ // many tabs/fields. Computing the group state synchronously with useMemo folds
+ // it into the SAME commit as the triggering useFormState() update instead of
+ // scheduling a new one.
+ const [, forceRerenderOnGroupFieldsChange] = useReducer(c => c + 1, 0);
+ useEffect(() => {
if (!formGroups)
return;
+ // Whenever the group content changes (input are added or removed)
+ // we must recompute its state
+ return formGroups.subscribe(name, forceRerenderOnGroupFieldsChange);
+ }, [formGroups, name]);
+ return useMemo(() => {
+ if (!formGroups) {
+ return {
+ errors: undefined,
+ isDirty: false,
+ isTouched: false,
+ isValid: true,
+ isValidating: true,
+ };
+ }
const fields = formGroups.getGroupFields(name);
const fieldStates = fields
.map(field => {
@@ -81,40 +100,16 @@ export const useFormGroup = (name) => {
};
})
.filter(fieldState => fieldState != undefined); // eslint-disable-line
- const newState = getFormGroupState(fieldStates);
- setState(oldState => {
- if (!isEqual(oldState, newState)) {
- return newState;
- }
- return oldState;
- });
- });
- useEffect(() => {
- updateGroupState();
- }, [
+ return getFormGroupState(fieldStates);
// eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ name,
+ formGroups,
JSON.stringify(dirtyFieldsNames),
- // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(errorsNames),
- // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(touchedFieldsNames),
- // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(validatingFieldsNames),
- updateGroupState,
- name,
- formGroups,
]);
- useEffect(() => {
- if (!formGroups)
- return;
- // Whenever the group content changes (input are added or removed)
- // we must update its state
- const unsubscribe = formGroups.subscribe(name, () => {
- updateGroupState();
- });
- return unsubscribe;
- }, [formGroups, name, updateGroupState]);
- return state;
};
/**
* Get the state of a form group
diff --git a/dist/form/useInput.cjs b/dist/form/useInput.cjs
index e677a4d381368e795ad630a9df1419bcbb590741..2889690d26d9d931832f10c4bcc9ec8aeb1ed3be 100644
--- a/dist/form/useInput.cjs
+++ b/dist/form/useInput.cjs
@@ -46,28 +46,29 @@ const useInput = (props) => {
// This ensures dynamically added inputs have their value set correctly (ArrayInput for example).
// We don't do this for the form level defaultValues so that it works as it should in react-hook-form
// (i.e. field level defaultValue override form level defaultValues for this field).
+ // Local patch: see dist/form/useInput.js for the rationale.
const { field: controllerField, fieldState, formState, } = (0, react_hook_form_1.useController)({
name: finalName,
defaultValue: (0, get_js_1.default)(record, finalSource, defaultValue),
- rules: {
- validate: async (value, values) => {
- if (!sanitizedValidate)
- return true;
- const error = await sanitizedValidate(value, values, {
- ...props,
- finalSource,
- });
- if (!error)
- return true;
- // react-hook-form expects errors to be plain strings but our validators can return objects
- // that have message and args.
- // To avoid double translation for users that validate with a schema instead of our validators
- // we use a special format for our validators errors.
- // The ValidationError component will check for this format and extract the message and args
- // to translate.
- return `@@react-admin@@${JSON.stringify(error)}`;
- },
- },
+ rules: sanitizedValidate
+ ? {
+ validate: async (value, values) => {
+ const error = await sanitizedValidate(value, values, {
+ ...props,
+ finalSource,
+ });
+ if (!error)
+ return true;
+ // react-hook-form expects errors to be plain strings but our validators can return objects
+ // that have message and args.
+ // To avoid double translation for users that validate with a schema instead of our validators
+ // we use a special format for our validators errors.
+ // The ValidationError component will check for this format and extract the message and args
+ // to translate.
+ return `@@react-admin@@${JSON.stringify(error)}`;
+ },
+ }
+ : undefined,
...options,
});
// Because our forms may receive an asynchronously loaded record for instance,
diff --git a/dist/form/useInput.js b/dist/form/useInput.js
index fcf726c966fa6aaf24243521326e24cc43e05023..14f5ed40507553fa553ea8c4c5ebb20b69f46f4f 100644
--- a/dist/form/useInput.js
+++ b/dist/form/useInput.js
@@ -40,28 +40,37 @@ export const useInput = (props) => {
// This ensures dynamically added inputs have their value set correctly (ArrayInput for example).
// We don't do this for the form level defaultValues so that it works as it should in react-hook-form
// (i.e. field level defaultValue override form level defaultValues for this field).
+ // Local patch: react-admin used to register an async `validate`
+ // for every field regardless of whether it had real validators. On a form with
+ // many fields, react-hook-form flips `validatingFields` for each of them one
+ // microtask at a time during a full-form submit validation, and under React 19
+ // (which nests commit-phase updates more aggressively than React 18) that flood
+ // trips "Maximum update depth exceeded" in components subscribed to form state
+ // (e.g. TabbedForm's FormTabHeader via useFormGroup). Registering `validate`
+ // only when there is an actual validator removes the async churn for fields
+ // that never needed it.
const { field: controllerField, fieldState, formState, } = useController({
name: finalName,
defaultValue: get(record, finalSource, defaultValue),
- rules: {
- validate: async (value, values) => {
- if (!sanitizedValidate)
- return true;
- const error = await sanitizedValidate(value, values, {
- ...props,
- finalSource,
- });
- if (!error)
- return true;
- // react-hook-form expects errors to be plain strings but our validators can return objects
- // that have message and args.
- // To avoid double translation for users that validate with a schema instead of our validators
- // we use a special format for our validators errors.
- // The ValidationError component will check for this format and extract the message and args
- // to translate.
- return `@@react-admin@@${JSON.stringify(error)}`;
- },
- },
+ rules: sanitizedValidate
+ ? {
+ validate: async (value, values) => {
+ const error = await sanitizedValidate(value, values, {
+ ...props,
+ finalSource,
+ });
+ if (!error)
+ return true;
+ // react-hook-form expects errors to be plain strings but our validators can return objects
+ // that have message and args.
+ // To avoid double translation for users that validate with a schema instead of our validators
+ // we use a special format for our validators errors.
+ // The ValidationError component will check for this format and extract the message and args
+ // to translate.
+ return `@@react-admin@@${JSON.stringify(error)}`;
+ },
+ }
+ : undefined,
...options,
});
// Because our forms may receive an asynchronously loaded record for instance,
Versions
react-admin/ra-core/ra-ui-materialui: 5.15.3react-hook-form: 7.87.0react/react-dom: 19.2.7What happened
Submitting a
<TabbedForm>with several tabs and enough fields throws:React DevTools shows the crashing
setStatebelongs toFormTabHeader(owner:FormTab), i.e. it comes fromuseFormGroup.This looks like the same class of bug fixed for
<ReferenceField>in #11329 ("Fix Maximum update depth exceeded when many ReferenceFields resolve at once"), just for a different consumer:Root cause
useFormGroup(packages/ra-core/src/form/groups/useFormGroup.ts) derives its return value in auseState+useEffect:Because this
setStatefires from auseEffectthat reacts touseFormState()'s snapshot, it lands in a separate commit from the one that already re-renderedFormTabHeaderfor the new form state — an extra "commit-phase update" per tick.Separately,
useInput(packages/ra-core/src/form/useInput.ts) registers anasyncvalidatefor every field regardless of whether it has real validators:So on a full-form submit validation,
react-hook-formflipsvalidatingFieldsfor every field, one microtask at a time (this is the same trigger that caused #10068 / #10032, fixed upstream inreact-hook-form7.53 — we're already on 7.87, so that fix doesn't help here). With aTabbedFormthat has several tabs (each mounting auseFormGroupinstance, sinceTabbedFormViewdeliberately mounts all tabs' content at once "to allow validation on tabs not in focus") and many fields, this produces a burst of extra commit-phase updates. React 18 tolerated this (per the reasoning in #11329); React 19 does not, and throws "Maximum update depth exceeded" partway throughhandleSubmit's validation pass — so the mutation never runs and the form silently fails to save.Reproduction
A
<TabbedForm>with e.g. 5-7<FormTab>s, severalTextInputs per tab (some withvalidate={required()}, most without), submitted via<SaveButton>. The larger the tab × field count, the more reliably it reproduces; our production form (multi-step hiring-request edit form, 7 tabs) hits it consistently. I can try to put together a minimal reproduction if useful — happy to help.Patch we're running locally
Two changes, applied via
yarn patchagainstra-core@5.15.3:useFormGroup: compute the group state synchronously withuseMemoinstead ofuseState+useEffect, so it's folded into the same commit as theuseFormState()update that triggered it, instead of scheduling a new one:useInput/ArrayInputBase: only registerrules.validatewhen there is an actual validator, instead of always registering an async no-op — reduces (but on its own does not eliminate) how oftenvalidatingFieldschurns:We verified (via
git stash) that change 1 alone (theuseFormGrouprewrite) is sufficient to fix the crash in our app; change 2 is a smaller-impact mitigation we kept anyway. Full patch (applied to the compileddistoutput, both ESM and CJS) attached below for reference — happy to open a PR againstpackages/ra-core/srcinstead if that's preferred.Patch (dist, both useFormGroup and useInput/ArrayInputBase, ESM+CJS)