From 7ed7642626cc92c966801a4a252457fec3ed87d3 Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Fri, 31 Jul 2026 11:31:52 +0000 Subject: [PATCH] fix(form-core): support value types with a custom equals() method in deep equality --- .changeset/evaluate-custom-equals.md | 5 +++++ packages/form-core/src/utils.ts | 14 ++++++++++++++ packages/form-core/tests/utils.spec.ts | 24 ++++++++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 .changeset/evaluate-custom-equals.md diff --git a/.changeset/evaluate-custom-equals.md b/.changeset/evaluate-custom-equals.md new file mode 100644 index 000000000..d2d6388f0 --- /dev/null +++ b/.changeset/evaluate-custom-equals.md @@ -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). diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index e87296e41..e789136d6 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -514,6 +514,20 @@ export function evaluate(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) diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index 29d9a53a0..f40b760df 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -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)