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
5 changes: 5 additions & 0 deletions .changeset/gentle-jars-share.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 42 additions & 20 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down Expand Up @@ -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<any>[]).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,
}),
)
})
})
}

/**
Expand Down Expand Up @@ -2542,15 +2557,22 @@ export class FormApi<
*/
resetField = <TField extends DeepKeys<TFormData>>(field: TField) => {
this.baseStore.setState((prev) => {
const fieldDefault =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
this.getFieldInfo(field)?.instance?.options.defaultValue
Comment on lines +2561 to +2562
Copy link
Contributor

Choose a reason for hiding this comment

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

This lint error seems odd. Which part does it think does not need an optional chain?

From a surface level, this looks like a wrong type that should be adjusted to reflect (potential) runtime values.

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,
}
})
}
Expand Down
50 changes: 49 additions & 1 deletion packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Reminder to self: Check git blame.

I don't this change is good, but I want to know the context of why it was explicitly listed as unit test.


form.resetField('name')
expect(field.getMeta().isDefaultValue).toBe(true)
Expand All @@ -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: {
Expand Down
40 changes: 40 additions & 0 deletions packages/form-core/tests/FormApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading