Repository: logaretm/vee-validate (ts)

Issue:
Dirty check doesn't work when resetForm values contains optional field
### What happened?

Consider a use case where user types something in the form and then moves to some other page but forgets to save. In this case it would be nice to show a confirmation dialog to the user to verify if they want to leave the page without saving.

In order to do this, I'm using `meta` object `dirty` value to check if at least one field's value has been changed. When the user lands on the page then initial data is fetched from the backend server. I'm using `resetForm` to update the values and reset meta state just like it's described in here https://vee-validate.logaretm.com/v4/guide/composition-api/handling-forms/#setting-initial-values-asynchronously. This works for most fields but sometimes there's no "dirty check" for some other fields.

### Reproduction steps

Open stackblitz demo link

1. Type something in the 3rd field. - "Dirty check" works as expected.
2. Press `Reset` button. - This mimics API request, sets values and resets meta state.
3. Type something in the 3rd field. - "Dirty check" doesn't work at all.

I've observed that when `field1` is `undefined` then the last field's "dirty check" doesn't work. When `field1` is something else than `undefined` then "dirty check" works for all fields. I've also observed that when the order of `formSchema`'s `field2` and `field3` is changed, then `field2` dirty check doesn't work anymore but `field3` dirty check works.

### Version

Vue.js 3.x and vee-validate 4.x

### What browsers are you seeing the problem on?

- [X] Firefox
- [X] Chrome
- [ ] Safari
- [ ] Microsoft Edge

### Relevant log output

_No response_

### Demo link

https://stackblitz.com/edit/vitejs-vite-id7xmdvf?file=src%2FApp.vue

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](CONDUCT.md)

Pull request description:
fix: normalize objects before equality checks
🔎 __Overview__

<!-- Explain the why behind adding this PR, here is a couple of examples -->
<!--
  This PR {adds/fixes/improves} the {feature/bug/something}.
  This PR changes the {locale} messages style because {reason}
