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/fix-evaluate-getter-only-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

`evaluate()` incorrectly treated distinct non-plain objects with no own enumerable keys (Temporal types, RegExp, getter-only class instances) as equal because the key-iteration loop vacuously succeeded. A guard now returns `false` for such objects, falling back to referential inequality.
14 changes: 14 additions & 0 deletions packages/form-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,20 @@ export function evaluate<T>(objA: T, objB: T) {
return false
}

// Two distinct non-plain, non-array objects with no own enumerable keys cannot
// be compared by key iteration — the loop below would vacuously succeed and
// treat them as equal regardless of their internal state. This covers Temporal
// types, RegExp, and any class that exposes values only through getters.
if (
keysA.length === 0 &&
!Array.isArray(objA) &&
!Array.isArray(objB) &&
(Object.getPrototypeOf(objA) !== Object.prototype ||
Object.getPrototypeOf(objB) !== Object.prototype)
) {
return false
}

for (const key of keysA) {
// performs recursive search down the object tree

Expand Down
35 changes: 35 additions & 0 deletions packages/form-core/tests/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,41 @@ describe('evaluate', () => {
const setB = new Set([1, 2, 4])
expect(evaluate(setA, setB)).toEqual(false)
})

it('should treat distinct non-plain objects with no own enumerable keys as not equal', () => {
// Simulates Temporal.Duration, RegExp, or any class that exposes state only
// through getters. Object.keys() returns [] for these, so without this guard
// the key-iteration loop would vacuously succeed and return true.
class Duration {
#ms: number
constructor(ms: number) {
this.#ms = ms
}
get milliseconds() {
return this.#ms
}
}

const d1 = new Duration(1000)
const d2 = new Duration(2000)
const d3 = new Duration(1000)
const dSame = d1

expect(evaluate(d1, dSame)).toEqual(true) // same reference
expect(evaluate(d1, d2)).toEqual(false) // different instances, different state
expect(evaluate(d1, d3)).toEqual(false) // different instances, same state — still false
})

it('should still treat two plain empty objects as equal', () => {
expect(evaluate({}, {})).toEqual(true)
expect(evaluate({ a: {} }, { a: {} })).toEqual(true)
})

it('should treat a non-plain object against a plain empty object as not equal', () => {
class Empty {}
expect(evaluate(new Empty(), {})).toEqual(false)
expect(evaluate({}, new Empty())).toEqual(false)
})
})

describe('concatenatePaths', () => {
Expand Down