diff --git a/packages/ra-core/src/form/groups/useFormGroup.spec.tsx b/packages/ra-core/src/form/groups/useFormGroup.spec.tsx
index 12ddb377c3c..60cc71a0551 100644
--- a/packages/ra-core/src/form/groups/useFormGroup.spec.tsx
+++ b/packages/ra-core/src/form/groups/useFormGroup.spec.tsx
@@ -8,6 +8,7 @@ import {
TextInput,
} from 'ra-ui-materialui';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { useFormContext } from 'react-hook-form';
import expect from 'expect';
import { FormGroupContextProvider } from './FormGroupContextProvider';
import { testDataProvider } from '../../dataProvider';
@@ -281,4 +282,69 @@ describe('useFormGroup', () => {
});
});
});
+
+ // https://github.com/marmelab/react-admin/issues/11290
+ it('should not re-render when a field validating state changes', async () => {
+ // useFormGroup must not subscribe to react-hook-form's validatingFields.
+ // Subscribing re-renders every form group on each per-field validation
+ // toggle, which makes large TabbedForms exceed React's maximum update
+ // depth on submit.
+ const asyncValidate = () => Promise.resolve(undefined);
+
+ let renderCount = 0;
+ const GroupRenderCounter = React.memo(() => {
+ useFormGroup('group');
+ renderCount++;
+ return null;
+ });
+ GroupRenderCounter.displayName = 'GroupRenderCounter';
+
+ // Re-validates the field without changing its value/dirty/touched state,
+ // so the only form state that changes is validatingFields.
+ const RevalidateButton = () => {
+ const { trigger } = useFormContext();
+ return (
+
+ );
+ };
+
+ render(
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ await waitFor(() => expect(renderCount).toBeGreaterThan(0));
+ await new Promise(resolve => setTimeout(resolve, 50));
+ const rendersBeforeRevalidation = renderCount;
+
+ // Re-validate the field many times. Each re-validation toggles
+ // validatingFields. When the group subscribes to validatingFields (the
+ // bug), every toggle re-renders the group, so the count grows with each
+ // re-validation; without that subscription it stays flat.
+ const REVALIDATIONS = 10;
+ for (let i = 0; i < REVALIDATIONS; i++) {
+ fireEvent.click(screen.getByText('revalidate'));
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ // The group must not re-render on each per-field validation toggle.
+ expect(renderCount - rendersBeforeRevalidation).toBeLessThan(
+ REVALIDATIONS
+ );
+ });
});
diff --git a/packages/ra-core/src/form/groups/useFormGroup.ts b/packages/ra-core/src/form/groups/useFormGroup.ts
index ca10641ecc5..eecf8ac53cb 100644
--- a/packages/ra-core/src/form/groups/useFormGroup.ts
+++ b/packages/ra-core/src/form/groups/useFormGroup.ts
@@ -66,16 +66,14 @@ type FormGroupState = {
* @returns {FormGroupState} The form group state
*/
export const useFormGroup = (name: string): FormGroupState => {
- const { dirtyFields, touchedFields, validatingFields, errors } =
- useFormState();
+ const { dirtyFields, touchedFields, errors } = useFormState();
- // dirtyFields, touchedFields, validatingFields and errors are objects with keys being the field names
+ // dirtyFields, touchedFields 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
const dirtyFieldsNames = Object.keys(dirtyFields);
const touchedFieldsNames = Object.keys(touchedFields);
- const validatingFieldsNames = Object.keys(validatingFields);
const errorsNames = Object.keys(errors);
const formGroups = useFormGroups();
@@ -97,8 +95,13 @@ export const useFormGroup = (name: string): FormGroupState => {
error: get(errors, field, undefined),
isDirty: get(dirtyFields, field, false) !== false,
isValid: get(errors, field, undefined) == null,
- isValidating:
- get(validatingFields, field, undefined) == null,
+ // We intentionally do not derive isValidating from
+ // react-hook-form's validatingFields. Subscribing to it
+ // re-renders every form group on each per-field validation
+ // toggle, which on a large TabbedForm exceeds React's
+ // maximum update depth on submit. The group-level
+ // isValidating value is not consumed anywhere.
+ isValidating: false,
isTouched: get(touchedFields, field, false) !== false,
};
})
@@ -123,8 +126,6 @@ export const useFormGroup = (name: string): FormGroupState => {
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,
diff --git a/packages/ra-ui-materialui/src/form/TabbedForm.stories.tsx b/packages/ra-ui-materialui/src/form/TabbedForm.stories.tsx
index 5fc3b6d746f..eb704a918e5 100644
--- a/packages/ra-ui-materialui/src/form/TabbedForm.stories.tsx
+++ b/packages/ra-ui-materialui/src/form/TabbedForm.stories.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import {
RaRecord,
+ required,
ResourceContextProvider,
testDataProvider,
TestMemoryRouter,
@@ -184,3 +185,25 @@ export const EncodedPaths = () => (
);
+
+// https://github.com/marmelab/react-admin/issues/11290
+// Submitting this form (with the fields empty) used to throw
+// "Maximum update depth exceeded" because each per-tab FormGroup re-rendered
+// on every per-field validation toggle. Click SAVE to check it no longer does.
+export const ManyRequiredInputs = () => (
+
+
+ {Array.from({ length: 6 }, (_, tab) => (
+
+ {Array.from({ length: 8 }, (_, field) => (
+
+ ))}
+
+ ))}
+
+
+);