diff --git a/src/Field.js b/src/Field.js index baab6f01..4e7706e8 100644 --- a/src/Field.js +++ b/src/Field.js @@ -16,6 +16,7 @@ const Field = ({ formatOnBlur, initialValue, isEqual, + keepFieldStateOnUnmount, multiple, name, parse, @@ -38,6 +39,7 @@ const Field = ({ formatOnBlur, initialValue, isEqual, + keepFieldStateOnUnmount, multiple, parse, subscription, diff --git a/src/ReactFinalForm.test.js b/src/ReactFinalForm.test.js index 5e38ac37..0de19009 100644 --- a/src/ReactFinalForm.test.js +++ b/src/ReactFinalForm.test.js @@ -912,7 +912,8 @@ describe('ReactFinalForm', () => { it('should allow an alternative form api to be passed in', () => { const onSubmit = jest.fn() const form = createForm({ onSubmit: onSubmit }) - const formMock = jest.spyOn(form, 'registerField') + const registerFieldSpy = jest.spyOn(form, 'registerField') + const subscribeToFieldStateSpy = jest.spyOn(form, 'subscribeToFieldState') render( <Form form={form}> {({ handleSubmit }) => ( @@ -922,14 +923,199 @@ describe('ReactFinalForm', () => { )} </Form> ) - expect(formMock).toHaveBeenCalled() - // called once on first render to get initial state, and then again to subscribe - expect(formMock).toHaveBeenCalledTimes(2) - expect(formMock.mock.calls[0][0]).toBe('name') - expect(formMock.mock.calls[0][2].active).toBe(true) // default subscription - expect(formMock.mock.calls[1][0]).toBe('name') - expect(formMock.mock.calls[1][2].active).toBe(true) // default subscription + // called once on first render to register only once + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(registerFieldSpy.mock.calls[0][0]).toBe('name') + expect(registerFieldSpy.mock.calls[0][1]).toBe(undefined) // no initial callback + expect(registerFieldSpy.mock.calls[0][2]).toBe(undefined) // no subscription + + // subscribe to field state once + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy.mock.calls[0][0]).toBe('name') + expect(subscribeToFieldStateSpy.mock.calls[0][2].active).toBe(true) // default subscription + }) + + it('should keep field states when field is hidden with keepFieldStateOnUnmount option', () => { + const { getByTestId, getByText } = render( + <Toggle> + {hidden => ( + <Form initialValues={{ name: 'erikras' }} onSubmit={onSubmitMock}> + {({ handleSubmit }) => ( + <form onSubmit={handleSubmit}> + {!hidden && ( + <Field + keepFieldStateOnUnmount + name="name" + validate={v => + v.toLowerCase() !== v ? 'SHOULD BE LOWERCASE' : undefined + } + > + {({ input, meta }) => ( + <> + <input {...input} data-testid="name" /> + <span data-testid="error">{meta.error}</span> + </> + )} + </Field> + )} + </form> + )} + </Form> + )} + </Toggle> + ) + + const nameField = getByTestId('name') + const errorElem = getByTestId('error') + expect(nameField).toHaveValue('erikras') + expect(errorElem).not.toHaveTextContent('SHOULD BE LOWERCASE') + + fireEvent.change(nameField, { target: { value: 'ERIKRAS' } }) + + expect(nameField).toHaveValue('ERIKRAS') + expect(errorElem).toHaveTextContent('SHOULD BE LOWERCASE') + + const toggleButton = getByText('Toggle') + // hide + fireEvent.click(toggleButton) + expect(nameField).not.toBeInTheDocument() + + // show + fireEvent.click(toggleButton) + expect(nameField).toHaveValue('ERIKRAS') + expect(errorElem).toHaveTextContent('SHOULD BE LOWERCASE') + }) + + it('should not re-register when hidden field becomes visible again with keepFieldStateOnUnmount option', () => { + const onSubmit = jest.fn() + const form = createForm({ onSubmit: onSubmit }) + const registerFieldSpy = jest.spyOn(form, 'registerField') + const subscribeToFieldStateSpy = jest.spyOn(form, 'subscribeToFieldState') + + const { getByTestId, getByText } = render( + <Toggle> + {hidden => ( + <Form form={form} onSubmit={onSubmitMock}> + {({ handleSubmit }) => ( + <form onSubmit={handleSubmit}> + {!hidden && ( + <Field + component="input" + data-testid="name" + keepFieldStateOnUnmount + name="name" + /> + )} + </form> + )} + </Form> + )} + </Toggle> + ) + + const toggleButton = getByText('Toggle') + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + + // hide + fireEvent.click(toggleButton) + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + + // show + fireEvent.click(toggleButton) + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(2) + }) + + it('should re-register with the name prop change', () => { + const onSubmit = jest.fn() + const form = createForm({ onSubmit: onSubmit }) + const registerFieldSpy = jest.spyOn(form, 'registerField') + const subscribeToFieldStateSpy = jest.spyOn(form, 'subscribeToFieldState') + + const { getByTestId, getByText } = render( + <Toggle> + {isCat => ( + <Form form={form} onSubmit={onSubmitMock}> + {({ handleSubmit }) => ( + <form onSubmit={handleSubmit}> + <Field + component="input" + data-testid="name" + name={isCat ? 'cat' : 'dog'} + /> + </form> + )} + </Form> + )} + </Toggle> + ) + + const nameField = getByTestId('name') + const toggleButton = getByText('Toggle') + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + + // change to the input field shouldn't trigger re-register + fireEvent.change(nameField, { target: { value: 'Jon' } }) + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + + fireEvent.click(toggleButton) + expect(registerFieldSpy).toHaveBeenCalledTimes(2) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(2) + + fireEvent.click(toggleButton) + expect(registerFieldSpy).toHaveBeenCalledTimes(3) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(3) + }) + + it('should re-register with the name prop change with keepFieldStateOnUnmount', () => { + const onSubmit = jest.fn() + const form = createForm({ onSubmit: onSubmit }) + const registerFieldSpy = jest.spyOn(form, 'registerField') + const subscribeToFieldStateSpy = jest.spyOn(form, 'subscribeToFieldState') + + const { getByTestId, getByText } = render( + <Toggle> + {isCat => ( + <Form form={form} onSubmit={onSubmitMock}> + {({ handleSubmit }) => ( + <form onSubmit={handleSubmit}> + <Field + component="input" + data-testid="name" + name={isCat ? 'cat' : 'dog'} + keepFieldStateOnUnmount + /> + </form> + )} + </Form> + )} + </Toggle> + ) + + const nameField = getByTestId('name') + const toggleButton = getByText('Toggle') + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + + // change to the input field shouldn't trigger re-register + fireEvent.change(nameField, { target: { value: 'Jon' } }) + expect(registerFieldSpy).toHaveBeenCalledTimes(1) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(1) + + fireEvent.click(toggleButton) + expect(registerFieldSpy).toHaveBeenCalledTimes(2) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(2) + + // this should change the name back to cat (or dog, whatever toggle toggles the toggle) + // since the states weren't removed, registration should not happen again, but subscription to field will + fireEvent.click(toggleButton) + expect(registerFieldSpy).toHaveBeenCalledTimes(2) + expect(subscribeToFieldStateSpy).toHaveBeenCalledTimes(3) }) it('should not destroy on unregister on initial unregister', () => { diff --git a/src/types.js.flow b/src/types.js.flow index 5784a865..a06d7143 100644 --- a/src/types.js.flow +++ b/src/types.js.flow @@ -94,6 +94,7 @@ export type UseFieldAutoConfig = { formatOnBlur?: boolean, initialValue?: any, isEqual?: (a: any, b: any) => boolean, + keepFieldStateOnUnmount?: boolean, multiple?: boolean, parse?: (value: any, name: string) => any, type?: string, diff --git a/src/useField.js b/src/useField.js index 48a52499..7034441c 100644 --- a/src/useField.js +++ b/src/useField.js @@ -39,6 +39,7 @@ function useField<FormValues: FormValuesShape>( format = defaultFormat, formatOnBlur, initialValue, + keepFieldStateOnUnmount = false, multiple, parse = defaultParse, subscription = all, @@ -50,13 +51,13 @@ function useField<FormValues: FormValuesShape>( const configRef = useLatest(config) - const register = (callback: FieldState => void) => + const register = () => // avoid using `state` const in any closures created inside `register` // because they would refer `state` from current execution context // whereas actual `state` would defined in the subsequent `useField` hook // execution // (that would be caused by `setState` call performed in `register` callback) - form.registerField(name, callback, subscription, { + form.registerField(name, undefined, undefined, { afterSubmit, beforeSubmit: () => { const { @@ -84,49 +85,61 @@ function useField<FormValues: FormValuesShape>( validateFields }) - const firstRender = React.useRef(true) + const firstRenderRef = React.useRef(true) + const unregisterRef = React.useRef() + + const registerAndGetInitialState: () => FieldState = () => { + let initState + if (!keepFieldStateOnUnmount) { + unregisterRef.current && unregisterRef.current() + unregisterRef.current = undefined + } else { + initState = form.getFieldState(name) + } + + // if no initial state, register! + if (!initState) { + const unregisterFn = register() + // only set unregister function when keepFieldStateOnUnmount option is false + if (keepFieldStateOnUnmount === false) { + unregisterRef.current = unregisterFn + } + initState = form.getFieldState(name) + } - // synchronously register and unregister to query field state for our subscription on first render - const [state, setState] = React.useState<FieldState>((): FieldState => { - let initialState: FieldState = {} + return initState || {} + } - // temporarily disable destroyOnUnregister - const destroyOnUnregister = form.destroyOnUnregister - form.destroyOnUnregister = false + // register on first render + // this will initially check for existing field state from the form before trying to register + const [state, setState] = React.useState<FieldState>( + registerAndGetInitialState + ) - register(state => { - initialState = state - })() + React.useEffect( + () => { + // make sure this doesn't get triggered on first render + if (firstRenderRef.current === false) { + unregisterRef.current && unregisterRef.current() + setState(registerAndGetInitialState()) + } - // return destroyOnUnregister to its original value - form.destroyOnUnregister = destroyOnUnregister + const unsubscribeFieldState = form.subscribeToFieldState( + name, + setState, + subscription + ) - return initialState - }) + firstRenderRef.current = false - React.useEffect( - () => - register(state => { - if (firstRender.current) { - firstRender.current = false - } else { - setState(state) - } - }), + return () => { + unsubscribeFieldState() + unregisterRef.current && unregisterRef.current() + unregisterRef.current = undefined + } + }, // eslint-disable-next-line react-hooks/exhaustive-deps - [ - name, - data, - defaultValue, - // If we want to allow inline fat-arrow field-level validation functions, we - // cannot reregister field every time validate function !==. - // validate, - initialValue - // The validateFields array is often passed as validateFields={[]}, creating - // a !== new array every time. If it needs to be changed, a rerender/reregister - // can be forced by changing the key prop - // validateFields - ] + [defaultValue, initialValue, name] ) const handlers = {