diff --git a/.changeset/async-default-untouched-fields.md b/.changeset/async-default-untouched-fields.md new file mode 100644 index 000000000..23955b7db --- /dev/null +++ b/.changeset/async-default-untouched-fields.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +fix(form-core): apply updated async `defaultValues` to untouched fields even after another field has been edited, instead of skipping the whole update once any field is touched (#2229) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index d710cd105..bad9ab8ef 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1754,8 +1754,7 @@ export class FormApi< const shouldUpdateValues = options.defaultValues && - !evaluate(options.defaultValues, oldOptions.defaultValues) && - !this.state.isTouched + !evaluate(options.defaultValues, oldOptions.defaultValues) const shouldUpdateState = !evaluate(options.defaultState, oldOptions.defaultState) && @@ -1763,6 +1762,25 @@ export class FormApi< if (!shouldUpdateValues && !shouldUpdateState) return + // Fields the user has already edited must keep their edited values, but + // untouched fields (including ones that have not mounted yet) should still + // receive their newly-arrived default values — even when a *different* + // field has already been touched. See #2229. + let nextValues = options.defaultValues + if (shouldUpdateValues && this.state.isTouched) { + for (const [fieldName, fieldMeta] of Object.entries( + this.state.fieldMeta, + )) { + if ((fieldMeta as AnyFieldLikeMeta | undefined)?.isTouched) { + nextValues = setBy( + nextValues, + fieldName, + getBy(this.state.values, fieldName), + ) + } + } + } + batch(() => { this.baseStore.setState(() => getDefaultFormState( @@ -1774,7 +1792,7 @@ export class FormApi< shouldUpdateValues ? { - values: options.defaultValues, + values: nextValues, } : {}, ), diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index 024b319f0..cbcd080a7 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -1232,6 +1232,54 @@ describe('form api', () => { expect(form.getFieldValue('name')).toEqual('two') }) + it('should apply new default values to untouched fields after another field is edited', () => { + const form = new FormApi({ + defaultValues: { + name: '', + asyncField: '', + }, + }) + form.mount() + + // The user edits one field while async data for another is still loading. + form.setFieldValue('name', 'edited') + + // The async default for `asyncField` resolves and is passed in via update(). + form.update({ + defaultValues: { + name: '', + asyncField: 'loaded default', + }, + }) + + // The untouched field receives its newly-arrived default... + expect(form.getFieldValue('asyncField')).toEqual('loaded default') + // ...while the edited field keeps the user's value. + expect(form.getFieldValue('name')).toEqual('edited') + }) + + it('should apply new default values to untouched array fields after another field is edited', () => { + const form = new FormApi({ + defaultValues: { + name: '', + asyncItems: [] as string[], + }, + }) + form.mount() + + form.setFieldValue('name', 'edited') + + form.update({ + defaultValues: { + name: '', + asyncItems: ['a', 'b'], + }, + }) + + expect(form.getFieldValue('asyncItems')).toEqual(['a', 'b']) + expect(form.getFieldValue('name')).toEqual('edited') + }) + it('should delete field from the form', () => { const form = new FormApi({ defaultValues: {