/
githubmirror
/
react-hook-form
Обзор
Документация
Войти
/
githubmirror
/
react-hook-form
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/__tests__/formStateSubscribe.test.tsx
697 строк
19 KB
Noé TATOUD
🎧 feat: <FormStateSubscribe /> component (#13142)
29 ноя 2025, 00:09
Не верифицирован
29 ноя 2025, 00:09
43bcf05
Код
Авторство
О чём код?
// __tests__/formState.component.test.tsx import React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Controller } from '../controller'; import { FormStateSubscribe } from '../formStateSubscribe'; import type { Control } from '../types'; import { useFieldArray } from '../useFieldArray'; import { useForm } from '../useForm'; import { FormProvider } from '../useFormContext'; import deepEqual from '../utils/deepEqual'; import noop from '../utils/noop'; describe('FormStateSubscribe', () => { it('should render correct form state with isDirty, dirty, touched', () => { let count = 0; const Test = ({ control }: { control: Control<{ test: string }> }) => ( <FormStateSubscribe control={control} render={({ isDirty, dirtyFields, touchedFields }) => ( <> <div>{isDirty ? 'isDirty' : ''}</div> <div>{dirtyFields['test'] ? 'dirty field' : ''}</div> <div>{touchedFields['test'] ? 'isTouched' : ''}</div> </> )} /> ); const Component = () => { const { register, control } = useForm<{ test: string }>(); count++; return ( <div> <input aria-label="test" {...register('test')} /> <Test control={control} /> </div> ); }; render(<Component />); fireEvent.input(screen.getByLabelText('test'), { target: { value: 'test' }, }); expect(screen.getByText('isDirty')).toBeVisible(); expect(screen.getByText('dirty field')).toBeVisible(); expect(count).toEqual(2); fireEvent.blur(screen.getByLabelText('test')); expect(screen.getByText('isTouched')).toBeVisible(); expect(count).toEqual(2); }); it('should render correct isolated errors message', async () => { let count = 0; const Test = ({ control }: { control: Control }) => ( <FormStateSubscribe control={control} render={({ errors, isValid }) => ( <> <div>{errors['test'] ? 'error' : 'valid'}</div> <div>{isValid ? 'yes' : 'no'}</div> </> )} /> ); const Component = () => { const { register, control } = useForm({ mode: 'onChange' }); count++; return ( <div> <input aria-label="test" {...register('test', { minLength: 5 })} /> <Test control={control} /> </div> ); }; render(<Component />); await waitFor(() => expect(screen.getByText('yes')).toBeVisible()); fireEvent.input(screen.getByLabelText('test'), { target: { value: 'test' }, }); expect(await screen.findByText('error')).toBeVisible(); expect(screen.getByText('no')).toBeVisible(); fireEvent.input(screen.getByLabelText('test'), { target: { value: 'testtest' }, }); expect(await screen.findByText('valid')).toBeVisible(); expect(screen.getByText('yes')).toBeVisible(); expect(count).toEqual(2); }); it('should update isValidating correctly', async () => { function Child() { return ( <FormStateSubscribe render={({ isDirty, isValid, isValidating }) => { const enabled = !isValidating && isDirty && isValid; return ( <button disabled={!enabled} type="submit"> Submit </button> ); }} /> ); } function App() { const form = useForm({ mode: 'onChange' }); const { register } = form; return ( <FormProvider {...form}> <form> <input {...register('value', { required: true })} /> <Child /> </form> </FormProvider> ); } render(<App />); fireEvent.change(screen.getByRole('textbox'), { target: { value: '1' } }); await waitFor(() => expect(screen.getByRole('button')).not.toBeDisabled()); fireEvent.change(screen.getByRole('textbox'), { target: { value: '12' } }); await waitFor(() => expect(screen.getByRole('button')).not.toBeDisabled()); }); it('should update formState separately with multiple FormState instances', async () => { let count = 0; let testCount = 0; let test1Count = 0; const Test1 = ({ control }: { control: Control }) => ( <FormStateSubscribe control={control} render={({ isDirty, dirtyFields }) => { testCount++; return ( <> <div> {dirtyFields['test'] ? 'hasDirtyField' : 'notHasDirtyField'} </div> <div>{isDirty ? 'isDirty' : 'notDirty'}</div> </> ); }} /> ); const Test = ({ control }: { control: Control }) => ( <FormStateSubscribe control={control} render={({ touchedFields }) => { test1Count++; return ( <div>{touchedFields['test'] ? 'isTouched' : 'notTouched'}</div> ); }} /> ); const Component = () => { const { register, control } = useForm({ mode: 'onChange' }); count++; return ( <div> <input aria-label="test" {...register('test', { minLength: 5 })} /> <Test control={control} /> <Test1 control={control} /> </div> ); }; render(<Component />); fireEvent.input(screen.getByLabelText('test'), { target: { value: 'test' }, }); expect(await screen.findByText('hasDirtyField')).toBeVisible(); expect(screen.getByText('isDirty')).toBeVisible(); expect(count).toEqual(2); expect(testCount).toEqual(3); expect(test1Count).toEqual(2); fireEvent.blur(screen.getByLabelText('test')); expect(screen.getByText('isTouched')).toBeVisible(); expect(count).toEqual(2); expect(testCount).toEqual(3); expect(test1Count).toEqual(3); fireEvent.input(screen.getByLabelText('test'), { target: { value: '' } }); expect(count).toEqual(2); expect(testCount).toEqual(3); expect(test1Count).toEqual(3); }); it('should render correct submit state', async () => { let count = 0; const Test = ({ control }: { control: Control }) => ( <FormStateSubscribe control={control} render={({ isSubmitted, submitCount }) => ( <> <div>{isSubmitted ? 'isSubmitted' : ''}</div> <div>{submitCount}</div> </> )} /> ); const Component = () => { const { control, handleSubmit } = useForm(); count++; return ( <form onSubmit={handleSubmit(noop)}> <Test control={control} /> <button>Submit</button> </form> ); }; render(<Component />); fireEvent.click(screen.getByRole('button')); expect(await screen.findByText('isSubmitted')).toBeVisible(); expect(screen.getByText('1')).toBeVisible(); expect(count).toEqual(2); }); it('should only re-render when subscribed field name updated', async () => { let count = 0; type FormValues = { firstName: string; lastName: string }; const Test = ({ control }: { control: Control<FormValues> }) => ( <FormStateSubscribe control={control} name="firstName" render={({ errors }) => { count++; return <>{errors?.firstName?.message}</>; }} /> ); const Component = () => { const { control, register } = useForm<FormValues>({ mode: 'onChange', defaultValues: { firstName: 'a', lastName: 'b' }, }); return ( <form> <Test control={control} /> <input {...register('firstName', { required: true })} placeholder="firstName" /> <input {...register('lastName')} /> </form> ); }; render(<Component />); fireEvent.change(screen.getByPlaceholderText('firstName'), { target: { value: '' }, }); await waitFor(() => expect(count).toEqual(2)); }); it('should not re-render when subscribed field name is not included', () => { let count = 0; type FormValues = { firstName: string; lastName: string }; const Test = ({ control }: { control: Control<FormValues> }) => ( <FormStateSubscribe control={control} name="lastName" render={({ errors }) => { count++; return <>{errors?.lastName?.message}</>; }} /> ); const Component = () => { const { control, register } = useForm<FormValues>({ mode: 'onChange', defaultValues: { firstName: 'a', lastName: 'b' }, }); return ( <form> <Test control={control} /> <input {...register('firstName', { required: true })} placeholder="firstName" /> <input {...register('lastName')} /> </form> ); }; render(<Component />); fireEvent.change(screen.getByPlaceholderText('firstName'), { target: { value: '' }, }); expect(count).toEqual(2); }); it('should only re-render when subscribed field names updated (array)', async () => { let count = 0; type FormValues = { firstName: string; lastName: string; age: number }; const Test = ({ control }: { control: Control<FormValues> }) => ( <FormStateSubscribe control={control} name={['firstName', 'lastName']} render={({ errors }) => { count++; return <>{errors?.firstName?.message}</>; }} /> ); const Component = () => { const { control, register } = useForm<FormValues>({ mode: 'onChange', defaultValues: { firstName: 'a', lastName: 'b' }, }); return ( <form> <Test control={control} /> <input {...register('firstName', { required: true })} placeholder="firstName" /> <input {...register('lastName', { required: true })} placeholder="lastName" /> <input {...register('age', { valueAsNumber: true, required: true })} type="number" /> </form> ); }; render(<Component />); fireEvent.change(screen.getByPlaceholderText('firstName'), { target: { value: '' }, }); fireEvent.change(screen.getByPlaceholderText('lastName'), { target: { value: '' }, }); await waitFor(() => expect(count).toEqual(2)); }); it('should not re-render when a non-subscribed field changes', () => { let count = 0; type FormValues = { firstName: string; lastName: string; age: number }; const Test = ({ control }: { control: Control<FormValues> }) => ( <FormStateSubscribe control={control} name={['age', 'lastName']} render={() => { count++; return null; }} /> ); const Component = () => { const { control, register } = useForm<FormValues>({ mode: 'onChange', defaultValues: { firstName: 'a', lastName: 'b' }, }); return ( <form> <Test control={control} /> <input {...register('firstName', { required: true })} placeholder="firstName" /> <input {...register('lastName')} placeholder="lastName" /> <input {...register('age', { valueAsNumber: true, required: true })} type="number" /> </form> ); }; render(<Component />); fireEvent.change(screen.getByPlaceholderText('firstName'), { target: { value: '' }, }); expect(count).toEqual(2); }); it('should be able to stop the formState subscription (disabled toggle)', async () => { type FormValues = { test: string }; function Child({ control }: { control: Control<FormValues> }) { const [disabled, setDisabled] = React.useState(true); return ( <div> <FormStateSubscribe control={control} name="test" disabled={disabled} render={({ errors }) => (errors.test ? <p>error</p> : null)} /> <button onClick={() => setDisabled((v) => !v)}>toggle</button> </div> ); } const App = () => { const { trigger, register, control } = useForm<FormValues>(); return ( <div> <input {...register('test', { required: true })} /> <Child control={control} /> <button onClick={() => trigger()}>trigger</button> </div> ); }; render(<App />); fireEvent.click(screen.getByRole('button', { name: 'trigger' })); expect(screen.queryByText('error')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'toggle' })); fireEvent.click(screen.getByRole('button', { name: 'trigger' })); expect(await screen.findByText('error')).toBeVisible(); }); it('should not start early subscription in StrictMode flow', async () => { type FormValues = { test: { data: string }[] }; function FieldArray() { const { reset, control } = useForm<FormValues>({ defaultValues: { test: [] }, }); const { fields, append } = useFieldArray({ control, name: 'test' }); return ( <div> {/* We include FormState to ensure it doesn't cause early subscribe */} <FormStateSubscribe control={control} render={() => null} /> {fields.map((field, index) => ( <div key={field.id}> <Controller control={control} name={`test.${index}.data` as const} render={({ field }) => <input {...field} />} /> </div> ))} <button onClick={() => append({ data: 'data' })}>add</button> <button onClick={() => reset({})}>reset</button> </div> ); } render(<FieldArray />, { reactStrictMode: true }); fireEvent.click(screen.getByRole('button', { name: 'add' })); fireEvent.click(screen.getByRole('button', { name: 'reset' })); fireEvent.click(screen.getByRole('button', { name: 'add' })); expect(await screen.findAllByRole('textbox')).toHaveLength(1); }); it('should subscribe to exact form state update (exact)', () => { const App = () => { const { control, register } = useForm(); const [exact, setExact] = React.useState(true); return ( <div> <input {...register('testData')} /> <FormStateSubscribe name="test" control={control} exact={exact} render={({ touchedFields }) => ( <p>{touchedFields.testData && 'touched'}</p> )} /> <button onClick={() => setExact(false)}>toggle</button> </div> ); }; render(<App />); fireEvent.focus(screen.getByRole('textbox')); fireEvent.blur(screen.getByRole('textbox')); expect(screen.queryByText('touched')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button')); fireEvent.focus(screen.getByRole('textbox')); fireEvent.blur(screen.getByRole('textbox')); expect(screen.getByText('touched')).toBeVisible(); }); it('should be able to access defaultValues', () => { type FormValues = { firstName: string; lastName: string }; const defaultValues = { firstName: 'a', lastName: 'b' }; const Test = ({ control }: { control: Control<FormValues> }) => ( <FormStateSubscribe control={control} render={(formState) => ( <p> {deepEqual(formState.defaultValues, defaultValues) ? 'yes' : 'no'} </p> )} /> ); const Component = () => { const { control } = useForm<FormValues>({ defaultValues }); return <Test control={control} />; }; render(<Component />); expect(screen.getByText('yes')).toBeVisible(); }); it('should conditionally update formState after mount', async () => { function DirtyState() { return ( <FormStateSubscribe render={({ isDirty, isValid }) => ( <div> <p>{isDirty ? 'dirty' : 'pristine'}</p> <p>{isValid ? 'valid' : 'error'}</p> </div> )} /> ); } function App() { const [showDirty, toggleShowDirty] = React.useReducer((p) => !p, false); const form = useForm({ defaultValues: { firstname: '' } }); return ( <FormProvider {...form}> {showDirty && <DirtyState />} <input {...form.register('firstname', { required: true })} /> <button type="button" onClick={toggleShowDirty} /> </FormProvider> ); } render(<App />); expect(screen.queryByRole('pristine')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' }, }); fireEvent.click(screen.getByRole('button')); expect(await screen.queryByText('dirty')).toBeNull(); expect(await screen.findByText('valid')).toBeVisible(); }); it('should subscribe and update formState (errors via submit)', async () => { function App() { const { register, control, handleSubmit } = useForm({ defaultValues: { firstName: '' }, }); return ( <form onSubmit={handleSubmit(noop)}> <input {...register('firstName', { required: 'Required' })} /> <FormStateSubscribe control={control} render={({ errors }) => <p>{errors.firstName?.message}</p>} /> <button>Submit</button> </form> ); } render(<App />); fireEvent.click(screen.getByRole('button')); await waitFor(() => screen.getByText('Required')); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'data' }, }); await waitFor(() => expect(screen.queryByText('Required')).not.toBeInTheDocument(), ); }); it('should return the latest values with async values', async () => { type FormValues = { firstName: string }; function Input({ control }: { control: Control<FormValues> }) { return ( <FormStateSubscribe control={control} render={({ isValid }) => <p>{String(isValid)}</p>} /> ); } function Form({ values }: { values: FormValues }) { const { getValues, control } = useForm<FormValues>({ defaultValues: { firstName: '' }, values, resetOptions: { keepDefaultValues: true }, }); return ( <> <p>{getValues().firstName}</p> <Input control={control} /> </> ); } function App() { return <Form values={{ firstName: 'test' }} />; } render(<App />); await waitFor(() => { screen.getByText('test'); }); }); it('should reflect disabled state from useForm', async () => { function Form({ control }: { control: Control }) { return ( <FormStateSubscribe control={control} render={({ disabled }) => <p>{disabled ? 'disabled' : ''}</p>} /> ); } function App() { const { control } = useForm({ disabled: true }); return <Form control={control} />; } render(<App />); await waitFor(() => { screen.getByText('disabled'); }); }); });