diff --git a/.changeset/calm-fields-reset.md b/.changeset/calm-fields-reset.md new file mode 100644 index 0000000000..0b23c13a70 --- /dev/null +++ b/.changeset/calm-fields-reset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': patch +--- + +Fix `form.resetField()` leaving the current value in place when the field's default value is `undefined`. The field is now reset to `undefined`, matching `form.reset()` and `isDefaultValue`. A field-level `defaultValue` of `null` is also restored instead of falling through to the form-level default. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 4400017d30..4982a124ff 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -2940,7 +2940,10 @@ export class FormApi< const fieldDefault = this.getFieldInfo(field).instance?.options.defaultValue const formDefault = getBy(this.options.defaultValues, field) - const targetValue = fieldDefault ?? formDefault + const targetValue = + fieldDefault !== undefined ? fieldDefault : formDefault + const hasDefault = + fieldDefault !== undefined || this.options.defaultValues !== undefined return { ...prev, @@ -2948,10 +2951,9 @@ export class FormApi< ...prev.fieldMetaBase, [field]: defaultFieldMeta, }, - values: - targetValue !== undefined - ? setBy(prev.values, field, targetValue) - : prev.values, + values: hasDefault + ? setBy(prev.values, field, targetValue) + : prev.values, } }) } diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index c57bb044e8..448b162dfa 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -4236,6 +4236,135 @@ it('should preserve nested fields on resetField if defaultValues is not provided expect(form.state.values.nested.field.name).toEqual('Nested') }) +it.each([ + ['undefined', undefined], + ['null', null], + ['an empty string', ''], + ['false', false], + ['0', 0], + ['a defined value', 'tony'], +])( + 'should reset a field to a form-level default of %s on resetField', + (_, defaultValue) => { + const form = new FormApi({ + defaultValues: { name: defaultValue } as { name: unknown }, + }) + form.mount() + + const field = new FieldApi({ form, name: 'name' }) + field.mount() + + field.setValue('hawk') + expect(field.getMeta().isDefaultValue).toBe(false) + + form.resetField('name') + + expect(form.state.values.name).toBe(defaultValue) + expect(field.state.value).toBe(defaultValue) + expect(field.getMeta().isDefaultValue).toBe(true) + expect(field.getMeta().isTouched).toBe(false) + }, +) + +it('should reset a field to undefined on resetField if its key is missing from defaultValues', () => { + const form = new FormApi({ + defaultValues: {} as { name?: string }, + }) + form.mount() + + const field = new FieldApi({ form, name: 'name' }) + field.mount() + + field.setValue('hawk') + form.resetField('name') + + expect(form.state.values.name).toBeUndefined() + expect(field.getMeta().isDefaultValue).toBe(true) +}) + +it('should reset nested fields with undefined defaults on resetField without changing their siblings', () => { + const form = new FormApi({ + defaultValues: { + nested: { name: undefined, age: 1 }, + people: [{ name: undefined }, { name: 'second' }], + } as { + nested: { name?: string; age: number } + people: { name?: string }[] + }, + }) + form.mount() + + form.setFieldValue('nested.name', 'Nested') + form.setFieldValue('nested.age', 2) + form.setFieldValue('people[0].name', 'first') + form.setFieldValue('people[1].name', 'changed') + + form.resetField('nested.name') + form.resetField('people[0].name') + + expect(form.state.values).toEqual({ + nested: { name: undefined, age: 2 }, + people: [{ name: undefined }, { name: 'changed' }], + }) +}) + +it.each([ + ['a defined value', 'tony'], + ['an empty string', ''], + ['false', false], + ['0', 0], +])( + 'should reset a field to a field-level default of %s on resetField if defaultValues is not provided', + (_, defaultValue) => { + const form = new FormApi({ + defaultState: { values: { name: 'initial' } as { name: unknown } }, + }) + form.mount() + + const field = new FieldApi({ form, name: 'name', defaultValue }) + field.mount() + + field.setValue('hawk') + form.resetField('name') + + expect(form.state.values.name).toBe(defaultValue) + expect(field.getMeta().isDefaultValue).toBe(true) + }, +) + +it('should reset a field to a field-level default of null on resetField if defaultValues is not provided', () => { + const form = new FormApi({ + defaultState: { values: { name: 'initial' } as { name: unknown } }, + }) + form.mount() + + const field = new FieldApi({ form, name: 'name', defaultValue: null }) + field.mount() + + field.setValue('hawk') + form.resetField('name') + + expect(form.state.values.name).toBeNull() + expect(field.state.value).toBeNull() + expect(field.getMeta().isTouched).toBe(false) +}) + +it('should prefer a field-level default of null over the form-level default on resetField', () => { + const form = new FormApi({ + defaultValues: { name: 'tony' } as { name: unknown }, + }) + form.mount() + + const field = new FieldApi({ form, name: 'name', defaultValue: null }) + field.mount() + + field.setValue('hawk') + form.resetField('name') + + expect(form.state.values.name).toBeNull() + expect(field.state.value).toBeNull() +}) + it('should accept formId and return it', () => { const form = new FormApi({ defaultValues: { age: 0 },