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/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).
18 changes: 18 additions & 0 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,24 @@ 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).
// Both operands must expose the method: plain object literals also share the
// `Object` constructor, so a one-sided `equals` (e.g. `{ equals: () => true }`
// vs `{}`) must fall through to structural comparison instead of delegating.
if (
objA.constructor === objB.constructor &&
typeof (objA as { equals?: unknown }).equals === 'function' &&
typeof (objB as { equals?: unknown }).equals === 'function'
) {
return (objA as unknown as { equals: (other: unknown) => boolean }).equals(
objB,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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

Expand Down
37 changes: 37 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,43 @@ 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('does not delegate to equals() when only one operand exposes it', () => {
// Plain object literals share the `Object` constructor, so a one-sided
// `equals` property must not hijack the comparison — otherwise
// `evaluate({ equals: () => true }, {})` would report structurally
// different objects as equal.
expect(evaluate({ equals: () => true } as unknown, {} as unknown)).toBe(
false,
)
expect(evaluate({} as unknown, { equals: () => true } as unknown)).toBe(
false,
)
})

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