diff --git a/.changeset/hot-sides-fetch.md b/.changeset/hot-sides-fetch.md new file mode 100644 index 000000000..1700ea248 --- /dev/null +++ b/.changeset/hot-sides-fetch.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-form': patch +--- + +Use fresh FieldApi in current render on name change diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index b0d3eff9f..b40b1b42b 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -169,22 +169,28 @@ export function useField< name: opts.name, })) - const [fieldApi, setFieldApi] = useState(() => { + const [fieldApiState, setFieldApi] = useState(() => { return new FieldApi({ ...opts, }) }) + let fieldApi = fieldApiState + // We only want to // update on name changes since those are at risk of becoming stale. The field // state must be up to date for the internal JSX render. // The other options can freely be in `fieldApi.update` if (prevOptions.form !== opts.form || prevOptions.name !== opts.name) { - setFieldApi( - new FieldApi({ - ...opts, - }), - ) + // Adjusting state during render: create the new FieldApi and use it for the + // rest of this render so the render prop reads state at the current `name`. + // Otherwise the discarded render still runs to completion with the stale + // instance, briefly surfacing `undefined` for shifted array items on removal. + // See: https://github.com/TanStack/form/issues/2238 + fieldApi = new FieldApi({ + ...opts, + }) + setFieldApi(fieldApi) setPrevOptions({ form: opts.form, name: opts.name }) } diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index d45412fe9..445416594 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -962,6 +962,86 @@ describe('useField', () => { expect(fn).toHaveBeenCalledWith({ people: [{ name: 'John', age: 0 }] }) }) + it('should not surface undefined for shifted items when a non-last stably-keyed array item is removed', async () => { + // Regression test for https://github.com/TanStack/form/issues/2238 + // When array items are keyed by a stable id (not index), removing a + // non-last item changes the `name` prop of the surviving items. The field + // must read state at its current `name` on that render, never `undefined`. + const observedValues: Array = [] + + function Comp() { + const form = useForm({ + defaultValues: { + sections: [] as Array<{ id: string; title: string }>, + }, + }) + + let nextId = 0 + + return ( + + {(field) => { + return ( +
+ {field.state.value.map((section, i) => { + return ( + + {(subField) => { + observedValues.push(subField.state.value) + return ( + + ) + }} + + ) + })} + + +
+ ) + }} +
+ ) + } + + const { getByText, findByLabelText } = render() + + await user.click(getByText('Add section')) + await user.click(getByText('Add section')) + + await findByLabelText('Title for section id-1') + await findByLabelText('Title for section id-2') + + observedValues.length = 0 + await user.click(getByText('Remove first section')) + + // The surviving item (previously `sections[1]`) shifts to `sections[0]`. + // Its value must remain "Section 2" and never render as `undefined`. + const survivingInput = await findByLabelText('Title for section id-2') + expect((survivingInput as HTMLInputElement).value).toBe('Section 2') + expect(observedValues).not.toContain(undefined) + }) + it('should handle sync linked fields', async () => { const fn = vi.fn() function Comp() {