Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/async-array-conditional-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/form-core': patch
'@tanstack/react-form': patch
'@tanstack/preact-form': patch
'@tanstack/vue-form': patch
'@tanstack/solid-form': patch
'@tanstack/svelte-form': patch
'@tanstack/angular-form': patch
'@tanstack/lit-form': patch
---

Bump array field versions when async `defaultValues` arrive before array fields mount, and when array fields mount with existing values. Fixes #2201.
6 changes: 6 additions & 0 deletions packages/form-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @tanstack/form-core

## 1.33.1

### Patch Changes

- Bump `_arrayVersion` when async `defaultValues` update array fields that are not mounted yet, and when array fields mount with existing values (fixes [#2201](https://github.com/TanStack/form/issues/2201)).

## 1.33.0

### Minor Changes
Expand Down
14 changes: 13 additions & 1 deletion packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
isStandardSchemaValidator,
standardSchemaValidators,
} from './standardSchemaValidator'
import { defaultFieldMeta } from './metaHelper'
import { defaultFieldMeta, metaHelper } from './metaHelper'
import {
determineFieldLevelErrorSourceAndValue,
evaluate,
Expand Down Expand Up @@ -902,6 +902,18 @@ export class FieldApi<
fieldApi: this,
})

// Array fields that mount after async defaultValues may already have data
// but still have _arrayVersion 0. Bump so adapters subscribe to the
// populated array (rendering, validation, hooks).
const mountedValue = this.form.getFieldValue(this.name)
if (
Array.isArray(mountedValue) &&
mountedValue.length > 0 &&
(this.getMeta()._arrayVersion ?? 0) === 0
) {
metaHelper(this.form).bumpArrayVersion(this.name)
}

return () => {
// Stop any in-flight async validation or listener work tied to this instance.
for (const [key, timeout] of Object.entries(
Expand Down
13 changes: 12 additions & 1 deletion packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { batch, createStore } from '@tanstack/store'
import {
collectArrayFieldPaths,
deleteBy,
determineFormLevelErrorSourceAndValue,
evaluate,
Expand Down Expand Up @@ -1783,13 +1784,23 @@ export class FormApi<

if (shouldUpdateValues) {
const helper = metaHelper(this)
const arrayFieldKeys = new Set<DeepKeys<TFormData>>()

for (const fieldKey of Object.keys(
this.fieldInfo,
) as DeepKeys<TFormData>[]) {
if (Array.isArray(this.getFieldValue(fieldKey))) {
helper.bumpArrayVersion(fieldKey)
arrayFieldKeys.add(fieldKey)
}
}

for (const fieldPath of collectArrayFieldPaths(options.defaultValues)) {
arrayFieldKeys.add(fieldPath as DeepKeys<TFormData>)
}

for (const fieldKey of arrayFieldKeys) {
helper.bumpArrayVersion(fieldKey)
}
}

formEventClient.emit('form-api', {
Expand Down
38 changes: 38 additions & 0 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,44 @@ export function concatenatePaths(path1: string, path2: string): string {
return `${path1}.${path2}`
}

/**
* Collects dot-notation paths to every array value in a form values object.
* Used when async `defaultValues` update before array fields have mounted.
*
* @private
*/
export function collectArrayFieldPaths(
value: unknown,
prefix?: string,
): string[] {
if (value === null || value === undefined || typeof value !== 'object') {
return []
}

if (Array.isArray(value)) {
return prefix !== undefined && prefix !== '' ? [prefix] : []
}

const paths: string[] = []
for (const key of Object.keys(value)) {
const nextPath = prefix ? `${prefix}.${key}` : key
const child = (value as Record<string, unknown>)[key]

if (Array.isArray(child)) {
paths.push(nextPath)
child.forEach((item, index) => {
paths.push(
...collectArrayFieldPaths(item, `${nextPath}[${index}]`),
)
})
} else if (child !== null && typeof child === 'object') {
paths.push(...collectArrayFieldPaths(child, nextPath))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return paths
}

/**
* @private
*/
Expand Down
22 changes: 22 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,28 @@ describe('form api', () => {
expect(form.getFieldValue('name')).toEqual('two')
})

it('should bump array version when defaultValues update before the array field mounts', () => {
type Person = { name: string }
type FormData = { people: Person[] }
const form = new FormApi({
defaultValues: {
people: [],
} as FormData,
})
form.mount()

const versionBefore = form.getFieldMeta('people')?._arrayVersion ?? 0

form.update({
defaultValues: {
people: [{ name: 'Alice' }, { name: 'Bob' }],
},
})

expect(form.getFieldValue('people')).toHaveLength(2)
expect(form.getFieldMeta('people')?._arrayVersion).toBe(versionBefore + 1)
})

it('should delete field from the form', () => {
const form = new FormApi({
defaultValues: {
Expand Down
21 changes: 21 additions & 0 deletions packages/form-core/tests/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import {
collectArrayFieldPaths,
concatenatePaths,
createFieldMap,
deleteBy,
Expand Down Expand Up @@ -844,6 +845,26 @@ describe('evaluate', () => {
})
})

describe('collectArrayFieldPaths', () => {
it('should collect top-level and nested array paths', () => {
expect(
collectArrayFieldPaths({
people: [],
profile: { tags: [] },
name: 'test',
}),
).toEqual(['people', 'profile.tags'])
})

it('should collect nested arrays inside array items', () => {
expect(
collectArrayFieldPaths({
people: [{ tags: ['a'] }, { tags: [] }],
}),
).toEqual(['people', 'people[0].tags', 'people[1].tags'])
})
})

describe('concatenatePaths', () => {
it('should concatenate two object accessors with dot', () => {
expect(concatenatePaths('user', 'name')).toBe('user.name')
Expand Down
52 changes: 51 additions & 1 deletion packages/react-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { render, waitFor, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { StrictMode, useState } from 'react'
import { StrictMode, useEffect, useState } from 'react'
import { useStore } from '@tanstack/react-store'
import { useForm } from '../src/index'
import { sleep } from './utils'
Expand Down Expand Up @@ -1564,6 +1564,56 @@ describe('useField', () => {
expect(getByTestId('item-0')).toHaveTextContent('Alice')
})

it('should rerender array field when mounted after async defaultValues resolve', async () => {
// Regression test for https://github.com/TanStack/form/issues/2201
type Person = { name: string }
type FormData = { people: Person[] }

function Comp() {
const [data, setData] = useState<FormData | null>(null)

const form = useForm({
defaultValues: data ?? { people: [] },
})

useEffect(() => {
void Promise.resolve().then(() => {
setData({ people: [{ name: 'Alice' }, { name: 'Bob' }] })
})
}, [])

if (!data) {
return <div data-testid="loading">loading</div>
}

return (
<form.Field name="people" mode="array">
{(field) => {
const val = field.state.value ?? []
return (
<ol data-testid="list">
{val.map((person, i) => (
<li key={i} data-testid={`item-${i}`}>
{person.name}
</li>
))}
</ol>
)
}}
</form.Field>
)
}

const { getByTestId } = render(<Comp />)
expect(getByTestId('loading')).toBeInTheDocument()

await waitFor(() =>
expect(getByTestId('list').children).toHaveLength(2),
)
expect(getByTestId('item-0')).toHaveTextContent('Alice')
expect(getByTestId('item-1')).toHaveTextContent('Bob')
})

it('should handle defaultValue without setstate-in-render error', async () => {
// Spy on console.error before rendering
const consoleErrorSpy = vi.spyOn(console, 'error')
Expand Down