diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 5c9de3c1c..60ab2a49f 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1067,6 +1067,15 @@ export class FormApi< */ private _devtoolsSubmissionOverride: boolean + /** + * The `defaultValues` carried by the options on the previous `update()` call + * (i.e. the last values that came from the framework adapter / props). Used + * to detect a genuine props change without being fooled by `reset(values)`, + * which legitimately mutates `this.options.defaultValues`. + * See https://github.com/TanStack/form/issues/1681 + */ + private _prevUpdateDefaultValues: TFormData | undefined = undefined + /** * Constructs a new `FormApi` instance with the given form options. */ @@ -1748,13 +1757,29 @@ export class FormApi< const oldOptions = this.options - // Options need to be updated first so that when the store is updated, the state is correct for the derived state - this.options = options - - const shouldUpdateValues = - options.defaultValues && - !evaluate(options.defaultValues, oldOptions.defaultValues) && - !this.state.isTouched + // `reset(values)` moves the baseline by writing `this.options.defaultValues`. + // Compare the incoming `defaultValues` against the ones seen on the previous + // `update()` (the previous props), NOT against `this.options.defaultValues`, + // so a reset isn't misread as a props change and doesn't get clobbered. + // See https://github.com/TanStack/form/issues/1681 + const prevUpdateDefaultValues = this._prevUpdateDefaultValues + this._prevUpdateDefaultValues = options.defaultValues + + const defaultValuesChanged = + !!options.defaultValues && + !evaluate(options.defaultValues, prevUpdateDefaultValues) + + // Options need to be updated first so that when the store is updated, the state is correct for the derived state. + // When the props `defaultValues` did not actually change, preserve the + // current baseline (e.g. one set by `reset(values)`) instead of overwriting + // it, so `isDirty`/`isDefaultValue` stay correct after a reset. + this.options = + defaultValuesChanged || + evaluate(options.defaultValues, oldOptions.defaultValues) + ? options + : { ...options, defaultValues: oldOptions.defaultValues } + + const shouldUpdateValues = defaultValuesChanged && !this.state.isTouched const shouldUpdateState = !evaluate(options.defaultState, oldOptions.defaultState) && diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 092d0ed6c..dadb9c605 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -1232,6 +1232,41 @@ describe('form api', () => { expect(form.getFieldValue('name')).toEqual('two') }) + it('should keep reset() values when a subsequent update() reuses the original props defaultValues', () => { + // Reproduces https://github.com/TanStack/form/issues/1681 + // In react-form, `formApi.update(opts)` runs in a layout effect on every + // render. When `reset(newValues)` is called (e.g. inside onSubmit) it both + // changes the store and updates `options.defaultValues`, which triggers a + // re-render. The following `update()` still carries the *original* props + // `defaultValues`, which previously looked like a real change and clobbered + // the freshly reset values. + const form = new FormApi({ + defaultValues: { + name: 'original', + }, + }) + form.mount() + + // user edits the field -> form becomes touched + form.setFieldValue('name', 'edited') + expect(form.getFieldValue('name')).toEqual('edited') + + // onSubmit calls reset with brand new default values + form.reset({ name: 'new-default' }) + expect(form.getFieldValue('name')).toEqual('new-default') + expect(form.state.isTouched).toBe(false) + + // re-render fires the layout effect with the UNCHANGED props defaultValues + form.update({ + defaultValues: { + name: 'original', + }, + }) + + // the reset values must survive: props defaultValues never actually changed + expect(form.getFieldValue('name')).toEqual('new-default') + }) + it('should delete field from the form', () => { const form = new FormApi({ defaultValues: { diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx index b71094cdd..8f58889a6 100644 --- a/packages/react-form/tests/useForm.test.tsx +++ b/packages/react-form/tests/useForm.test.tsx @@ -124,6 +124,54 @@ describe('useForm', () => { ) }) + it('should keep reset(values) called inside onSubmit while subscribed to isDirty', async () => { + // Reproduces https://github.com/TanStack/form/issues/1681 + // A component-level `isDirty` subscription re-renders the component after + // reset(), which re-runs the `formApi.update(opts)` layout effect with the + // unchanged props defaultValues. That update must not clobber the values + // just set by reset() back to the original default values. + function Comp() { + const form = useForm({ + defaultValues: { firstName: 'FirstName' }, + onSubmit: () => { + form.reset({ firstName: 'ResetName' }) + }, + }) + + const isDirty = useStore(form.store, (state) => state.isDirty) + + return ( + <> + ( + field.handleChange(e.target.value)} + /> + )} + /> +

isDirty: {String(isDirty)}

+ + + ) + } + + const { getByTestId, getByText } = render() + const input = getByTestId('firstName') as HTMLInputElement + + // make the form dirty, then submit -> reset({ firstName: 'ResetName' }) + await user.type(input, 'X') + await user.click(getByText('Submit')) + + // the reset values must survive the post-reset update() sync + await waitFor(() => expect(input.value).toBe('ResetName')) + // and the reset baseline should make the form pristine again + await waitFor(() => expect(getByText('isDirty: false')).toBeInTheDocument()) + }) + it('should run on form mount', async () => { function Comp() { const [formMounted, setFormMounted] = useState(false)