From cef5f4f96dede43b6a5ce8778e1008f6dcb06e32 Mon Sep 17 00:00:00 2001 From: Syed Imran Abidi Date: Wed, 24 Jun 2026 17:03:49 +0530 Subject: [PATCH 1/2] Fix 'Maximum update depth exceeded' on large TabbedForm submit useFormGroup subscribed to react-hook-form's validatingFields only to compute a group-level isValidating that is not consumed anywhere (both FormTabHeader and TranslatableInputsTab read isValid only). Because react-admin wraps every validator as async, react-hook-form toggles validatingFields per field on submit; subscribing to it re-renders every form group on each toggle, so a TabbedForm with many required fields exceeds React's maximum update depth. Stop subscribing to validatingFields. Tab error badges and validation (driven by errors/isValid) are unaffected. Closes #11290 --- .../src/form/groups/useFormGroup.spec.tsx | 66 +++++++++++++++++++ .../ra-core/src/form/groups/useFormGroup.ts | 17 ++--- 2 files changed, 75 insertions(+), 8 deletions(-) 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, From c047eb53c5a018dde019295b1ad1ff57ca54ef68 Mon Sep 17 00:00:00 2001 From: Syed Imran Abidi Date: Wed, 24 Jun 2026 17:08:59 +0530 Subject: [PATCH 2/2] Add TabbedForm story reproducing the maximum update depth issue --- .../src/form/TabbedForm.stories.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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) => ( + + ))} + + ))} + + +);