Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/quiet-arrays-share.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 13 additions & 2 deletions packages/preact-form/src/AppForm/Components.lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -63,10 +73,11 @@ type AnyFieldComponent = FunctionComponent<
function createFieldWithContext(
form: AnyInternalFormApi,
fieldComponents: Record<string, FunctionComponent<any>>,
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
Expand Down
71 changes: 70 additions & 1 deletion packages/preact-form/tests/createFormHook.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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<Array<string>> }) {
return <span>Items: {field.value.length}</span>
}

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 (
<>
<form.ArrayField name="tags">
{(field) => {
renders++
return (
<>
<field.ItemCount />
<button type="button" onClick={() => field.pushValue('c')}>
Add
</button>
</>
)
}}
</form.ArrayField>
<form.FormGroup name="group">
{(group) => (
<group.ArrayField name="tags">
{(field) => {
renders++
return <field.ItemCount />
}}
</group.ArrayField>
)}
</form.FormGroup>
<button
type="button"
onClick={() => {
form.setFieldValue('tags[0]', 'z')
form.setFieldValue('group.tags[0]', 'z')
}}
>
Update first
</button>
</>
)
}

const view = render(<Component />)

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)
})
})
3 changes: 2 additions & 1 deletion packages/react-form/src/AppForm/Components.lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions packages/react-form/src/AppForm/ReactAppFormApi.lib.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'

Expand Down Expand Up @@ -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<any>
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions packages/react-form/tests/createFormHook.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<string>> }) {
return <span>Items: {field.value.length}</span>
}

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 (
<>
<form.ArrayField name="tags">
{(field) => {
renders++
return (
<>
<field.ItemCount />
<button type="button" onClick={() => field.pushValue('c')}>
Add
</button>
</>
)
}}
</form.ArrayField>
<form.FormGroup name="group">
{(group) => (
<group.ArrayField name="tags">
{(field) => {
renders++
return <field.ItemCount />
}}
</group.ArrayField>
)}
</form.FormGroup>
<button
type="button"
onClick={() => {
form.setFieldValue('tags[0]', 'z')
form.setFieldValue('group.tags[0]', 'z')
}}
>
Update first
</button>
</>
)
}

const { getByText } = await render(<Component />)

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<string> }) {
return <span>Field value: {field.value}</span>
Expand Down
Loading