Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Register only once for useField hook #727

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions src/Field.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const Field = ({
formatOnBlur,
initialValue,
isEqual,
keepFieldStateOnUnmount,
multiple,
name,
parse,
Expand All @@ -38,6 +39,7 @@ const Field = ({
formatOnBlur,
initialValue,
isEqual,
keepFieldStateOnUnmount,
multiple,
parse,
subscription,
Expand Down
202 changes: 194 additions & 8 deletions src/ReactFinalForm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
Expand All @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions src/types.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 51 additions & 38 deletions src/useField.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function useField<FormValues: FormValuesShape>(
format = defaultFormat,
formatOnBlur,
initialValue,
keepFieldStateOnUnmount = false,
multiple,
parse = defaultParse,
subscription = all,
Expand 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 {
Expand Down Expand Up @@ -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 = {
Expand Down