From 478c73894daf3b14c09bb9a44dd8d78702fe1a37 Mon Sep 17 00:00:00 2001 From: Zlata <129689245+kkatalz@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:23 +0300 Subject: [PATCH] fix(react-form): [v2] rerender `ArrayField` when the array's own errors change `useArrayFieldSubscription` selects only the array's length and `_arrayVersion`, so an error from the array's own validator (or one a form validator routes to it) never renders while the length stays the same. Also select the array's own `errors`, compared shallowly: hidden errors are a fresh empty array on every meta update, so child edits still don't rerender the array. Same in Preact. --- .changeset/loud-arrays-report.md | 6 ++ .../src/PreactForm/fieldSubscriptions.lib.ts | 8 +- packages/preact-form/tests/FormField.spec.tsx | 79 +++++++++++++++++++ .../src/ReactForm/fieldSubscriptions.lib.ts | 8 +- packages/react-form/tests/FormField.spec.tsx | 77 ++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 .changeset/loud-arrays-report.md diff --git a/.changeset/loud-arrays-report.md b/.changeset/loud-arrays-report.md new file mode 100644 index 0000000000..d915b90b26 --- /dev/null +++ b/.changeset/loud-arrays-report.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-form': patch +'@tanstack/preact-form': patch +--- + +Rerender `ArrayField` when the array's own errors change, so an array-level validator error shows without a change to the array's length. diff --git a/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts b/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts index 614d909d93..1916647412 100644 --- a/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts +++ b/packages/preact-form/src/PreactForm/fieldSubscriptions.lib.ts @@ -1,4 +1,4 @@ -import { useSelector } from '@tanstack/preact-store' +import { shallow, useSelector } from '@tanstack/preact-store' import { useMemo } from 'preact/compat' import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' @@ -23,10 +23,14 @@ export function useArrayFieldSubscription( fieldApi.atom, (state) => state.meta._arrayVersion, ) + const errors = useSelector(fieldApi.atom, (state) => state.meta.errors, { + compare: shallow, + }) return useMemo(() => { void length void arrayVersion + void errors return Object.create(fieldApi) - }, [fieldApi, arrayVersion, length]) + }, [fieldApi, arrayVersion, length, errors]) } diff --git a/packages/preact-form/tests/FormField.spec.tsx b/packages/preact-form/tests/FormField.spec.tsx index 528ac23254..0c79cb2f55 100644 --- a/packages/preact-form/tests/FormField.spec.tsx +++ b/packages/preact-form/tests/FormField.spec.tsx @@ -345,6 +345,85 @@ describe('Form fields', () => { expect(renderCounts.last).toBe(initialCounts.last) }) + it('rerenders an ArrayField when its own errors change', async () => { + let renders = 0 + + function Component() { + const form = useForm({ + defaultValues: { items: [{ label: 'A' }] }, + errorVisibility: ({ state }) => state.submissionAttempts > 0, + }) + + return ( + <> + + + value.length < 2 ? 'Add another item' : undefined, + }, + ]} + > + {(field) => { + renders++ + return ( + + {field.errors.map((error) => error.message).join(',')} + + ) + }} + + + value === 'AB' ? 'Too long' : undefined, + }, + ]} + > + {(field) => ( + <> + + field.handleChange(event.currentTarget.value) + } + /> + + {field.meta.original.errors + .map((error) => error.message) + .join(',')} + + + )} + + + ) + } + + const { getByLabelText, getByRole, getByTestId } = render() + const initialRenders = renders + + await userEvent.type(getByLabelText('label'), 'B') + + await expect + .element(getByTestId('label-error')) + .toHaveTextContent('Too long') + expect(renders).toBe(initialRenders) + + await getByRole('button', { name: 'Submit' }).click() + + await expect + .element(getByTestId('array')) + .toHaveTextContent('Add another item') + }) + it('should recreate mounted fields after form reset', async () => { function Component() { const form = useForm({ defaultValues: { name: 'tony-hawk' } }) diff --git a/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts b/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts index d67491fedf..c14b3a40ba 100644 --- a/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts +++ b/packages/react-form/src/ReactForm/fieldSubscriptions.lib.ts @@ -1,4 +1,4 @@ -import { useSelector } from '@tanstack/react-store' +import { shallow, useSelector } from '@tanstack/react-store' import { useMemo } from 'react' import type { AnyInternalFieldApi } from '@tanstack/form-core/internals' @@ -21,10 +21,14 @@ export function useArrayFieldSubscription( fieldApi.atom, (state) => state.meta._arrayVersion, ) + const errors = useSelector(fieldApi.atom, (state) => state.meta.errors, { + compare: shallow, + }) return useMemo(() => { void length void arrayVersion + void errors return Object.create(fieldApi) - }, [fieldApi, arrayVersion, length]) + }, [fieldApi, arrayVersion, length, errors]) } diff --git a/packages/react-form/tests/FormField.spec.tsx b/packages/react-form/tests/FormField.spec.tsx index 766f022de9..ee191450e8 100644 --- a/packages/react-form/tests/FormField.spec.tsx +++ b/packages/react-form/tests/FormField.spec.tsx @@ -141,6 +141,83 @@ describe('Form fields', () => { expect(renders).toBe(replacementRenders) }) + it('rerenders an ArrayField when its own errors change', async () => { + let renders = 0 + + function Component() { + const form = useForm({ + defaultValues: { items: [{ label: 'A' }] }, + errorVisibility: ({ state }) => state.submissionAttempts > 0, + }) + + return ( + <> + + + value.length < 2 ? 'Add another item' : undefined, + }, + ]} + > + {(field) => { + renders++ + return ( + + {field.errors.map((error) => error.message).join(',')} + + ) + }} + + + value === 'AB' ? 'Too long' : undefined, + }, + ]} + > + {(field) => ( + <> + field.handleChange(event.target.value)} + /> + + {field.meta.original.errors + .map((error) => error.message) + .join(',')} + + + )} + + + ) + } + + const screen = await render() + const initialRenders = renders + + await userEvent.type(screen.getByLabelText('label'), 'B') + + await vi.waitFor(() => + expect(screen.getByTestId('label-error')).toHaveTextContent('Too long'), + ) + expect(renders).toBe(initialRenders) + + await screen.getByRole('button', { name: 'Submit' }).click() + + await vi.waitFor(() => + expect(screen.getByTestId('array')).toHaveTextContent('Add another item'), + ) + }) + it('should have the correct meta when changing the field', async () => { function Component() { const form = useForm({ defaultValues: { name: 'tony-hawk' } })