From 5b057cb33cbfaf2c3c485c5be8b775da3a6cb481 Mon Sep 17 00:00:00 2001 From: hemraj-007 <140829250+hemraj-007@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:59:33 +0530 Subject: [PATCH 1/2] 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 --- .changeset/async-array-conditional-field.md | 12 +++++ packages/form-core/CHANGELOG.md | 6 +++ packages/form-core/src/FieldApi.ts | 11 ++++- packages/form-core/src/FormApi.ts | 13 +++++- packages/form-core/src/utils.ts | 33 +++++++++++++ packages/form-core/tests/FormApi.spec.ts | 22 +++++++++ packages/form-core/tests/utils.spec.ts | 13 ++++++ packages/react-form/tests/useField.test.tsx | 52 ++++++++++++++++++++- 8 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 .changeset/async-array-conditional-field.md diff --git a/.changeset/async-array-conditional-field.md b/.changeset/async-array-conditional-field.md new file mode 100644 index 000000000..8663baf90 --- /dev/null +++ b/.changeset/async-array-conditional-field.md @@ -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. diff --git a/packages/form-core/CHANGELOG.md b/packages/form-core/CHANGELOG.md index 61e7df752..4839584f1 100644 --- a/packages/form-core/CHANGELOG.md +++ b/packages/form-core/CHANGELOG.md @@ -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 diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index b1125ea09..b762a8324 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -3,7 +3,7 @@ import { isStandardSchemaValidator, standardSchemaValidators, } from './standardSchemaValidator' -import { defaultFieldMeta } from './metaHelper' +import { defaultFieldMeta, metaHelper } from './metaHelper' import { determineFieldLevelErrorSourceAndValue, evaluate, @@ -902,6 +902,15 @@ export class FieldApi< fieldApi: this, }) + 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( diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 5c9de3c1c..cf0018808 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1,5 +1,6 @@ import { batch, createStore } from '@tanstack/store' import { + collectArrayFieldPaths, deleteBy, determineFormLevelErrorSourceAndValue, evaluate, @@ -1783,13 +1784,23 @@ export class FormApi< if (shouldUpdateValues) { const helper = metaHelper(this) + const arrayFieldKeys = new Set>() + for (const fieldKey of Object.keys( this.fieldInfo, ) as DeepKeys[]) { if (Array.isArray(this.getFieldValue(fieldKey))) { - helper.bumpArrayVersion(fieldKey) + arrayFieldKeys.add(fieldKey) } } + + for (const fieldPath of collectArrayFieldPaths(options.defaultValues)) { + arrayFieldKeys.add(fieldPath as DeepKeys) + } + + for (const fieldKey of arrayFieldKeys) { + helper.bumpArrayVersion(fieldKey) + } } formEventClient.emit('form-api', { diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index e87296e41..727388e47 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -263,6 +263,39 @@ 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)[key] + + if (Array.isArray(child)) { + paths.push(nextPath) + } else if (child !== null && typeof child === 'object') { + paths.push(...collectArrayFieldPaths(child, nextPath)) + } + } + + return paths +} + /** * @private */ diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 092d0ed6c..36fd983fb 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -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: { diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index 29d9a53a0..2db6865d7 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { + collectArrayFieldPaths, concatenatePaths, createFieldMap, deleteBy, @@ -844,6 +845,18 @@ describe('evaluate', () => { }) }) +describe('collectArrayFieldPaths', () => { + it('should collect top-level and nested array paths', () => { + expect( + collectArrayFieldPaths({ + people: [], + profile: { tags: [] }, + name: 'test', + }), + ).toEqual(['people', 'profile.tags']) + }) +}) + describe('concatenatePaths', () => { it('should concatenate two object accessors with dot', () => { expect(concatenatePaths('user', 'name')).toBe('user.name') diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index d45412fe9..35fba7ce9 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -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' @@ -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(null) + + const form = useForm({ + defaultValues: data ?? { people: [] }, + }) + + useEffect(() => { + void Promise.resolve().then(() => { + setData({ people: [{ name: 'Alice' }, { name: 'Bob' }] }) + }) + }, []) + + if (!data) { + return
loading
+ } + + return ( + + {(field) => { + const val = field.state.value ?? [] + return ( +
    + {val.map((person, i) => ( +
  1. + {person.name} +
  2. + ))} +
+ ) + }} +
+ ) + } + + const { getByTestId } = render() + 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') From 078e802f169f058e0c3532e30d72404efe552b8f Mon Sep 17 00:00:00 2001 From: hemraj-007 <140829250+hemraj-007@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:30:22 +0530 Subject: [PATCH 2/2] fix: recurse into array items in collectArrayFieldPaths --- packages/form-core/src/FieldApi.ts | 3 +++ packages/form-core/src/utils.ts | 5 +++++ packages/form-core/tests/utils.spec.ts | 8 ++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index b762a8324..a8032bbe7 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -902,6 +902,9 @@ 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) && diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index 727388e47..0b6c4ec2e 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -288,6 +288,11 @@ export function collectArrayFieldPaths( 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)) } diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index 2db6865d7..4ceb00557 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -855,6 +855,14 @@ describe('collectArrayFieldPaths', () => { }), ).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', () => {