Skip to content

Commit fceed8a

Browse files
fix(react-form): [v2] provide field context inside app form ArrayField (#2434)
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. Co-authored-by: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com>
1 parent e6fd415 commit fceed8a

7 files changed

Lines changed: 172 additions & 9 deletions

File tree

‎.changeset/quiet-arrays-share.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/react-form': patch
3+
'@tanstack/preact-form': patch
4+
---
5+
6+
Provide the field context inside App Form `ArrayField`, so registered field components render there instead of throwing.

‎packages/preact-form/src/AppForm/Components.lib.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import Preact from 'preact/compat'
33
import { InternalFormGroupApi } from '@tanstack/form-core/internals'
44
import { attachPreactFormComponents } from '../PreactForm/Components.lib'
55
import { useField } from '../PreactForm/useField.lib'
6-
import { useValueFieldSubscription } from '../PreactForm/fieldSubscriptions.lib'
6+
import {
7+
useArrayFieldSubscription,
8+
useValueFieldSubscription,
9+
} from '../PreactForm/fieldSubscriptions.lib'
710
import { Subscribe } from '../Subscribe.public'
811
import { FieldContext, FormContext } from './contexts.lib'
912
import type { AnyInternalFormApi } from '@tanstack/form-core/internals'
@@ -36,6 +39,13 @@ export function attachPreactAppFormComponents(
3639
form,
3740
fieldComponents,
3841
) as AnyPreactAppFormApi['Field']
42+
const arrayField = createFieldWithContext(
43+
form,
44+
fieldComponents,
45+
useArrayFieldSubscription,
46+
)
47+
arrayField.displayName = 'TanStackForm.ArrayField'
48+
resultForm.ArrayField = arrayField as AnyPreactAppFormApi['ArrayField']
3949
resultForm.FormGroup = createFormGroupWithContext(
4050
resultForm as any,
4151
) as AnyPreactAppFormApi['FormGroup']
@@ -63,10 +73,11 @@ type AnyFieldComponent = FunctionComponent<
6373
function createFieldWithContext(
6474
form: AnyInternalFormApi,
6575
fieldComponents: Record<string, FunctionComponent<any>>,
76+
useFieldSubscription = useValueFieldSubscription,
6677
) {
6778
const TanStackFormField: AnyFieldComponent = (props) => {
6879
const fieldApi = useField({ ...props, form }, fieldComponents)
69-
const field = useValueFieldSubscription(fieldApi)
80+
const field = useFieldSubscription(fieldApi)
7081

7182
return (
7283
// eslint-disable-next-line @eslint-react/no-context-provider

‎packages/preact-form/tests/createFormHook.spec.tsx‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render } from 'vitest-browser-preact'
22
import { describe, expect, it, vi } from 'vitest'
3-
import { createFormHook } from '../src'
3+
import { createFormHook, getFormHookHelpers } from '../src'
4+
import type { FieldWithValue } from '../src'
45

56
describe('createFormHook defaults', () => {
67
it('applies form, field, and form group defaults through public components', async () => {
@@ -150,4 +151,72 @@ describe('createFormHook defaults', () => {
150151
await view.getByRole('button', { name: 'Submit group' }).click()
151152
await vi.waitFor(() => expect(onSubmitInvalid).toHaveBeenCalledOnce())
152153
})
154+
155+
it('provides field context to field components inside an ArrayField', async () => {
156+
let renders = 0
157+
158+
function ItemCount({ field }: { field: FieldWithValue<Array<string>> }) {
159+
return <span>Items: {field.value.length}</span>
160+
}
161+
162+
const { fieldComponent } = getFormHookHelpers()
163+
const { useAppForm } = createFormHook({
164+
fieldComponents: { ItemCount: fieldComponent.strict(ItemCount, 'field') },
165+
formComponents: {},
166+
})
167+
168+
function Component() {
169+
const form = useAppForm({
170+
defaultValues: { tags: ['a', 'b'], group: { tags: ['c'] } },
171+
})
172+
173+
return (
174+
<>
175+
<form.ArrayField name="tags">
176+
{(field) => {
177+
renders++
178+
return (
179+
<>
180+
<field.ItemCount />
181+
<button type="button" onClick={() => field.pushValue('c')}>
182+
Add
183+
</button>
184+
</>
185+
)
186+
}}
187+
</form.ArrayField>
188+
<form.FormGroup name="group">
189+
{(group) => (
190+
<group.ArrayField name="tags">
191+
{(field) => {
192+
renders++
193+
return <field.ItemCount />
194+
}}
195+
</group.ArrayField>
196+
)}
197+
</form.FormGroup>
198+
<button
199+
type="button"
200+
onClick={() => {
201+
form.setFieldValue('tags[0]', 'z')
202+
form.setFieldValue('group.tags[0]', 'z')
203+
}}
204+
>
205+
Update first
206+
</button>
207+
</>
208+
)
209+
}
210+
211+
const view = render(<Component />)
212+
213+
expect(view.getByText('Items: 2')).toBeInTheDocument()
214+
expect(view.getByText('Items: 1')).toBeInTheDocument()
215+
await view.getByText('Add').click()
216+
expect(view.getByText('Items: 3')).toBeInTheDocument()
217+
218+
const pushRenders = renders
219+
await view.getByText('Update first').click()
220+
expect(renders).toBe(pushRenders)
221+
})
153222
})

‎packages/react-form/src/AppForm/Components.lib.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ type AnyFieldComponent = FunctionComponent<
3434
export function createFieldWithContext(
3535
form: InternalReactFormApi,
3636
scope: FieldOptionsScope,
37+
useFieldSubscription = useValueFieldSubscription,
3738
) {
3839
const TanStackFormField: AnyFieldComponent = (props) => {
3940
const fieldApi = useField({ ...props, form }, scope)
40-
const field = useValueFieldSubscription(fieldApi)
41+
const field = useFieldSubscription(fieldApi)
4142

4243
return (
4344
// eslint-disable-next-line @eslint-react/no-context-provider

‎packages/react-form/src/AppForm/ReactAppFormApi.lib.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createArrayFieldComponent } from '../ReactForm/Components.lib'
1+
import { useArrayFieldSubscription } from '../ReactForm/fieldSubscriptions.lib'
22
import { InternalReactFormApi } from '../ReactForm/ReactFormApi.lib'
33
import { createInternalReactAppFieldApiClass } from './ReactAppFieldApi.lib'
44
import {
@@ -7,7 +7,6 @@ import {
77
createFormGroupWithContext,
88
} from './Components.lib'
99
import type { DefaultOptions, FormOptions } from '@tanstack/form-core'
10-
import type { FunctionComponent } from 'react'
1110
import type { AppFormComponent } from './ReactAppFormApi.public'
1211
import type { ReactComponentTree } from './componentMap.public'
1312

@@ -48,14 +47,23 @@ export function createInternalReactAppFormApiClass<
4847
super(options, defaultOptions)
4948

5049
const field = createFieldWithContext(this, 'field')
50+
const arrayField = createFieldWithContext(
51+
this,
52+
'field',
53+
useArrayFieldSubscription,
54+
)
5155
const groupField = createFieldWithContext(this, 'field')
52-
const groupArrayField = createArrayFieldComponent(
56+
const groupArrayField = createFieldWithContext(
5357
this,
5458
'field',
55-
) as FunctionComponent<any>
59+
useArrayFieldSubscription,
60+
)
61+
arrayField.displayName = 'TanStackForm.ArrayField'
62+
groupArrayField.displayName = 'TanStackForm.ArrayField'
5663

5764
this.AppForm = createAppForm(this)
5865
this.Field = field as InternalReactAppFormApi['Field']
66+
this.ArrayField = arrayField as InternalReactAppFormApi['ArrayField']
5967
this.FormGroup = createFormGroupWithContext(
6068
this,
6169
groupField,

‎packages/react-form/src/AppForm/getFormHookHelpers.public.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export interface FieldComponentHelper {
7272
*
7373
* The returned component omits `fieldPropKey` from its public props and reads
7474
* that field API from the nearest App Form field context. It must be rendered
75-
* beneath an App Form `Field` or field-group `Field` component.
75+
* beneath an App Form or field-group `Field` or `ArrayField` component.
7676
*
7777
* @example
7878
* ```tsx

‎packages/react-form/tests/createFormHook.spec.tsx‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,74 @@ describe('createFormHook', () => {
4343
expect(getByText('Shared field component')).toBeInTheDocument()
4444
})
4545

46+
it('provides field context to field components inside an ArrayField', async () => {
47+
let renders = 0
48+
49+
function ItemCount({ field }: { field: FieldWithValue<Array<string>> }) {
50+
return <span>Items: {field.value.length}</span>
51+
}
52+
53+
const { fieldComponent } = getFormHookHelpers()
54+
const { useAppForm } = createFormHook({
55+
fieldComponents: { ItemCount: fieldComponent.strict(ItemCount, 'field') },
56+
formComponents: {},
57+
})
58+
59+
function Component() {
60+
const form = useAppForm({
61+
defaultValues: { tags: ['a', 'b'], group: { tags: ['c'] } },
62+
})
63+
64+
return (
65+
<>
66+
<form.ArrayField name="tags">
67+
{(field) => {
68+
renders++
69+
return (
70+
<>
71+
<field.ItemCount />
72+
<button type="button" onClick={() => field.pushValue('c')}>
73+
Add
74+
</button>
75+
</>
76+
)
77+
}}
78+
</form.ArrayField>
79+
<form.FormGroup name="group">
80+
{(group) => (
81+
<group.ArrayField name="tags">
82+
{(field) => {
83+
renders++
84+
return <field.ItemCount />
85+
}}
86+
</group.ArrayField>
87+
)}
88+
</form.FormGroup>
89+
<button
90+
type="button"
91+
onClick={() => {
92+
form.setFieldValue('tags[0]', 'z')
93+
form.setFieldValue('group.tags[0]', 'z')
94+
}}
95+
>
96+
Update first
97+
</button>
98+
</>
99+
)
100+
}
101+
102+
const { getByText } = await render(<Component />)
103+
104+
expect(getByText('Items: 2')).toBeInTheDocument()
105+
expect(getByText('Items: 1')).toBeInTheDocument()
106+
await getByText('Add').click()
107+
expect(getByText('Items: 3')).toBeInTheDocument()
108+
109+
const pushRenders = renders
110+
await getByText('Update first').click()
111+
expect(renders).toBe(pushRenders)
112+
})
113+
46114
it('supports nested field and form component trees', async () => {
47115
function FieldValue({ field }: { field: FieldWithValue<string> }) {
48116
return <span>Field value: {field.value}</span>

0 commit comments

Comments
 (0)