diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 5c9de3c1c..2f141532a 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -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. @@ -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 + } + const shouldUpdateValues = options.defaultValues && !evaluate(options.defaultValues, oldOptions.defaultValues) && @@ -1815,6 +1836,7 @@ export class FormApi< ...this.options, defaultValues: values, } + this._defaultValuesOverridden = true } this.baseStore.setState(() => { diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 092d0ed6c..2d2b77021 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -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()