-->
This PR fixes the custom logic inside the [`isEqual`](https://github.com/logaretm/vee-validate/blob/main/packages/vee-validate/src/utils/assertions.ts#L166-L180) function, which is used to determine whether a form is dirty.

The previous logic to compare 2 objects for equality while "ignoring" undefined values was faulty, as it would exclude from the comparison the last `n` keys of the object, where `n` is the number of undefined values found in object `a` (example 1 in the snippet below).

For cases where the second argument (`b`) would have missing keys, the logic would also fail, as it would use keys with undefined values from `a` to check if they were also present in `b` (example 2 in the snippet below).

The fix in this PR uses the original logic from [fast-deep-equal](https://github.com/epoberezkin/fast-deep-equal) to check for object equality, but normalizes both objects before the comparison, i.e., removes keys with undefined values.

🤓 __Code snippets/examples (if applicable)__

```js
// Example 1
isEqual({
  field1: undefined,
  field2: 'value2',
  field3: 'value3'
}, {
  field1: undefined,
  field2: 'value2',
  field3: 'DIFF' // --> this field is ignored
}); // returns true, but should be false

// Example 2
isEqual({
  field1: undefined, // --> this key will be used to check if exists in `b`
  field2: 'value2',
  field3: 'value3'
}, {
  field2: 'value2',
  field3: 'value3'
}); // returns false, but should be true
```

✔ __Issues affected__
* closes #4966 
<!-- list of issues formatted like this
closes #{issue id}
 -->

🙏 __Special thanks__
Thanks to @Jak-Ch-ll for providing valuable insights and helping fix this issue!


 


Patch:
diff --git a/.changeset/tiny-lamps-kneel.md b/.changeset/tiny-lamps-kneel.md
new file mode 100644
index 00000000..5895e7f5
--- /dev/null
+++ b/.changeset/tiny-lamps-kneel.md
@@ -0,0 +1,5 @@
+---
+"vee-validate": patch
+---
+
+fix: normalize objects before equality checks closes #5006
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 5923b028..255d76fd 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -6,7 +6,7 @@ jobs:
   # Below is the definition of your job to build and test your app, you can rename and customize it as you want.
   test:
     docker:
-      - image: cimg/node:22.10
+      - image: cimg/node:22.11
     steps:
       # Checkout the code as the first step.
       - checkout
@@ -14,11 +14,18 @@ jobs:
           name: Restore pnpm Package Cache
           keys:
             - pnpm-packages-{{ checksum "pnpm-lock.yaml" }}
+      - run:
+          name: Use latest Corepack
+          command: |
+            echo "Before: corepack version => $(corepack --version || echo 'not installed')"
+            sudo npm install -g corepack@latest
+            echo "After : corepack version => $(corepack --version)"
       - run:
           name: Install pnpm package manager
           command: |
             corepack enable --install-directory ~/bin
             corepack prepare pnpm@latest-9 --activate
+            pnpm --version
             pnpm config set store-dir .pnpm-store
       - run:
           name: Install dependencies
diff --git a/packages/vee-validate/src/utils/assertions.ts b/packages/vee-validate/src/utils/assertions.ts
index 5d1a158e..658ad9ba 100644
--- a/packages/vee-validate/src/utils/assertions.ts
+++ b/packages/vee-validate/src/utils/assertions.ts
@@ -163,19 +163,19 @@ export function isEqual(a: any, b: any) {
     if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
     if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
 
-    keys = Object.keys(a);
-    length = keys.length - countUndefinedValues(a, keys);
+    // Remove undefined values before object comparison
+    a = normalizeObject(a);
+    b = normalizeObject(b);
 
-    if (length !== Object.keys(b).length - countUndefinedValues(b, Object.keys(b))) return false;
+    keys = Object.keys(a);
+    length = keys.length;
+    if (length !== Object.keys(b).length) return false;
 
-    for (i = length; i-- !== 0; ) {
-      if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false;
-    }
+    for (i = length; i-- !== 0; ) if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false;
 
     for (i = length; i-- !== 0; ) {
       // eslint-disable-next-line no-var
       var key = keys[i];
-
       if (!isEqual(a[key], b[key])) return false;
     }
 
@@ -183,19 +183,16 @@ export function isEqual(a: any, b: any) {
   }
 
   // true if both NaN, false otherwise
-
   return a !== a && b !== b;
 }
 
-function countUndefinedValues(a: any, keys: string[]) {
-  let result = 0;
-  for (let i = keys.length; i-- !== 0; ) {
-    // eslint-disable-next-line no-var
-    var key = keys[i];
-
-    if (a[key] === undefined) result++;
-  }
-  return result;
+/**
+ * Returns a new object where keys with an `undefined` value are removed.
+ *
+ * @param a object to normalize
+ */
+function normalizeObject(a: Record<PropertyKey, unknown>) {
+  return Object.fromEntries(Object.entries(a).filter(([, value]) => value !== undefined));
 }
 
 export function isFile(a: unknown): a is File {


Test patch:
diff --git a/packages/vee-validate/tests/utils/assertions.spec.ts b/packages/vee-validate/tests/utils/assertions.spec.ts
new file mode 100644
index 00000000..ad6b9122
--- /dev/null
+++ b/packages/vee-validate/tests/utils/assertions.spec.ts
@@ -0,0 +1,231 @@
+import { isEqual } from 'packages/vee-validate/src/utils';
+
+describe('assertions', () => {
+  test('equal objects are equal', () => {
+    const a1 = { field1: undefined, field2: 'value2', field3: 'value3' };
+    const a2 = { field1: undefined, field3: 'value3', field2: 'value2' };
+    const a3 = { field2: 'value2', field3: 'value3', field1: undefined };
+    const a4 = { field2: 'value2', field1: undefined, field3: 'value3' };
+    const a5 = { field3: 'value3', field1: undefined, field2: 'value2' };
+    const a6 = { field3: 'value3', field2: 'value2', field1: undefined };
+
+    const b1 = { field1: undefined, field2: 'value2', field3: 'value3' };
+    const b2 = { field1: undefined, field3: 'value3', field2: 'value2' };
+    const b3 = { field2: 'value2', field3: 'value3', field1: undefined };
+    const b4 = { field2: 'value2', field1: undefined, field3: 'value3' };
+    const b5 = { field3: 'value3', field1: undefined, field2: 'value2' };
+    const b6 = { field3: 'value3', field2: 'value2', field1: undefined };
+
+    expect(isEqual(a1, b1)).toBe(true);
+    expect(isEqual(a1, b2)).toBe(true);
+    expect(isEqual(a1, b3)).toBe(true);
+    expect(isEqual(a1, b4)).toBe(true);
+    expect(isEqual(a1, b5)).toBe(true);
+    expect(isEqual(a1, b6)).toBe(true);
+
+    expect(isEqual(a2, b1)).toBe(true);
+    expect(isEqual(a2, b2)).toBe(true);
+    expect(isEqual(a2, b3)).toBe(true);
+    expect(isEqual(a2, b4)).toBe(true);
+    expect(isEqual(a2, b5)).toBe(true);
+    expect(isEqual(a2, b6)).toBe(true);
+
+    expect(isEqual(a3, b1)).toBe(true);
+    expect(isEqual(a3, b2)).toBe(true);
+    expect(isEqual(a3, b3)).toBe(true);
+    expect(isEqual(a3, b4)).toBe(true);
+    expect(isEqual(a3, b5)).toBe(true);
+    expect(isEqual(a3, b6)).toBe(true);
+
+    expect(isEqual(a4, b1)).toBe(true);
+    expect(isEqual(a4, b2)).toBe(true);
+    expect(isEqual(a4, b3)).toBe(true);
+    expect(isEqual(a4, b4)).toBe(true);
+    expect(isEqual(a4, b5)).toBe(true);
+    expect(isEqual(a4, b6)).toBe(true);
+
+    expect(isEqual(a5, b1)).toBe(true);
+    expect(isEqual(a5, b2)).toBe(true);
+    expect(isEqual(a5, b3)).toBe(true);
+    expect(isEqual(a5, b4)).toBe(true);
+    expect(isEqual(a5, b5)).toBe(true);
+    expect(isEqual(a5, b6)).toBe(true);
+
+    expect(isEqual(a6, b1)).toBe(true);
+    expect(isEqual(a6, b2)).toBe(true);
+    expect(isEqual(a6, b3)).toBe(true);
+    expect(isEqual(a6, b4)).toBe(true);
+    expect(isEqual(a6, b5)).toBe(true);
+    expect(isEqual(a6, b6)).toBe(true);
+  });
+
+  test('equal objects where A has missing keys are equal', () => {
+    const a1 = { field2: 'value2', field3: 'value3' };
+    const a2 = { field3: 'value3', field2: 'value2' };
+
+    const b1 = { field1: undefined, field2: 'value2', field3: 'value3' };
+    const b2 = { field1: undefined, field3: 'value3', field2: 'value2' };
+    const b3 = { field2: 'value2', field3: 'value3', field1: undefined };
+    const b4 = { field2: 'value2', field1: undefined, field3: 'value3' };
+    const b5 = { field3: 'value3', field1: undefined, field2: 'value2' };
+    const b6 = { field3: 'value3', field2: 'value2', field1: undefined };
+
+    expect(isEqual(a1, b1)).toBe(true);
+    expect(isEqual(a1, b2)).toBe(true);
+    expect(isEqual(a1, b3)).toBe(true);
+    expect(isEqual(a1, b4)).toBe(true);
+    expect(isEqual(a1, b5)).toBe(true);
+    expect(isEqual(a1, b6)).toBe(true);
+
+    expect(isEqual(a2, b1)).toBe(true);
+    expect(isEqual(a2, b2)).toBe(true);
+    expect(isEqual(a2, b3)).toBe(true);
+    expect(isEqual(a2, b4)).toBe(true);
+    expect(isEqual(a2, b5)).toBe(true);
+    expect(isEqual(a2, b6)).toBe(true);
+  });
+
+  test('equal objects where B has missing keys are equal', () => {
+    const a1 = { field1: undefined, field2: 'value2', field3: 'value3' };
+    const a2 = { field1: undefined, field3: 'value3', field2: 'value2' };
+    const a3 = { field2: 'value2', field3: 'value3', field1: undefined };
+    const a4 = { field2: 'value2', field1: undefined, field3: 'value3' };
+    const a5 = { field3: 'value3', field1: undefined, field2: 'value2' };
+    const a6 = { field3: 'value3', field2: 'value2', field1: undefined };
+
+    const b1 = { field2: 'value2', field3: 'value3' };
+    const b2 = { field3: 'value3', field2: 'value2' };
+
+    expect(isEqual(a1, b1)).toBe(true);
+    expect(isEqual(a1, b2)).toBe(true);
+
+    expect(isEqual(a2, b1)).toBe(true);
+    expect(isEqual(a2, b2)).toBe(true);
+
+    expect(isEqual(a3, b1)).toBe(true);
+    expect(isEqual(a3, b2)).toBe(true);
+
+    expect(isEqual(a4, b1)).toBe(true);
+    expect(isEqual(a4, b2)).toBe(true);
+
+    expect(isEqual(a5, b1)).toBe(true);
+    expect(isEqual(a5, b2)).toBe(true);
+
+    expect(isEqual(a6, b1)).toBe(true);
+    expect(isEqual(a6, b2)).toBe(true);
+  });
+
+  test('different objects are not equal', () => {
+    const a1 = { field1: undefined, field2: 'value2', field3: 'value3' };
+    const a2 = { field1: undefined, field3: 'value3', field2: 'value2' };
+    const a3 = { field2: 'value2', field3: 'value3', field1: undefined };
+    const a4 = { field2: 'value2', field1: undefined, field3: 'value3' };
+    const a5 = { field3: 'value3', field1: undefined, field2: 'value2' };
+    const a6 = { field3: 'value3', field2: 'value2', field1: undefined };
+
+    const b1 = { field1: undefined, field2: 'value2', field3: 'DIFF' };
+    const b2 = { field1: undefined, field3: 'DIFF', field2: 'value2' };
+    const b3 = { field2: 'value2', field3: 'DIFF', field1: undefined };
+    const b4 = { field2: 'value2', field1: undefined, field3: 'DIFF' };
+    const b5 = { field3: 'DIFF', field1: undefined, field2: 'value2' };
+    const b6 = { field3: 'DIFF', field2: 'value2', field1: undefined };
+
+    expect(isEqual(a1, b1)).toBe(false);
+    expect(isEqual(a1, b2)).toBe(false);
+    expect(isEqual(a1, b3)).toBe(false);
+    expect(isEqual(a1, b4)).toBe(false);
+    expect(isEqual(a1, b5)).toBe(false);
+    expect(isEqual(a1, b6)).toBe(false);
+
+    expect(isEqual(a2, b1)).toBe(false);
+    expect(isEqual(a2, b2)).toBe(false);
+    expect(isEqual(a2, b3)).toBe(false);
+    expect(isEqual(a2, b4)).toBe(false);
+    expect(isEqual(a2, b5)).toBe(false);
+    expect(isEqual(a2, b6)).toBe(false);
+
+    expect(isEqual(a3, b1)).toBe(false);
+    expect(isEqual(a3, b2)).toBe(false);
+    expect(isEqual(a3, b3)).toBe(false);
+    expect(isEqual(a3, b4)).toBe(false);
+    expect(isEqual(a3, b5)).toBe(false);
+    expect(isEqual(a3, b6)).toBe(false);
+
+    expect(isEqual(a4, b1)).toBe(false);
+    expect(isEqual(a4, b2)).toBe(false);
+    expect(isEqual(a4, b3)).toBe(false);
+    expect(isEqual(a4, b4)).toBe(false);
+    expect(isEqual(a4, b5)).toBe(false);
+    expect(isEqual(a4, b6)).toBe(false);
+
+    expect(isEqual(a5, b1)).toBe(false);
+    expect(isEqual(a5, b2)).toBe(false);
+    expect(isEqual(a5, b3)).toBe(false);
+    expect(isEqual(a5, b4)).toBe(false);
+    expect(isEqual(a5, b5)).toBe(false);
+    expect(isEqual(a5, b6)).toBe(false);
+
+    expect(isEqual(a6, b1)).toBe(false);
+    expect(isEqual(a6, b2)).toBe(false);
+    expect(isEqual(a6, b3)).toBe(false);
+    expect(isEqual(a6, b4)).toBe(false);
+    expect(isEqual(a6, b5)).toBe(false);
+    expect(isEqual(a6, b6)).toBe(false);
+  });
+
+  test('different objects where A has missing keys are not equal', () => {
+    const a1 = { field2: 'value2', field3: 'value3' };
+    const a2 = { field3: 'value3', field2: 'value2' };
+
+    const b1 = { field1: undefined, field2: 'value2', field3: 'DIFF' };
+    const b2 = { field1: undefined, field3: 'DIFF', field2: 'value2' };
+    const b3 = { field2: 'value2', field3: 'DIFF', field1: undefined };
+    const b4 = { field2: 'value2', field1: undefined, field3: 'DIFF' };
+    const b5 = { field3: 'DIFF', field1: undefined, field2: 'value2' };
+    const b6 = { field3: 'DIFF', field2: 'value2', field1: undefined };
+
+    expect(isEqual(a1, b1)).toBe(false);
+    expect(isEqual(a1, b2)).toBe(false);
+    expect(isEqual(a1, b3)).toBe(false);
+    expect(isEqual(a1, b4)).toBe(false);
+    expect(isEqual(a1, b5)).toBe(false);
+    expect(isEqual(a1, b6)).toBe(false);
+
+    expect(isEqual(a2, b1)).toBe(false);
+    expect(isEqual(a2, b2)).toBe(false);
+    expect(isEqual(a2, b3)).toBe(false);
+    expect(isEqual(a2, b4)).toBe(false);
+    expect(isEqual(a2, b5)).toBe(false);
+    expect(isEqual(a2, b6)).toBe(false);
+  });
+
+  test('different objects where B has missing keys not equal', () => {
+    const a1 = { field1: undefined, field2: 'value2', field3: 'value3' };
+    const a2 = { field1: undefined, field3: 'value3', field2: 'value2' };
+    const a3 = { field2: 'value2', field3: 'value3', field1: undefined };
+    const a4 = { field2: 'value2', field1: undefined, field3: 'value3' };
+    const a5 = { field3: 'value3', field1: undefined, field2: 'value2' };
+    const a6 = { field3: 'value3', field2: 'value2', field1: undefined };
+
+    const b1 = { field2: 'value2', field3: 'DIFF' };
+    const b2 = { field3: 'DIFF', field2: 'value2' };
+
+    expect(isEqual(a1, b1)).toBe(false);
+    expect(isEqual(a1, b2)).toBe(false);
+
+    expect(isEqual(a2, b1)).toBe(false);
+    expect(isEqual(a2, b2)).toBe(false);
+
+    expect(isEqual(a3, b1)).toBe(false);
+    expect(isEqual(a3, b2)).toBe(false);
+
+    expect(isEqual(a4, b1)).toBe(false);
+    expect(isEqual(a4, b2)).toBe(false);
+
+    expect(isEqual(a5, b1)).toBe(false);
+    expect(isEqual(a5, b2)).toBe(false);
+
+    expect(isEqual(a6, b1)).toBe(false);
+    expect(isEqual(a6, b2)).toBe(false);
+  });
+});