Skip to content

Commit 5b057cb

Browse files
committed
fix(form-core): sync array fields after async defaultValues
Bump _arrayVersion for array paths in defaultValues even when the array field is not mounted yet, and when an array field mounts with existing values. Fixes conditional render + async initial arrays. Fixes #2201
1 parent 6a73479 commit 5b057cb

8 files changed

Lines changed: 159 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@tanstack/form-core': patch
3+
'@tanstack/react-form': patch
4+
'@tanstack/preact-form': patch
5+
'@tanstack/vue-form': patch
6+
'@tanstack/solid-form': patch
7+
'@tanstack/svelte-form': patch
8+
'@tanstack/angular-form': patch
9+
'@tanstack/lit-form': patch
10+
---
11+
12+
Bump array field versions when async `defaultValues` arrive before array fields mount, and when array fields mount with existing values. Fixes #2201.

packages/form-core/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @tanstack/form-core
22

3+
## 1.33.1
4+
5+
### Patch Changes
6+
7+
- 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)).
8+
39
## 1.33.0
410

511
### Minor Changes

packages/form-core/src/FieldApi.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
isStandardSchemaValidator,
44
standardSchemaValidators,
55
} from './standardSchemaValidator'
6-
import { defaultFieldMeta } from './metaHelper'
6+
import { defaultFieldMeta, metaHelper } from './metaHelper'
77
import {
88
determineFieldLevelErrorSourceAndValue,
99
evaluate,
@@ -902,6 +902,15 @@ export class FieldApi<
902902
fieldApi: this,
903903
})
904904

