Skip to content
Draft
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/evaluate-custom-equals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

Support value types that expose a custom `equals()` method (Temporal types, Luxon `DateTime`, etc.) in the deep-equality check. Previously two equal instances were reported as unequal because they have no own enumerable keys, so a field backed by such a value stayed dirty even after being reset to its original value (#2195).
14 changes: 14 additions & 0 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,20 @@ export function evaluate<T>(objA: T, objB: T) {
return true
}

// Delegate to a custom value-equality method (Temporal types, Luxon DateTime,
// Immutable collections, etc.) when both operands share a constructor that
// exposes one. This must run before the no-enumerable-keys guard below, which
// would otherwise treat every such value as unequal since they expose their
// state through getters/private fields rather than own enumerable keys (#2195).
if (
objA.constructor === objB.constructor &&
typeof (objA as { equals?: unknown }).equals === 'function'
) {
return (objA as unknown as { equals: (other: unknown) => boolean }).equals(
objB,
)
}

const keysA = Object.keys(objA)
const keysB = Object.keys(objB)

Expand Down
24 changes: 24 additions & 0 deletions packages/form-core/tests/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,30 @@ describe('evaluate', () => {
expect(undefinedFalse).toEqual(false)
})

it('uses a custom equals() method for value types like Temporal (#2195)', () => {
// Value types (Temporal.PlainDate, Luxon DateTime, ...) expose their state
// through private fields/getters, so they have no own enumerable keys and a
// non-plain prototype. Without delegating to `equals()`, two equal instances
// are reported as unequal, marking a reset field as permanently dirty.
class PlainDate {
#iso: string
constructor(iso: string) {
this.#iso = iso
}
equals(other: PlainDate) {
return this.#iso === other.#iso
}
}

expect(Object.keys(new PlainDate('2026-07-31'))).toEqual([])
expect(
evaluate(new PlainDate('2026-07-31'), new PlainDate('2026-07-31')),
).toBe(true)
expect(
evaluate(new PlainDate('2026-07-31'), new PlainDate('2020-01-01')),
).toBe(false)
})

it('should test equality between arrays', () => {
const arrayTrue = evaluate([], [])
expect(arrayTrue).toEqual(true)
Expand Down