Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/ra-core/src/form/groups/useFormGroup.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TextInput,
} from 'ra-ui-materialui';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useWatch } from 'react-hook-form';
import expect from 'expect';
import { FormGroupContextProvider } from './FormGroupContextProvider';
import { testDataProvider } from '../../dataProvider';
Expand Down Expand Up @@ -222,6 +223,108 @@ describe('useFormGroup', () => {
});
});

it('should update the group state in the same render as the form state it derives from', async () => {
// Regression test for https://github.com/marmelab/react-admin/issues/11368
// Computing the group state in an effect schedules an additional commit
// for each form state update. Under React 19, chains of such commit-phase
// updates make React throw "Maximum update depth exceeded" on forms with
// many fields, so the group state must be derived during render.
const observations: Array<{ value: string; isDirty: boolean }> = [];
const GroupStateProbe = () => {
const { isDirty } = useFormGroup('simplegroup');
const value = useWatch({ name: 'url' });
React.useLayoutEffect(() => {
observations.push({ value, isDirty });
});
return null;
};

render(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="posts">
<SimpleForm mode="onChange">
<FormGroupContextProvider name="simplegroup">
<GroupStateProbe />
<TextInput source="url" />
</FormGroupContextProvider>
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);

await waitFor(() => {
expect(observations.length).toBeGreaterThan(0);
});

const input = screen.getByLabelText('resources.posts.fields.url');
fireEvent.change(input, {
target: { value: 'test' },
});

await waitFor(() => {
expect(
observations.some(
({ value, isDirty }) => value === 'test' && isDirty
)
).toBe(true);
});

// the group state must never lag behind the form state it derives from
expect(
observations.filter(
({ value, isDirty }) => value === 'test' && !isDirty
)
).toEqual([]);
});

it('should recompute the group state when a field is added to or removed from the group', async () => {
let state;
const GroupState = () => {
state = useFormGroup('simplegroup');
return null;
};
const ToggleInput = () => {
const [showInput, setShowInput] = React.useState(true);
return (
<>
<button onClick={() => setShowInput(show => !show)}>
Toggle input
</button>
{showInput && <TextInput source="url" />}
</>
);
};

render(
<AdminContext dataProvider={testDataProvider()}>
<ResourceContextProvider value="posts">
<SimpleForm>
<FormGroupContextProvider name="simplegroup">
<GroupState />
<ToggleInput />
</FormGroupContextProvider>
</SimpleForm>
</ResourceContextProvider>
</AdminContext>
);

await waitFor(() => {
expect(state).toEqual({
errors: {},
isDirty: false,
isTouched: false,
isValid: true,
isValidating: false,
});
});

fireEvent.click(screen.getByText('Toggle input'));

await waitFor(() => {
expect(state.errors).toBeUndefined();
});
});

it('should return correct group state when an ArrayInput is in the group', async () => {
let state;
const IsDirty = () => {
Expand Down
72 changes: 32 additions & 40 deletions packages/ra-core/src/form/groups/useFormGroup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { useEffect, useState } from 'react';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import get from 'lodash/get.js';
import isEqual from 'lodash/isEqual.js';
import { useFormState } from 'react-hook-form';
import { useFormGroups } from './useFormGroups';
import { useEvent } from '../../util';

type FieldState = {
name: string;
Expand All @@ -22,6 +20,16 @@ type FormGroupState = {
isValidating: boolean;
};

const EMPTY_FORM_GROUP_STATE: FormGroupState = {
errors: undefined,
isDirty: false,
isTouched: false,
isValid: true,
isValidating: true,
};

const EMPTY_GROUP_FIELDS: string[] = [];

/**
* Retrieve a specific form group data such as its validation status (valid/invalid) or
* or whether its inputs have been updated (dirty/pristine)
Expand Down Expand Up @@ -72,24 +80,32 @@ export const useFormGroup = (name: string): FormGroupState => {
// 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 missing updates, we extract the keys and use them 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<FormGroupState>({
errors: undefined,
isDirty: false,
isTouched: false,
isValid: true,
isValidating: true,
});

const updateGroupState = useEvent(() => {
if (!formGroups) return;
const subscribe = useCallback(
(onStoreChange: () => void) =>
formGroups?.subscribe(name, onStoreChange) ?? (() => undefined),
[formGroups, name]
);
const getSnapshot = useCallback(() => {
if (!formGroups) {
return null;
}
const fields = formGroups.getGroupFields(name);
return fields.length ? fields : EMPTY_GROUP_FIELDS;
}, [formGroups, name]);
const fields = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

return useMemo(() => {
if (!fields) {
return EMPTY_FORM_GROUP_STATE;
}
const fieldStates = fields
.map<FieldState>(field => {
return {
Expand All @@ -104,19 +120,10 @@ export const useFormGroup = (name: string): FormGroupState => {
})
.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
}, [
fields,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(dirtyFieldsNames),
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -125,22 +132,7 @@ export const useFormGroup = (name: string): FormGroupState => {
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;
};

/**
Expand Down
Loading