From 7ed7642626cc92c966801a4a252457fec3ed87d3 Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Fri, 31 Jul 2026 11:31:52 +0000 Subject: [PATCH 1/2] 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) From c424ccaea39bd4b1c72057bea351e5f268c45154 Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Mon, 3 Aug 2026 12:49:26 +0000 Subject: [PATCH 2/2] fix: require both operands to expose equals() before delegating A one-sided equals (e.g. { equals: () => true } vs {}) shares the Object constructor, so delegating on objA alone let a stray equals property hijack structural comparison. Per review. --- packages/form-core/src/utils.ts | 6 +++++- packages/form-core/tests/utils.spec.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts index e789136d6..6f10e2aad 100644 --- a/packages/form-core/src/utils.ts +++ b/packages/form-core/src/utils.ts @@ -519,9 +519,13 @@ export function evaluate(objA: T, objB: T) { // 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 (objA as { equals?: unknown }).equals === 'function' && + typeof (objB as { equals?: unknown }).equals === 'function' ) { return (objA as unknown as { equals: (other: unknown) => boolean }).equals( objB, diff --git a/packages/form-core/tests/utils.spec.ts b/packages/form-core/tests/utils.spec.ts index f40b760df..d69a6b7ba 100644 --- a/packages/form-core/tests/utils.spec.ts +++ b/packages/form-core/tests/utils.spec.ts @@ -701,6 +701,19 @@ describe('evaluate', () => { ).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)