Skip to content

Value equality does not handle Temporals #2195

@GiacoCorsiglia

Description

@GiacoCorsiglia

Describe the bug

The evaluate method in form-core, which does deep equality checking, does not work for Temporals. Previously, it always treated two Temporal instances (e.g., two Temporal.PlainDate) as equal, because they have no enumerable properties. With the recent fix #2140, it will now always treat temporal instances as not equal.

The new behavior is clearly better, but it's still incorrect. A date field backed by a Temporal instance will be considered "dirty" even if the user resets it back to the original date.

evaluate already special-cases some built-in types, like Date. With Temporal advancing to Stage 4 and shipping in browsers, it might make sense to special-case them as well.

OTOH, given that people will be using polyfills for a while yet, it may be best to duck-type. Here's one snippet that could be added to evaluate:

const objAToStringTag = objA[Symbol.toStringTag];
if (objAToStringTag.startsWith('Temporal.') && objAToStringTag === objB[Symbol.toStringTag]) {
  // Handle Temporal types (they all have .equals())
  return objA.equals(objB);
}

Your minimal, reproducible example

https://stackblitz.com/edit/vitejs-vite-1zaxj72u?file=src%2Fmain.ts

Steps to reproduce

The simple repro is:

evaluate(Temporal.PlainDate.from('2026-01-01'), Temporal.PlainDate.from('2026-01-01')) === false

I think any form that has a field with a Temporal value (e.g., a Temporal.PlainDate instance) will be subject to dirty-checking issues because of this.

Expected behavior

evaluate(Temporal.PlainDate.from('2026-01-01'), Temporal.PlainDate.from('2026-01-01')) === true

How often does this bug happen?

None

Screenshots or Videos

No response

Platform

This should be universal!

TanStack Form adapter

None

TanStack Form version

1.32.1

TypeScript version

No response

Additional context

Currently we are using this patch (pnpm patch) of @tanstack/form-core. It's a bit more general, and a bit less safe, because it just checks for the .equals() method. But it has the benefit of working for other types, like Decimal.js.

diff --git a/dist/cjs/utils.cjs b/dist/cjs/utils.cjs
--- a/dist/cjs/utils.cjs
+++ b/dist/cjs/utils.cjs
@@ -198,6 +198,10 @@ function evaluate(objA, objB) {
   if (objA instanceof Date && objB instanceof Date) {
     return objA.getTime() === objB.getTime();
   }
+  // Handle Temporal types (they all have .equals())
+  if (typeof objA.equals === "function" && objA.constructor === objB.constructor) {
+    return objA.equals(objB);
+  }
   if (objA instanceof Map && objB instanceof Map) {
     if (objA.size !== objB.size) return false;
     for (const [k, v] of objA) {
diff --git a/dist/esm/utils.js b/dist/esm/utils.js
--- a/dist/esm/utils.js
+++ b/dist/esm/utils.js
@@ -196,6 +196,10 @@ function evaluate(objA, objB) {
   if (objA instanceof Date && objB instanceof Date) {
     return objA.getTime() === objB.getTime();
   }
+  // Handle Temporal types (they all have .equals())
+  if (typeof objA.equals === "function" && objA.constructor === objB.constructor) {
+    return objA.equals(objB);
+  }
   if (objA instanceof Map && objB instanceof Map) {
     if (objA.size !== objB.size) return false;
     for (const [k, v] of objA) {

I wish JavaScript had a convention like for a method that checks value equality, like Symbol.for('valueEquals') or something

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions