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
11 changes: 11 additions & 0 deletions .changeset/stale-berries-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/angular-form': patch
'@tanstack/preact-form': patch
'@tanstack/svelte-form': patch
'@tanstack/react-form': patch
'@tanstack/solid-form': patch
'@tanstack/form-core': patch
'@tanstack/vue-form': patch
---

re-render arrays when length doesn't change but values do
5 changes: 1 addition & 4 deletions packages/angular-form/src/tanstack-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,7 @@ export class TanStackField<
const isArrayMode = this.mode() === 'array'
const reactiveValue = injectStore(
this._api().store,
(state) =>
isArrayMode
? Object.keys((state.value as unknown) ?? []).length
: state.value,
(state) => (isArrayMode ? state.meta._arrayVersion || 0 : state.value),
injectorOpts,
)
const reactiveIsTouched = injectStore(
Expand Down
40 changes: 40 additions & 0 deletions packages/angular-form/tests/tanstack-field.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,46 @@ describe('TanStackFieldDirective', () => {
await findByText(onBlurError)
expect(getByText(onBlurError)).toBeInTheDocument()
})

it('should rerender array mode field on swapFieldValues', async () => {
@Component({
selector: 'test-component',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ng-container
[tanstackField]="form"
name="test"
mode="array"
#f="field"
>
<div data-testid="val">{{ stringify(f.api.state.value) }}</div>
<button
data-testid="swap"
type="button"
(click)="form.swapFieldValues('test', 0, 1)"
>
swap
</button>
</ng-container>
`,
imports: [TanStackField],
})
class TestComponent {
form = injectForm({
defaultValues: {
test: ['a', 'b'] as string[],
},
})
stringify = (v: unknown) => JSON.stringify(v)
}

const { getByTestId } = await render(TestComponent)

expect(getByTestId('val')).toHaveTextContent('["a","b"]')
await user.click(getByTestId('swap'))
expect(getByTestId('val')).toHaveTextContent('["b","a"]')
})
})

describe('form should reset default value when resetting in onSubmit', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,14 @@ export type FieldMetaBase<
* A flag indicating whether the field is currently being validated.
*/
isValidating: boolean
/**
* @private a counter that is incremented every time a structural array
* operation (push, insert, remove, swap, move, replace, clear) modifies
* the value of an array field. Adapters can subscribe to this to trigger
* re-renders for `mode="array"` fields without having to subscribe to the
* full field value.
*/
_arrayVersion: number
}

export type AnyFieldMetaBase = FieldMetaBase<
Expand Down
7 changes: 7 additions & 0 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,7 @@ export class FormApi<
isValidating: false,
isBlurred: false,
isDirty: false,
_arrayVersion: 0,
...(existingFieldMeta ?? {}),
errorSourceMap: {
...(existingFieldMeta?.['errorSourceMap'] ?? {}),
Expand Down Expand Up @@ -2396,6 +2397,8 @@ export class FormApi<
(prev) => [...(Array.isArray(prev) ? prev : []), value] as any,
options,
)

metaHelper(this).bumpArrayVersion(field)
}

insertFieldValue = async <TField extends DeepKeysOfType<TFormData, any[]>>(
Expand Down Expand Up @@ -2453,6 +2456,8 @@ export class FormApi<
mergeOpts(options, { dontValidate: true }),
)

metaHelper(this).bumpArrayVersion(field)

const dontValidate = options?.dontValidate ?? false
if (!dontValidate) {
// Validate the whole array + all fields that have shifted
Expand Down Expand Up @@ -2584,6 +2589,8 @@ export class FormApi<
mergeOpts(options, { dontValidate: true }),
)

metaHelper(this).bumpArrayVersion(field)

if (lastIndex !== null) {
for (let i = 0; i <= lastIndex; i++) {
const fieldKey = `${field}[${i}]`
Expand Down
20 changes: 20 additions & 0 deletions packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const defaultFieldMeta: AnyFieldMeta = {
errors: [],
errorMap: {},
errorSourceMap: {},
_arrayVersion: 0,
}

export function metaHelper<
Expand Down Expand Up @@ -50,6 +51,20 @@ export function metaHelper<
TSubmitMeta
>,
) {
/**
* Bump the `_arrayVersion` counter on the array field's meta. This
* provides a cheap, structural signal that adapters can subscribe to in
* order to trigger re-renders when the array is mutated in ways that
* `length` alone cannot detect (e.g. swaps and moves).
*/
function bumpArrayVersion(field: DeepKeys<TFormData>) {
const currentMeta = formApi.getFieldMeta(field) ?? defaultFieldMeta
formApi.setFieldMeta(field, {
...currentMeta,
_arrayVersion: (currentMeta._arrayVersion || 0) + 1,
})
}

/**
* Handle the meta shift caused from moving a field from one index to another.
*/
Expand All @@ -58,6 +73,7 @@ export function metaHelper<
fromIndex: number,
toIndex: number,
) {
bumpArrayVersion(field)
const affectedFields = getAffectedFields(field, fromIndex, 'move', toIndex)

const startIndex = Math.min(fromIndex, toIndex)
Expand Down Expand Up @@ -102,6 +118,7 @@ export function metaHelper<
* Handle the meta shift from removing a field at the specified index.
*/
function handleArrayRemove(field: DeepKeys<TFormData>, index: number) {
bumpArrayVersion(field)
const affectedFields = getAffectedFields(field, index, 'remove')

shiftMeta(affectedFields, 'up')
Expand All @@ -115,6 +132,7 @@ export function metaHelper<
index: number,
secondIndex: number,
) {
bumpArrayVersion(field)
const affectedFields = getAffectedFields(field, index, 'swap', secondIndex)

affectedFields.forEach((fieldKey) => {
Expand Down Expand Up @@ -143,6 +161,7 @@ export function metaHelper<
* Handle the meta shift from inserting a field at the specified index.
*/
function handleArrayInsert(field: DeepKeys<TFormData>, insertIndex: number) {
bumpArrayVersion(field)
const affectedFields = getAffectedFields(field, insertIndex, 'insert')

shiftMeta(affectedFields, 'down')
Expand Down Expand Up @@ -229,6 +248,7 @@ export function metaHelper<
const getEmptyFieldMeta = (): AnyFieldMeta => defaultFieldMeta

return {
bumpArrayVersion,
handleArrayMove,
handleArrayRemove,
handleArraySwap,
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ describe('field api', () => {
errors: [],
errorMap: {},
errorSourceMap: {},
_arrayVersion: 0,
})
})

Expand Down
2 changes: 1 addition & 1 deletion packages/preact-form/src/useField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export function useField<
const reactiveStateValue = useStore(
fieldApi.store,
(opts.mode === 'array'
? (state) => Object.keys((state.value as unknown) ?? []).length
? (state) => state.meta._arrayVersion || 0
: (state) => state.value) as (
state: typeof fieldApi.state,
) => TData | number,
Expand Down
51 changes: 51 additions & 0 deletions packages/preact-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,57 @@ describe('useField', () => {
expect(renderCount.arrayField).toBeGreaterThan(arrayFieldBeforeAdd)
})

it('should rerender array field on swapFieldValues even when length is unchanged', async () => {
const renderCount = { arrayField: 0 }

function Comp() {
const form = useForm({
defaultValues: {
people: [{ name: 'John' }, { name: 'Jane' }],
},
})

return (
<form.Field name="people" mode="array">
{(arrayField) => {
renderCount.arrayField++
return (
<div>
<ol data-testid="list">
{arrayField.state.value.map((person, i) => (
// eslint-disable-next-line @eslint-react/no-array-index-key
<li key={i} data-testid={`item-${i}`}>
{person.name}
</li>
))}
</ol>
<button
type="button"
data-testid="swap"
onClick={() => form.swapFieldValues('people', 0, 1)}
>
Swap
</button>
</div>
)
}}
</form.Field>
)
}

const { getByTestId } = render(<Comp />)

expect(getByTestId('item-0')).toHaveTextContent('John')
expect(getByTestId('item-1')).toHaveTextContent('Jane')

const before = renderCount.arrayField
await user.click(getByTestId('swap'))

expect(renderCount.arrayField).toBeGreaterThan(before)
expect(getByTestId('item-0')).toHaveTextContent('Jane')
expect(getByTestId('item-1')).toHaveTextContent('John')
})

it('should handle defaultValue without setstate-in-render error', async () => {
// Spy on console.error before rendering
const consoleErrorSpy = vi.spyOn(console, 'error')
Expand Down
2 changes: 1 addition & 1 deletion packages/react-form/src/useField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function useField<
const reactiveStateValue = useStore(
fieldApi.store,
(opts.mode === 'array'
? (state) => Object.keys((state.value as unknown) ?? []).length
? (state) => state.meta._arrayVersion || 0
: (state) => state.value) as (
state: typeof fieldApi.state,
) => TData | number,
Expand Down
56 changes: 56 additions & 0 deletions packages/react-form/tests/useField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,62 @@ describe('useField', () => {
expect(renderCount.arrayField).toBeGreaterThan(arrayFieldBeforeAdd)
})

it('should rerender array field on swapFieldValues even when length is unchanged', async () => {
// swapFieldValues does not change array length but must still notify the
// parent array field so subscribers see the new order.
const renderCount = { arrayField: 0 }

function Comp() {
const form = useForm({
defaultValues: {
people: [{ name: 'John' }, { name: 'Jane' }],
},
})

return (
<form.Field name="people" mode="array">
{(arrayField) => {
renderCount.arrayField++
return (
<div>
<ol data-testid="list">
{arrayField.state.value.map((person, i) => (
<li key={i} data-testid={`item-${i}`}>
{person.name}
</li>
))}
</ol>
<button
type="button"
data-testid="swap"
onClick={() => form.swapFieldValues('people', 0, 1)}
>
Swap
</button>
</div>
)
}}
</form.Field>
)
}

const { getByTestId } = render(
<StrictMode>
<Comp />
</StrictMode>,
)

expect(getByTestId('item-0')).toHaveTextContent('John')
expect(getByTestId('item-1')).toHaveTextContent('Jane')

const before = renderCount.arrayField
await user.click(getByTestId('swap'))

expect(renderCount.arrayField).toBeGreaterThan(before)
expect(getByTestId('item-0')).toHaveTextContent('Jane')
expect(getByTestId('item-1')).toHaveTextContent('John')
})

it('should handle defaultValue without setstate-in-render error', async () => {
// Spy on console.error before rendering
const consoleErrorSpy = vi.spyOn(console, 'error')
Expand Down
4 changes: 1 addition & 3 deletions packages/solid-form/src/createField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,7 @@ function makeFieldReactive<
// piece so that consumers re-render when any meta property updates.
// See: https://github.com/TanStack/form/issues/1961
const reactiveStateValue = useStore(fieldApi.store, (state) =>
mode === 'array'
? Object.keys((state.value as unknown) ?? []).length
: state.value,
mode === 'array' ? state.meta._arrayVersion || 0 : state.value,
)
const reactiveMetaIsTouched = useStore(
fieldApi.store,
Expand Down
30 changes: 30 additions & 0 deletions packages/solid-form/tests/createField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,4 +595,34 @@ describe('createField', () => {
expect(getByTestId('val')).toHaveTextContent('["a","b"]'),
)
})

it('should rerender array mode field on swapFieldValues', async () => {
function Comp() {
const form = createForm(() => ({
defaultValues: {
test: ['a', 'b'],
},
}))

return (
<form.Field name="test" mode="array">
{(field) => (
<div>
<div data-testid="val">{JSON.stringify(field().state.value)}</div>
<button onClick={() => form.swapFieldValues('test', 0, 1)}>
swap
</button>
</div>
)}
</form.Field>
)
}

const { getByTestId, getByText } = render(() => <Comp />)
expect(getByTestId('val')).toHaveTextContent('["a","b"]')
await user.click(getByText('swap'))
await waitFor(() =>
expect(getByTestId('val')).toHaveTextContent('["b","a"]'),
)
})
})
2 changes: 1 addition & 1 deletion packages/svelte-form/src/Field.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
const storeSub = useStore(api.store, (state) =>
options.mode === 'array'
? Object.keys((state.value as unknown) ?? []).length
? state.meta._arrayVersion || 0
: state.value,
)
const metaIsTouchedSub = useStore(
Expand Down
Loading
Loading