From deafb2d857876f6e10057a3b837a042f7356883f Mon Sep 17 00:00:00 2001 From: Zlata <129689245+kkatalz@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:42:13 +0300 Subject: [PATCH] fix(react-form): [v2] provide field context inside app form `ArrayField` The app form replaces `Field` with a component that provides the field context, but keeps the plain `ArrayField`, so a registered field component rendered inside `form.ArrayField` or a form group's `ArrayField` throws "Field components must be used within a `form.Field` component", although the types offer it there. Let `createFieldWithContext` take the field subscription and use it with the array subscription for both, in React and Preact. Solid and Vue already provide the context for arrays. --- .changeset/quiet-arrays-share.md | 6 ++ .../src/AppForm/Components.lib.tsx | 15 +++- .../preact-form/tests/createFormHook.spec.tsx | 71 ++++++++++++++++++- .../react-form/src/AppForm/Components.lib.tsx | 3 +- .../src/AppForm/ReactAppFormApi.lib.ts | 16 +++-- .../src/AppForm/getFormHookHelpers.public.ts | 2 +- .../react-form/tests/createFormHook.spec.tsx | 68 ++++++++++++++++++ 7 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 .changeset/quiet-arrays-share.md diff --git a/.changeset/quiet-arrays-share.md b/.changeset/quiet-arrays-share.md new file mode 100644 index 000000000..497c7165c --- /dev/null +++ b/.changeset/quiet-arrays-share.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-form': patch +'@tanstack/preact-form': patch +--- + +Provide the field context inside App Form `ArrayField`, so registered field components render there instead of throwing. diff --git a/packages/preact-form/src/AppForm/Components.lib.tsx b/packages/preact-form/src/AppForm/Components.lib.tsx index 460a6eddf..95ac71a53 100644 --- a/packages/preact-form/src/AppForm/Components.lib.tsx +++ b/packages/preact-form/src/AppForm/Components.lib.tsx @@ -3,7 +3,10 @@ import Preact from 'preact/compat' import { InternalFormGroupApi } from '@tanstack/form-core/internals' import { attachPreactFormComponents } from '../PreactForm/Components.lib' import { useField } from '../PreactForm/useField.lib' -import { useValueFieldSubscription } from '../PreactForm/fieldSubscriptions.lib' +import { + useArrayFieldSubscription, + useValueFieldSubscription, +} from '../PreactForm/fieldSubscriptions.lib' import { Subscribe } from '../Subscribe.public' import { FieldContext, FormContext } from './contexts.lib' import type { AnyInternalFormApi } from '@tanstack/form-core/internals' @@ -36,6 +39,13 @@ export function attachPreactAppFormComponents( form, fieldComponents, ) as AnyPreactAppFormApi['Field'] + const arrayField = createFieldWithContext( + form, + fieldComponents, + useArrayFieldSubscription, + ) + arrayField.displayName = 'TanStackForm.ArrayField' + resultForm.ArrayField = arrayField as AnyPreactAppFormApi['ArrayField'] resultForm.FormGroup = createFormGroupWithContext( resultForm as any, ) as AnyPreactAppFormApi['FormGroup'] @@ -63,10 +73,11 @@ type AnyFieldComponent = FunctionComponent< function createFieldWithContext( form: AnyInternalFormApi, fieldComponents: Record>, + useFieldSubscription = useValueFieldSubscription, ) { const TanStackFormField: AnyFieldComponent = (props) => { const fieldApi = useField({ ...props, form }, fieldComponents) - const field = useValueFieldSubscription(fieldApi) + const field = useFieldSubscription(fieldApi) return ( // eslint-disable-next-line @eslint-react/no-context-provider diff --git a/packages/preact-form/tests/createFormHook.spec.tsx b/packages/preact-form/tests/createFormHook.spec.tsx index 4625d5c43..f4076c4d6 100644 --- a/packages/preact-form/tests/createFormHook.spec.tsx +++ b/packages/preact-form/tests/createFormHook.spec.tsx @@ -1,6 +1,7 @@ import { render } from 'vitest-browser-preact' import { describe, expect, it, vi } from 'vitest' -import { createFormHook } from '../src' +import { createFormHook, getFormHookHelpers } from '../src' +import type { FieldWithValue } from '../src' describe('createFormHook defaults', () => { it('applies form, field, and form group defaults through public components', async () => { @@ -150,4 +151,72 @@ describe('createFormHook defaults', () => { await view.getByRole('button', { name: 'Submit group' }).click() await vi.waitFor(() => expect(onSubmitInvalid).toHaveBeenCalledOnce()) }) + + it('provides field context to field components inside an ArrayField', async () => { + let renders = 0 + + function ItemCount({ field }: { field: FieldWithValue> }) { + return Items: {field.value.length} + } + + const { fieldComponent } = getFormHookHelpers() + const { useAppForm } = createFormHook({ + fieldComponents: { ItemCount: fieldComponent.strict(ItemCount, 'field') }, + formComponents: {}, + }) + + function Component() { + const form = useAppForm({ + defaultValues: { tags: ['a', 'b'], group: { tags: ['c'] } }, + }) + + return ( + <> + + {(field) => { + renders++ + return ( + <> + + + + ) + }} + + + {(group) => ( + + {(field) => { + renders++ + return + }} + + )} + + + + ) + } + + const view = render() + + expect(view.getByText('Items: 2')).toBeInTheDocument() + expect(view.getByText('Items: 1')).toBeInTheDocument() + await view.getByText('Add').click() + expect(view.getByText('Items: 3')).toBeInTheDocument() + + const pushRenders = renders + await view.getByText('Update first').click() + expect(renders).toBe(pushRenders) + }) }) diff --git a/packages/react-form/src/AppForm/Components.lib.tsx b/packages/react-form/src/AppForm/Components.lib.tsx index 2e7f2addd..dcfd0974f 100644 --- a/packages/react-form/src/AppForm/Components.lib.tsx +++ b/packages/react-form/src/AppForm/Components.lib.tsx @@ -34,10 +34,11 @@ type AnyFieldComponent = FunctionComponent< export function createFieldWithContext( form: InternalReactFormApi, scope: FieldOptionsScope, + useFieldSubscription = useValueFieldSubscription, ) { const TanStackFormField: AnyFieldComponent = (props) => { const fieldApi = useField({ ...props, form }, scope) - const field = useValueFieldSubscription(fieldApi) + const field = useFieldSubscription(fieldApi) return ( // eslint-disable-next-line @eslint-react/no-context-provider diff --git a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts index c5cb5679b..0f01c4d09 100644 --- a/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts +++ b/packages/react-form/src/AppForm/ReactAppFormApi.lib.ts @@ -1,4 +1,4 @@ -import { createArrayFieldComponent } from '../ReactForm/Components.lib' +import { useArrayFieldSubscription } from '../ReactForm/fieldSubscriptions.lib' import { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib' import { createInternalReactAppFieldApiClass } from './ReactAppFieldApi.lib' import { @@ -7,7 +7,6 @@ import { createFormGroupWithContext, } from './Components.lib' import type { DefaultOptions, FormOptions } from '@tanstack/form-core' -import type { FunctionComponent } from 'react' import type { AppFormComponent } from './ReactAppFormApi.public' import type { ReactComponentTree } from './componentMap.public' @@ -48,14 +47,23 @@ export function createInternalReactAppFormApiClass< super(options, defaultOptions) const field = createFieldWithContext(this, 'field') + const arrayField = createFieldWithContext( + this, + 'field', + useArrayFieldSubscription, + ) const groupField = createFieldWithContext(this, 'field') - const groupArrayField = createArrayFieldComponent( + const groupArrayField = createFieldWithContext( this, 'field', - ) as FunctionComponent + useArrayFieldSubscription, + ) + arrayField.displayName = 'TanStackForm.ArrayField' + groupArrayField.displayName = 'TanStackForm.ArrayField' this.AppForm = createAppForm(this) this.Field = field as InternalReactAppFormApi['Field'] + this.ArrayField = arrayField as InternalReactAppFormApi['ArrayField'] this.FormGroup = createFormGroupWithContext( this, groupField, diff --git a/packages/react-form/src/AppForm/getFormHookHelpers.public.ts b/packages/react-form/src/AppForm/getFormHookHelpers.public.ts index d54051e01..ff59d7f6d 100644 --- a/packages/react-form/src/AppForm/getFormHookHelpers.public.ts +++ b/packages/react-form/src/AppForm/getFormHookHelpers.public.ts @@ -72,7 +72,7 @@ export interface FieldComponentHelper { * * The returned component omits `fieldPropKey` from its public props and reads * that field API from the nearest App Form field context. It must be rendered - * beneath an App Form `Field` or field-group `Field` component. + * beneath an App Form or field-group `Field` or `ArrayField` component. * * @example * ```tsx diff --git a/packages/react-form/tests/createFormHook.spec.tsx b/packages/react-form/tests/createFormHook.spec.tsx index 3a4dabf83..649f7c7b8 100644 --- a/packages/react-form/tests/createFormHook.spec.tsx +++ b/packages/react-form/tests/createFormHook.spec.tsx @@ -43,6 +43,74 @@ describe('createFormHook', () => { expect(getByText('Shared field component')).toBeInTheDocument() }) + it('provides field context to field components inside an ArrayField', async () => { + let renders = 0 + + function ItemCount({ field }: { field: FieldWithValue> }) { + return Items: {field.value.length} + } + + const { fieldComponent } = getFormHookHelpers() + const { useAppForm } = createFormHook({ + fieldComponents: { ItemCount: fieldComponent.strict(ItemCount, 'field') }, + formComponents: {}, + }) + + function Component() { + const form = useAppForm({ + defaultValues: { tags: ['a', 'b'], group: { tags: ['c'] } }, + }) + + return ( + <> + + {(field) => { + renders++ + return ( + <> + + + + ) + }} + + + {(group) => ( + + {(field) => { + renders++ + return + }} + + )} + + + + ) + } + + const { getByText } = await render() + + expect(getByText('Items: 2')).toBeInTheDocument() + expect(getByText('Items: 1')).toBeInTheDocument() + await getByText('Add').click() + expect(getByText('Items: 3')).toBeInTheDocument() + + const pushRenders = renders + await getByText('Update first').click() + expect(renders).toBe(pushRenders) + }) + it('supports nested field and form component trees', async () => { function FieldValue({ field }: { field: FieldWithValue }) { return Field value: {field.value}