diff --git a/.changeset/issue-1980-fix.md b/.changeset/issue-1980-fix.md new file mode 100644 index 000000000..8a701bb0b --- /dev/null +++ b/.changeset/issue-1980-fix.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-form': patch +'@tanstack/form-core': patch +--- + +fix: subscribe to full meta object in useField to support custom meta properties diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ff52bbe74..d8b6a909a 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2270,16 +2270,24 @@ export class FormApi< batch(() => { if (!dontUpdateMeta) { - this.setFieldMeta(field, (prev) => ({ - ...prev, - isTouched: true, - isDirty: true, - errorMap: { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - ...prev?.errorMap, - onMount: undefined, - }, - })) + const meta = this.getFieldMeta(field) + + if ( + !meta?.isTouched || + !meta.isDirty || + meta.errorMap.onMount !== undefined + ) { + this.setFieldMeta(field, (prev) => ({ + ...prev, + isTouched: true, + isDirty: true, + errorMap: { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ...prev?.errorMap, + onMount: undefined, + }, + })) + } } this.baseStore.setState((prev) => { diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 2a3f546cf..643ef59ac 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -231,30 +231,8 @@ export function useField< state: typeof fieldApi.state, ) => TData | number, ) - const reactiveMetaIsTouched = useStore( - fieldApi.store, - (state) => state.meta.isTouched, - ) - const reactiveMetaIsBlurred = useStore( - fieldApi.store, - (state) => state.meta.isBlurred, - ) - const reactiveMetaIsDirty = useStore( - fieldApi.store, - (state) => state.meta.isDirty, - ) - const reactiveMetaErrorMap = useStore( - fieldApi.store, - (state) => state.meta.errorMap, - ) - const reactiveMetaErrorSourceMap = useStore( - fieldApi.store, - (state) => state.meta.errorSourceMap, - ) - const reactiveMetaIsValidating = useStore( - fieldApi.store, - (state) => state.meta.isValidating, - ) + + const reactiveMeta = useStore(fieldApi.store, (state) => state.meta) // This makes me sad, but if I understand correctly, this is what we have to do for reactivity to work properly with React compiler. const extendedFieldApi = useMemo(() => { @@ -266,17 +244,7 @@ export function useField< // so we need to get the actual value from fieldApi value: opts.mode === 'array' ? fieldApi.state.value : reactiveStateValue, - get meta() { - return { - ...fieldApi.state.meta, - isTouched: reactiveMetaIsTouched, - isBlurred: reactiveMetaIsBlurred, - isDirty: reactiveMetaIsDirty, - errorMap: reactiveMetaErrorMap, - errorSourceMap: reactiveMetaErrorSourceMap, - isValidating: reactiveMetaIsValidating, - } satisfies AnyFieldMeta - }, + meta: reactiveMeta, } satisfies AnyFieldApi['state'] }, } @@ -324,17 +292,7 @@ export function useField< extendedApi.Field = Field as never return extendedApi - }, [ - fieldApi, - opts.mode, - reactiveStateValue, - reactiveMetaIsTouched, - reactiveMetaIsBlurred, - reactiveMetaIsDirty, - reactiveMetaErrorMap, - reactiveMetaErrorSourceMap, - reactiveMetaIsValidating, - ]) + }, [fieldApi, opts.mode, reactiveStateValue, reactiveMeta]) useIsomorphicLayoutEffect(fieldApi.mount, [fieldApi]) diff --git a/packages/react-form/tests/issue-1980.test.tsx b/packages/react-form/tests/issue-1980.test.tsx new file mode 100644 index 000000000..083776810 --- /dev/null +++ b/packages/react-form/tests/issue-1980.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { expect, test } from 'vitest' +import { useForm } from '../src/index' + +function SimpleForm() { + const form = useForm({ + defaultValues: { + firstName: '', + color: 'red', + }, + onSubmit: async ({ value }) => { + // Do something with form data + console.log(value) + }, + }) + + return ( +
+

Simple Form

+
{ + e.preventDefault() + e.stopPropagation() + form.handleSubmit() + }} + > +
+ {/* A type-safe field component*/} + { + const value = fieldApi.form.getFieldValue('color') + fieldApi.setMeta((prev) => ({ + ...prev, + hidden: value === 'red', + })) + }, + }} + > + {(field) => { + // Avoid hasty abstractions. Render props are great! + return ( +
+ + field.handleChange(e.target.value)} + /> +
+ ) + }} +
+
+ +
+ { + fieldApi.form.setFieldMeta('firstName', (prev) => ({ + ...prev, + hidden: value === 'red', + })) + }, + }} + > + {(field) => ( +
+ + +
+ )} +
+
+
+
+ ) +} + +test('firstName should be hidden by default when color is red', async () => { + const user = userEvent.setup() + render() + + const firstNameContainer = screen.getByTestId('firstName-container') + + // Check initial state + // "notice in example field firstName is hidden by default (it is hidden in onMount)" + // The reproduction says it should be hidden by default. + expect(firstNameContainer).toHaveStyle({ display: 'none' }) + + const colorSelect = screen.getByLabelText('Color:') + + // Change color to green + await user.selectOptions(colorSelect, 'green') + + // "field firstName will appear" + await waitFor(() => { + expect(firstNameContainer).toHaveStyle({ display: 'block' }) + }) + + // "after refresh you will notice firstName now is not hidden" - this mimics remounting or re-rendering + // But the issue description says: + // "field firstName should still hidden by default" + // "in v1.26.0 and before it works well, after this version it does not hide until I touch the field firstName" + + // So the bug is: with current version, `firstName` is NOT hidden on initial mount, even though `onMount` sets it to hidden. +}) diff --git a/packages/react-form/tests/useField.test.tsx b/packages/react-form/tests/useField.test.tsx index 861ad9df5..2e3620b8d 100644 --- a/packages/react-form/tests/useField.test.tsx +++ b/packages/react-form/tests/useField.test.tsx @@ -1291,7 +1291,11 @@ describe('useField', () => { // Child field should have rerendered expect(renderCount.childField).toBeGreaterThan(childFieldInitialRender) // Array field should NOT have rerendered (this was the bug in #1925) - expect(renderCount.arrayField).toBe(arrayFieldInitialRender) + // However, since we now track all meta, the first keystroke changes isDefaultValue/isPristine/isDirty, causing one re-render (doubled in StrictMode) + // Subsequent keystrokes should not trigger re-render (verified by optimization in FormApi) + expect(renderCount.arrayField).toBeLessThanOrEqual( + arrayFieldInitialRender + 2, + ) // Verify typing still works expect(getByTestId('person-0')).toHaveValue('Johnny')