diff --git a/.changeset/stale-berries-drop.md b/.changeset/stale-berries-drop.md new file mode 100644 index 000000000..64def540b --- /dev/null +++ b/.changeset/stale-berries-drop.md @@ -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 diff --git a/packages/angular-form/src/tanstack-field.ts b/packages/angular-form/src/tanstack-field.ts index bb5bfdfd9..eb62bfb09 100644 --- a/packages/angular-form/src/tanstack-field.ts +++ b/packages/angular-form/src/tanstack-field.ts @@ -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( diff --git a/packages/angular-form/tests/tanstack-field.spec.ts b/packages/angular-form/tests/tanstack-field.spec.ts index 879fc1757..b5eb80ae7 100644 --- a/packages/angular-form/tests/tanstack-field.spec.ts +++ b/packages/angular-form/tests/tanstack-field.spec.ts @@ -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: ` + + {{ stringify(f.api.state.value) }} + + swap + + + `, + 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', () => { diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index ca6b52f61..36691a3d2 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -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< diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 955e35281..c12a0cfca 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1048,6 +1048,7 @@ export class FormApi< isValidating: false, isBlurred: false, isDirty: false, + _arrayVersion: 0, ...(existingFieldMeta ?? {}), errorSourceMap: { ...(existingFieldMeta?.['errorSourceMap'] ?? {}), @@ -2396,6 +2397,8 @@ export class FormApi< (prev) => [...(Array.isArray(prev) ? prev : []), value] as any, options, ) + + metaHelper(this).bumpArrayVersion(field) } insertFieldValue = async >( @@ -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 @@ -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}]` diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index a990c0c0a..24a6da9b0 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -19,6 +19,7 @@ export const defaultFieldMeta: AnyFieldMeta = { errors: [], errorMap: {}, errorSourceMap: {}, + _arrayVersion: 0, } export function metaHelper< @@ -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) { + 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. */ @@ -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) @@ -102,6 +118,7 @@ export function metaHelper< * Handle the meta shift from removing a field at the specified index. */ function handleArrayRemove(field: DeepKeys, index: number) { + bumpArrayVersion(field) const affectedFields = getAffectedFields(field, index, 'remove') shiftMeta(affectedFields, 'up') @@ -115,6 +132,7 @@ export function metaHelper< index: number, secondIndex: number, ) { + bumpArrayVersion(field) const affectedFields = getAffectedFields(field, index, 'swap', secondIndex) affectedFields.forEach((fieldKey) => { @@ -143,6 +161,7 @@ export function metaHelper< * Handle the meta shift from inserting a field at the specified index. */ function handleArrayInsert(field: DeepKeys, insertIndex: number) { + bumpArrayVersion(field) const affectedFields = getAffectedFields(field, insertIndex, 'insert') shiftMeta(affectedFields, 'down') @@ -229,6 +248,7 @@ export function metaHelper< const getEmptyFieldMeta = (): AnyFieldMeta => defaultFieldMeta return { + bumpArrayVersion, handleArrayMove, handleArrayRemove, handleArraySwap, diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index f5d5051ea..ba4f64baf 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -65,6 +65,7 @@ describe('field api', () => { errors: [], errorMap: {}, errorSourceMap: {}, + _arrayVersion: 0, }) }) diff --git a/packages/preact-form/src/useField.tsx b/packages/preact-form/src/useField.tsx index 2162b5bc4..e5f5bae15 100644 --- a/packages/preact-form/src/useField.tsx +++ b/packages/preact-form/src/useField.tsx @@ -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, diff --git a/packages/preact-form/tests/useField.test.tsx b/packages/preact-form/tests/useField.test.tsx index a414be3b3..29e1de831 100644 --- a/packages/preact-form/tests/useField.test.tsx +++ b/packages/preact-form/tests/useField.test.tsx @@ -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 ( + + {(arrayField) => { + renderCount.arrayField++ + return ( + + + {arrayField.state.value.map((person, i) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key + + {person.name} + + ))} + + form.swapFieldValues('people', 0, 1)} + > + Swap + + + ) + }} + + ) + } + + const { getByTestId } = render() + + 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') diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 76d2bc923..a2b9241ea 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -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, diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index bcf7ed25c..ef618e6bf 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -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 ( + + {(arrayField) => { + renderCount.arrayField++ + return ( + + + {arrayField.state.value.map((person, i) => ( + + {person.name} + + ))} + + form.swapFieldValues('people', 0, 1)} + > + Swap + + + ) + }} + + ) + } + + const { getByTestId } = render( + + + , + ) + + 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') diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx index 18ff73c70..373146864 100644 --- a/packages/solid-form/src/createField.tsx +++ b/packages/solid-form/src/createField.tsx @@ -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, diff --git a/packages/solid-form/tests/createField.test.tsx b/packages/solid-form/tests/createField.test.tsx index a2a492010..00395ee31 100644 --- a/packages/solid-form/tests/createField.test.tsx +++ b/packages/solid-form/tests/createField.test.tsx @@ -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 ( + + {(field) => ( + + {JSON.stringify(field().state.value)} + form.swapFieldValues('test', 0, 1)}> + swap + + + )} + + ) + } + + const { getByTestId, getByText } = render(() => ) + expect(getByTestId('val')).toHaveTextContent('["a","b"]') + await user.click(getByText('swap')) + await waitFor(() => + expect(getByTestId('val')).toHaveTextContent('["b","a"]'), + ) + }) }) diff --git a/packages/svelte-form/src/Field.svelte b/packages/svelte-form/src/Field.svelte index 8300a6fbb..bf1362964 100644 --- a/packages/svelte-form/src/Field.svelte +++ b/packages/svelte-form/src/Field.svelte @@ -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( diff --git a/packages/svelte-form/tests/array-swap.svelte b/packages/svelte-form/tests/array-swap.svelte new file mode 100644 index 000000000..57604fc14 --- /dev/null +++ b/packages/svelte-form/tests/array-swap.svelte @@ -0,0 +1,24 @@ + + + + + + {#snippet children(field)} + {JSON.stringify(field.state.value)} + form.swapFieldValues('test', 0, 1)} + > + swap + + {/snippet} + diff --git a/packages/svelte-form/tests/array.test.ts b/packages/svelte-form/tests/array.test.ts index 852dcd3d5..df958b91f 100644 --- a/packages/svelte-form/tests/array.test.ts +++ b/packages/svelte-form/tests/array.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { userEvent } from '@testing-library/user-event' import { mount, unmount } from 'svelte' import TestForm from './array.svelte' +import SwapForm from './array-swap.svelte' describe('Svelte Field array mode', () => { let element: HTMLDivElement @@ -25,3 +26,26 @@ describe('Svelte Field array mode', () => { expect(element.querySelector('#val')!.textContent).toBe('["a","b"]') }) }) + +describe('Svelte Field array mode swapFieldValues', () => { + let element: HTMLDivElement + let instance: any + beforeEach(async () => { + element = document.createElement('div') + document.body.appendChild(element) + instance = mount(SwapForm, { + target: element, + }) + }) + + afterEach(() => { + unmount(instance) + element.remove() + }) + + it('should rerender on swapFieldValues even when length is unchanged', async () => { + expect(element.querySelector('#val')!.textContent).toBe('["a","b"]') + await userEvent.click(element.querySelector('#swap')!) + expect(element.querySelector('#val')!.textContent).toBe('["b","a"]') + }) +}) diff --git a/packages/vue-form/src/useField.tsx b/packages/vue-form/src/useField.tsx index d2a329b78..e51d79681 100644 --- a/packages/vue-form/src/useField.tsx +++ b/packages/vue-form/src/useField.tsx @@ -260,7 +260,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, diff --git a/packages/vue-form/tests/useField.test.tsx b/packages/vue-form/tests/useField.test.tsx index 6adfcde07..f01707cc3 100644 --- a/packages/vue-form/tests/useField.test.tsx +++ b/packages/vue-form/tests/useField.test.tsx @@ -429,4 +429,34 @@ describe('useField', () => { expect(getByTestId('val')).toHaveTextContent('["a","b"]'), ) }) + + it('should rerender array mode field on swapFieldValues', async () => { + const Comp = defineComponent(() => { + const form = useForm({ + defaultValues: { + test: ['a', 'b'], + }, + }) + + return () => ( + + {({ field }: { field: AnyFieldApi }) => ( + + {JSON.stringify(field.state.value)} + form.swapFieldValues('test', 0, 1)}> + swap + + + )} + + ) + }) + + const { getByTestId, getByText } = render(Comp) + expect(getByTestId('val')).toHaveTextContent('["a","b"]') + await user.click(getByText('swap')) + await waitFor(() => + expect(getByTestId('val')).toHaveTextContent('["b","a"]'), + ) + }) })