diff --git a/.changeset/gentle-jars-share.md b/.changeset/gentle-jars-share.md new file mode 100644 index 000000000..e4de6dfc1 --- /dev/null +++ b/.changeset/gentle-jars-share.md @@ -0,0 +1,5 @@ +--- +'@tanstack/form-core': minor +--- + +Introduced a **Prioritized Default System** that ensures consistency between field metadata and form reset behavior. This change prioritizes field-level default values over form-level defaults across `isDefaultValue` derivation, `form.reset()`, and `form.resetField()`. This ensures that field metadata accurately reflects the state the form would return to upon reset and prevents `undefined` from being incorrectly treated as a default when a value is explicitly specified. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ff52bbe74..f4b6f4d63 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1091,16 +1091,12 @@ export class FormApi< // As primitives, we don't need to aggressively persist the same referential value for performance reasons const isFieldValid = !isNonEmptyArray(fieldErrors) const isFieldPristine = !currBaseMeta.isDirty - const isDefaultValue = - evaluate( - curFieldVal, + const isDefaultValue = evaluate( + curFieldVal, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.getFieldInfo(fieldName)?.instance?.options.defaultValue ?? getBy(this.options.defaultValues, fieldName), - ) || - evaluate( - curFieldVal, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - this.getFieldInfo(fieldName)?.instance?.options.defaultValue, - ) + ) if ( prevFieldInfo && @@ -1507,16 +1503,35 @@ export class FormApi< } } - this.baseStore.setState(() => - getDefaultFormState({ + this.baseStore.setState(() => { + let nextValues = + values ?? + this.options.defaultValues ?? + this.options.defaultState?.values + + if (!values) { + ;(Object.values(this.fieldInfo) as FieldInfo[]).forEach( + (fieldInfo) => { + if ( + fieldInfo.instance && + fieldInfo.instance.options.defaultValue !== undefined + ) { + nextValues = setBy( + nextValues, + fieldInfo.instance.name, + fieldInfo.instance.options.defaultValue, + ) + } + }, + ) + } + + return getDefaultFormState({ ...(this.options.defaultState as any), - values: - values ?? - this.options.defaultValues ?? - this.options.defaultState?.values, + values: nextValues, fieldMetaBase, - }), - ) + }) + }) } /** @@ -2542,15 +2557,22 @@ export class FormApi< */ resetField = >(field: TField) => { this.baseStore.setState((prev) => { + const fieldDefault = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + this.getFieldInfo(field)?.instance?.options.defaultValue + const formDefault = getBy(this.options.defaultValues, field) + const targetValue = fieldDefault ?? formDefault + return { ...prev, fieldMetaBase: { ...prev.fieldMetaBase, [field]: defaultFieldMeta, }, - values: this.options.defaultValues - ? setBy(prev.values, field, getBy(this.options.defaultValues, field)) - : prev.values, + values: + targetValue !== undefined + ? setBy(prev.values, field, targetValue) + : prev.values, } }) } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index abca29868..a4f2714ea 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -120,7 +120,7 @@ describe('field api', () => { expect(field.getMeta().isDefaultValue).toBe(false) field.setValue('test') - expect(field.getMeta().isDefaultValue).toBe(true) + expect(field.getMeta().isDefaultValue).toBe(false) form.resetField('name') expect(field.getMeta().isDefaultValue).toBe(true) @@ -130,6 +130,54 @@ describe('field api', () => { expect(field.getMeta().isDefaultValue).toBe(true) }) + it('should be false when value is undefined and a default value is specified in form-level only', () => { + const form = new FormApi({ + defaultValues: { + name: 'foo', + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + + expect(field.getMeta().isDefaultValue).toBe(true) + + // Set to undefined - should be false because 'foo' is the default + field.setValue(undefined as any) + expect(field.getMeta().isDefaultValue).toBe(false) + }) + + it('should handle falsy values correctly in isDefaultValue', () => { + const form = new FormApi({ + defaultValues: { + count: 0, + active: false, + text: '', + }, + }) + form.mount() + + const countField = new FieldApi({ form, name: 'count' }) + const activeField = new FieldApi({ form, name: 'active' }) + const textField = new FieldApi({ form, name: 'text' }) + countField.mount() + activeField.mount() + textField.mount() + + expect(countField.getMeta().isDefaultValue).toBe(true) + expect(activeField.getMeta().isDefaultValue).toBe(true) + expect(textField.getMeta().isDefaultValue).toBe(true) + + countField.setValue(1) + expect(countField.getMeta().isDefaultValue).toBe(false) + countField.setValue(0) + expect(countField.getMeta().isDefaultValue).toBe(true) + }) + it('should update the fields meta isDefaultValue with arrays - simple', () => { const form = new FormApi({ defaultValues: { diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index dd418d727..8438c4705 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -169,6 +169,46 @@ describe('form api', () => { expect(form.state.values).toEqual({ name: 'initial' }) }) + it('should prioritize field-level defaultValue over form-level defaultValues on reset', () => { + const form = new FormApi({ + defaultValues: { + name: 'form-default', + age: 25, + }, + }) + form.mount() + + const nameField = new FieldApi({ + form, + name: 'name', + defaultValue: 'field-default', + }) + nameField.mount() + + const ageField = new FieldApi({ + form, + name: 'age', + }) + ageField.mount() + + // Change values + nameField.setValue('changed-name') + ageField.setValue(30) + + expect(form.state.values).toEqual({ + name: 'changed-name', + age: 30, + }) + + // Reset without arguments - field-level defaultValue should take priority + form.reset() + + expect(form.state.values).toEqual({ + name: 'field-default', // field's defaultValue, not form's + age: 25, // form's defaultValues (no field-level default) + }) + }) + it('should handle multiple fields with mixed mount states', () => { const form = new FormApi({ defaultValues: {