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
22 changes: 22 additions & 0 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,12 @@ export class FormApi<
* @private
*/
private _devtoolsSubmissionOverride: boolean
/**
* @private
* Tracks whether `reset(values)` was called with explicit new default values.
* When true, `update()` must not overwrite those values with stale prop values.
*/
private _defaultValuesOverridden: boolean = false

/**
* Constructs a new `FormApi` instance with the given form options.
Expand Down Expand Up @@ -1751,6 +1757,21 @@ export class FormApi<
// Options need to be updated first so that when the store is updated, the state is correct for the derived state
this.options = options

// If reset(newValues) was called, the incoming `options` still carries the
// original (pre-reset) defaultValues from the render closure. We must not
// let those stale values overwrite what reset() set, so we skip the
// shouldUpdateValues branch and clear the flag for the next render.
if (this._defaultValuesOverridden) {
this._defaultValuesOverridden = false
// Keep the defaultValues that reset() stored on this.options so that
// future renders compare against them correctly.
this.options = {
...options,
defaultValues: oldOptions.defaultValues,
}
return
}
Comment on lines +1760 to +1773

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Line 1764: early return unintentionally suppresses defaultState synchronization.

When _defaultValuesOverridden is set, update() returns before evaluating shouldUpdateState, so a real options.defaultState change in that cycle can be silently dropped.

💡 Suggested fix
-    if (this._defaultValuesOverridden) {
-      this._defaultValuesOverridden = false
-      // Keep the defaultValues that reset() stored on this.options so that
-      // future renders compare against them correctly.
-      this.options = {
-        ...options,
-        defaultValues: oldOptions.defaultValues,
-      }
-      return
-    }
+    let nextOptions = options
+    if (this._defaultValuesOverridden) {
+      this._defaultValuesOverridden = false
+      // Keep defaultValues written by reset(), but still allow normal update()
+      // processing (eg defaultState sync) in this cycle.
+      nextOptions = {
+        ...options,
+        defaultValues: oldOptions.defaultValues,
+      }
+      this.options = nextOptions
+    }

-    const shouldUpdateValues =
-      options.defaultValues &&
-      !evaluate(options.defaultValues, oldOptions.defaultValues) &&
+    const shouldUpdateValues =
+      nextOptions.defaultValues &&
+      !evaluate(nextOptions.defaultValues, oldOptions.defaultValues) &&
       !this.state.isTouched

     const shouldUpdateState =
-      !evaluate(options.defaultState, oldOptions.defaultState) &&
+      !evaluate(nextOptions.defaultState, oldOptions.defaultState) &&
       !this.state.isTouched

@@
-            shouldUpdateState ? options.defaultState : {},
+            shouldUpdateState ? nextOptions.defaultState : {},

             shouldUpdateValues
               ? {
-                  values: options.defaultValues,
+                  values: nextOptions.defaultValues,
                 }
               : {},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/form-core/src/FormApi.ts` around lines 1760 - 1773, The early return
in the `_defaultValuesOverridden` block within the `update()` method prevents
`shouldUpdateState` from being evaluated, which causes changes to
`options.defaultState` to be dropped in that cycle. Instead of returning early
after handling the defaultValues override, continue processing the update by
allowing the `shouldUpdateState` evaluation to proceed. Merge the override logic
so that the old defaultValues are preserved while still permitting
synchronization of defaultState changes in the same cycle.


const shouldUpdateValues =
options.defaultValues &&
!evaluate(options.defaultValues, oldOptions.defaultValues) &&
Expand Down Expand Up @@ -1815,6 +1836,7 @@ export class FormApi<
...this.options,
defaultValues: values,
}
this._defaultValuesOverridden = true
}

this.baseStore.setState(() => {
Expand Down
27 changes: 27 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,33 @@ describe('form api', () => {
form.handleSubmit()
})

it('should not overwrite reset(newValues) when update() is called with stale options afterwards', async () => {
// Simulates the race condition from issue #1681:
// reset(newValues) is called inside onSubmit, then the framework calls
// update(originalOpts) on the next render - the new values must survive.
const originalOptions = { defaultValues: { name: '' } }
const form = new FormApi(originalOptions)
form.mount()

form.setFieldValue('name', 'test')
await form.handleSubmit()

// Simulate reset() inside onSubmit having been called with new values
form.reset({ name: 'test' })

// State should now reflect the reset values
expect(form.state.values).toEqual({ name: 'test' })
expect(form.options.defaultValues).toEqual({ name: 'test' })

// Simulate the framework calling update() with the original (stale) options
// – this is what useIsomorphicLayoutEffect does after every render
form.update(originalOptions)

// Values and defaultValues must NOT revert to the original empty string
expect(form.state.values).toEqual({ name: 'test' })
expect(form.options.defaultValues).toEqual({ name: 'test' })
})

it('should reset and set the new default values that are restored after an empty reset', () => {
const form = new FormApi({ defaultValues: { name: 'initial' } })
form.mount()
Expand Down