905+
const mountedValue = this.form.getFieldValue(this.name)
906+
if (
907+
Array.isArray(mountedValue) &&
908+
mountedValue.length > 0 &&
909+
(this.getMeta()._arrayVersion ?? 0) === 0
910+
) {
911+
metaHelper(this.form).bumpArrayVersion(this.name)
912+
}
913+
905914
return () => {
906915
// Stop any in-flight async validation or listener work tied to this instance.
907916
for (const [key, timeout] of Object.entries(

packages/form-core/src/FormApi.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { batch, createStore } from '@tanstack/store'
22
import {
3+
collectArrayFieldPaths,
34
deleteBy,
45
determineFormLevelErrorSourceAndValue,
56
evaluate,
@@ -1783,13 +1784,23 @@ export class FormApi<
17831784

17841785
if (shouldUpdateValues) {
17851786
const helper = metaHelper(this)
1787+
const arrayFieldKeys = new Set<DeepKeys<TFormData>>()
1788+
17861789
for (const fieldKey of Object.keys(
17871790
this.fieldInfo,
17881791
) as DeepKeys<TFormData>[]) {
17891792
if (Array.isArray(this.getFieldValue(fieldKey))) {
1790-
helper.bumpArrayVersion(fieldKey)
1793+
arrayFieldKeys.add(fieldKey)
17911794
}
17921795
}
1796+
1797+
for (const fieldPath of collectArrayFieldPaths(options.defaultValues)) {
1798+
arrayFieldKeys.add(fieldPath as DeepKeys<TFormData>)
1799+
}
1800+
1801+
for (const fieldKey of arrayFieldKeys) {
1802+
helper.bumpArrayVersion(fieldKey)
1803+
}
17931804
}
17941805

17951806
formEventClient.emit('form-api', {

packages/form-core/src/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,39 @@ export function concatenatePaths(path1: string, path2: string): string {
263263
return `${path1}.${path2}`
264264
}
265265

266+
/**
267+
* Collects dot-notation paths to every array value in a form values object.
268+
* Used when async `defaultValues` update before array fields have mounted.
269+
*
270+
* @private
271+
*/
272+
export function collectArrayFieldPaths(
273+
value: unknown,
274+
prefix?: string,
275+
): string[] {
276+
if (value === null || value === undefined || typeof value !== 'object') {
277+
return []
278+
}
279+
280+
if (Array.isArray(value)) {
281+
return prefix !== undefined && prefix !== '' ? [prefix] : []
282+
}
283+
284+
const paths: string[] = []
285+
for (const key of Object.keys(value)) {
286+
const nextPath = prefix ? `${prefix}.${key}` : key
287+
const child = (value as Record<string, unknown>)[key]
288+
289+
if (Array.isArray(child)) {
290+
paths.push(nextPath)
291+
} else if (child !== null && typeof child === 'object') {
292+
paths.push(...collectArrayFieldPaths(child, nextPath))
293+
}
294+
}
295+
296+
return paths
297+
}
298+
266299
/**
267300
* @private
268301
*/

packages/form-core/tests/FormApi.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,6 +1232,28 @@ describe('form api', () => {
12321232
expect(form.getFieldValue('name')).toEqual('two')
12331233
})
12341234

1235+
it('should bump array version when defaultValues update before the array field mounts', () => {
1236+
type Person = { name: string }
1237+
type FormData = { people: Person[] }
1238+
const form = new FormApi({
1239+
defaultValues: {
1240+
people: [],
1241+
} as FormData,
1242+
})
1243+
form.mount()
1244+
1245+
const versionBefore = form.getFieldMeta('people')?._arrayVersion ?? 0
1246+
1247+
form.update({
1248+
defaultValues: {
1249+
people: [{ name: 'Alice' }, { name: 'Bob' }],
1250+
},
1251+
})
1252+
1253+
expect(form.getFieldValue('people')).toHaveLength(2)
1254+
expect(form.getFieldMeta('people')?._arrayVersion).toBe(versionBefore + 1)
1255+
})
1256+
12351257
it('should delete field from the form', () => {
12361258
const form = new FormApi({
12371259
defaultValues: {

packages/form-core/tests/utils.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, expectTypeOf, it } from 'vitest'
22
import {
3+
collectArrayFieldPaths,
34
concatenatePaths,
45
createFieldMap,
56
deleteBy,
@@ -844,6 +845,18 @@ describe('evaluate', () => {
844845
})
845846
})
846847

848+
describe('collectArrayFieldPaths', () => {
849+
it('should collect top-level and nested array paths', () => {
850+
expect(
851+
collectArrayFieldPaths({
852+
people: [],
853+
profile: { tags: [] },
854+
name: 'test',
855+
}),
856+
).toEqual(['people', 'profile.tags'])
857+
})
858+
})
859+
847860
describe('concatenatePaths', () => {
848861
it('should concatenate two object accessors with dot', () => {
849862
expect(concatenatePaths('user', 'name')).toBe('user.name')

packages/react-form/tests/useField.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { describe, expect, it, vi } from 'vitest'
33
import { render, waitFor, within } from '@testing-library/react'
44
import { userEvent } from '@testing-library/user-event'
5-
import { StrictMode, useState } from 'react'
5+
import { StrictMode, useEffect, useState } from 'react'
66
import { useStore } from '@tanstack/react-store'
77
import { useForm } from '../src/index'
88
import { sleep } from './utils'
@@ -1564,6 +1564,56 @@ describe('useField', () => {
15641564
expect(getByTestId('item-0')).toHaveTextContent('Alice')
15651565
})
15661566

1567+
it('should rerender array field when mounted after async defaultValues resolve', async () => {
1568+
// Regression test for https://github.com/TanStack/form/issues/2201
1569+
type Person = { name: string }
1570+
type FormData = { people: Person[] }
1571+
1572+
function Comp() {
1573+
const [data, setData] = useState<FormData | null>(null)
1574+
1575+
const form = useForm({
1576+
defaultValues: data ?? { people: [] },
1577+
})
1578+
1579+
useEffect(() => {
1580+
void Promise.resolve().then(() => {
1581+
setData({ people: [{ name: 'Alice' }, { name: 'Bob' }] })
1582+
})
1583+
}, [])
1584+
1585+
if (!data) {
1586+
return <div data-testid="loading">loading</div>
1587+
}
1588+
1589+
return (
1590+
<form.Field name="people" mode="array">
1591+
{(field) => {
1592+
const val = field.state.value ?? []
1593+
return (
1594+
<ol data-testid="list">
1595+
{val.map((person, i) => (
1596+
<li key={i} data-testid={`item-${i}`}>
1597+
{person.name}
1598+
</li>
1599+
))}
1600+
</ol>
1601+
)
1602+
}}
1603+
</form.Field>
1604+
)
1605+
}
1606+
1607+
const { getByTestId } = render(<Comp />)
1608+
expect(getByTestId('loading')).toBeInTheDocument()
1609+
1610+
await waitFor(() =>
1611+
expect(getByTestId('list').children).toHaveLength(2),
1612+
)
1613+
expect(getByTestId('item-0')).toHaveTextContent('Alice')
1614+
expect(getByTestId('item-1')).toHaveTextContent('Bob')
1615+
})
1616+
15671617
it('should handle defaultValue without setstate-in-render error', async () => {
15681618
// Spy on console.error before rendering
15691619
const consoleErrorSpy = vi.spyOn(console, 'error')

0 commit comments

Comments
 (0)