Skip to content
Open
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
39 changes: 32 additions & 7 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) &&
Expand Down
35 changes: 35 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
48 changes: 48 additions & 0 deletions packages/react-form/tests/useForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<form.Field
name="firstName"
children={(field) => (
<input
data-testid="firstName"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<p>isDirty: {String(isDirty)}</p>
<button onClick={form.handleSubmit}>Submit</button>
</>
)
}

const { getByTestId, getByText } = render(<Comp />)
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)
Expand Down