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-4977-private-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'vee-validate': patch
---

Fix class instances with private properties causing errors in form values (#4977)
3 changes: 1 addition & 2 deletions packages/vee-validate/src/Form.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { klona as deepCopy } from 'klona/full';
import { defineComponent, h, PropType, resolveDynamicComponent, toRef, UnwrapRef, VNode } from 'vue';
import { FormContext, FormErrors, FormMeta, GenericObject, InvalidSubmissionHandler, SubmissionHandler } from './types';
import { useForm } from './useForm';
import { isEvent, isFormSubmitEvent, normalizeChildren } from './utils';
import { deepCopy, isEvent, isFormSubmitEvent, normalizeChildren } from './utils';

export type FormSlotProps = UnwrapRef<
Pick<
Expand Down
2 changes: 1 addition & 1 deletion packages/vee-validate/src/useField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
MaybeRefOrGetter,
unref,
} from 'vue';
import { klona as deepCopy } from 'klona/full';
import { validate as validateValue } from './validate';
import {
GenericValidateFunction,
Expand All @@ -27,6 +26,7 @@ import {
InputType,
} from './types';
import {
deepCopy,
normalizeRules,
extractLocators,
normalizeEventValue,
Expand Down
3 changes: 1 addition & 2 deletions packages/vee-validate/src/useFieldArray.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Ref, unref, ref, onBeforeUnmount, watch, MaybeRefOrGetter, toValue } from 'vue';
import { klona as deepCopy } from 'klona/full';
import { isNullOrUndefined } from '../../shared';
import { FormContextKey } from './symbols';
import { FieldArrayContext, FieldEntry, PrivateFieldArrayContext, PrivateFormContext } from './types';
import { computedDeep, getFromPath, injectWithSelf, warn, isEqual, setInPath } from './utils';
import { deepCopy, computedDeep, getFromPath, injectWithSelf, warn, isEqual, setInPath } from './utils';

export function useFieldArray<TValue = unknown>(arrayPath: MaybeRefOrGetter<string>): FieldArrayContext<TValue> {
const form = injectWithSelf(FormContextKey, undefined) as PrivateFormContext;
Expand Down
2 changes: 1 addition & 1 deletion packages/vee-validate/src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
inject,
} from 'vue';
import { PartialDeep } from 'type-fest';
import { klona as deepCopy } from 'klona/full';
import {
FieldMeta,
SubmissionHandler,
Expand Down Expand Up @@ -50,6 +49,7 @@ import {
ResetFormOpts,
} from './types';
import {
deepCopy,
getFromPath,
keysOf,
setInPath,
Expand Down
6 changes: 5 additions & 1 deletion packages/vee-validate/src/utils/assertions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Locator } from '../types';
import { isCallable, isObject } from '../../../shared';
import { isCallable, isObject, isPlainObject } from '../../../shared';
import { IS_ABSENT } from '../symbols';
import { StandardSchemaV1 } from '@standard-schema/spec';

Expand Down Expand Up @@ -160,6 +160,10 @@ 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();

// Class instances (non-plain objects) should use reference equality
// to avoid errors with private properties (#4977)
if (!isPlainObject(a) || !isPlainObject(b)) return a === b;

// Remove undefined values before object comparison
a = normalizeObject(a);
b = normalizeObject(b);
Expand Down
54 changes: 52 additions & 2 deletions packages/vee-validate/src/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,64 @@ import {
MaybeRefOrGetter,
toValue,
} from 'vue';
import { klona as deepCopy } from 'klona/full';
import { isIndex, isNullOrUndefined, isObject, toNumber } from '../../../shared';
import { klona } from 'klona/full';
import { isIndex, isNullOrUndefined, isObject, isPlainObject, toNumber } from '../../../shared';
import { isContainerValue, isEmptyContainer, isEqual, isNotNestedPath } from './assertions';
import { GenericObject, IssueCollection, MaybePromise } from '../types';
import { FormContextKey, FieldContextKey } from '../symbols';
import { StandardSchemaV1 } from '@standard-schema/spec';
import { getDotPath } from '@standard-schema/utils';

/**
* A wrapper around klona that handles class instances with private properties.
* Non-plain objects (class instances) are returned by reference instead of being deeply cloned,
* which avoids errors when private fields are present (#4977).
*/
export function deepCopy<T>(value: T): T {
if (typeof value !== 'object' || value === null) {
return value;
}

// Non-plain objects with [object Object] tag are class instances.
// They may have private fields that cannot be cloned, so return by reference.
if (isClassInstance(value)) {
return value;
}

// For arrays, recursively process each item to protect nested class instances
if (Array.isArray(value)) {
return value.map(item => deepCopy(item)) as T;
}

// For other known types that klona handles (Date, RegExp, Map, Set, etc.), delegate to klona
const tag = Object.prototype.toString.call(value);
if (tag !== '[object Object]') {
return klona(value);
Comment on lines +32 to +46
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deepCopy function correctly handles class instances with [object Object] toString tag by detecting them via isClassInstance and returning by reference. However, class instances that define a custom Symbol.toStringTag (e.g., get [Symbol.toStringTag]() { return 'MyClass'; }) will have a tag different from [object Object], so isClassInstance returns false for them. They then fall through to klona(value) at line 46, which will still fail if those class instances contain private fields.

While this is an edge case, the inconsistency between deepCopy and isEqual is worth noting: isEqual uses isPlainObject for its guard, which does handle objects with custom Symbol.toStringTag correctly (returns false for them), so isEqual would use reference equality for these instances. But deepCopy would still attempt to clone them via klona, potentially throwing on private fields.

Consider using !isPlainObject(value) as the condition to return by reference in deepCopy as well (for non-array, non-null objects), rather than checking the tag separately, to ensure consistency with how isEqual handles these objects.

Suggested change
// Non-plain objects with [object Object] tag are class instances.
// They may have private fields that cannot be cloned, so return by reference.
if (isClassInstance(value)) {
return value;
}
// For arrays, recursively process each item to protect nested class instances
if (Array.isArray(value)) {
return value.map(item => deepCopy(item)) as T;
}
// For other known types that klona handles (Date, RegExp, Map, Set, etc.), delegate to klona
const tag = Object.prototype.toString.call(value);
if (tag !== '[object Object]') {
return klona(value);
// For arrays, recursively process each item to protect nested class instances
if (Array.isArray(value)) {
return value.map(item => deepCopy(item)) as T;
}
// Non-plain objects (including class instances and other built-ins) are returned by reference.
// This avoids attempting to clone instances that may contain private fields and aligns with isEqual,
// which also treats non-plain objects by reference.
if (!isPlainObject(value)) {
return value;

Copilot uses AI. Check for mistakes.
}

// For plain objects, recursively deep copy each property
const result: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>)) {
result[key] = deepCopy((value as Record<string, unknown>)[key]);
}
return result as T;
}

function isClassInstance(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}

const tag = Object.prototype.toString.call(value);
// Only treat [object Object] as potentially a class instance.
// Other types (Date, RegExp, Map, Set, etc.) are safely handled by klona.
if (tag !== '[object Object]') {
return false;
}

return !isPlainObject(value);
}

export function cleanupNonNestedPath(path: string) {
if (isNotNestedPath(path)) {
return path.replace(/\[|\]/gi, '');
Expand Down
11 changes: 9 additions & 2 deletions packages/vee-validate/src/validate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { resolveRule } from './defineRule';
import { klona as deepCopy } from 'klona/full';
import { isLocator, normalizeRules, keysOf, getFromPath, isStandardSchema, combineStandardIssues } from './utils';
import {
deepCopy,
isLocator,
normalizeRules,
keysOf,
getFromPath,
isStandardSchema,
combineStandardIssues,
} from './utils';
import { getConfig } from './config';
import {
ValidationResult,
Expand Down
124 changes: 123 additions & 1 deletion packages/vee-validate/tests/utils/assertions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isEqual } from 'packages/vee-validate/src/utils';
import { isEqual, deepCopy } from 'packages/vee-validate/src/utils';

describe('assertions', () => {
test('equal objects are equal', () => {
Expand Down Expand Up @@ -228,4 +228,126 @@ describe('assertions', () => {
expect(isEqual(a6, b1)).toBe(false);
expect(isEqual(a6, b2)).toBe(false);
});

test('class instances with private properties do not throw in isEqual (#4977)', () => {
class MyClass {
#secret: string;
public name: string;

constructor(name: string, secret: string) {
this.name = name;
this.#secret = secret;
}

getSecret() {
return this.#secret;
}
}

const a = new MyClass('test', 'secret1');
const b = new MyClass('test', 'secret2');

// Same reference should be equal
expect(isEqual(a, a)).toBe(true);
// Different instances should not be equal (reference equality for class instances)
expect(isEqual(a, b)).toBe(false);
});

test('class instances with private properties nested in plain objects (#4977)', () => {
class DataObj {
#value: number;

constructor(value: number) {
this.#value = value;
}

getValue() {
return this.#value;
}
}

const obj1 = { name: 'test', data: new DataObj(1) };
const obj2 = { name: 'test', data: obj1.data };
const obj3 = { name: 'test', data: new DataObj(1) };

// Same nested reference should be equal
expect(isEqual(obj1, obj2)).toBe(true);
// Different class instance references should not be equal
expect(isEqual(obj1, obj3)).toBe(false);
});

test('deepCopy does not throw on class instances with private properties (#4977)', () => {
class MyClass {
#secret: string;
public name: string;

constructor(name: string, secret: string) {
this.name = name;
this.#secret = secret;
}

getSecret() {
return this.#secret;
}
}

const instance = new MyClass('test', 'secret');
const values = { field1: 'hello', myObj: instance };

// Should not throw
const cloned = deepCopy(values);

// The plain object should be cloned
expect(cloned).not.toBe(values);
expect(cloned.field1).toBe('hello');
// The class instance should be returned by reference, not cloned
expect(cloned.myObj).toBe(instance);
expect(cloned.myObj.getSecret()).toBe('secret');
});

test('deepCopy handles class instances nested in arrays (#4977)', () => {
class Item {
#id: number;

constructor(id: number) {
this.#id = id;
}

getId() {
return this.#id;
}
}

const item1 = new Item(1);
const item2 = new Item(2);
const values = { items: [item1, item2] };

const cloned = deepCopy(values);

expect(cloned).not.toBe(values);
expect(cloned.items).not.toBe(values.items);
// Class instances should be the same references
expect(cloned.items[0]).toBe(item1);
expect(cloned.items[1]).toBe(item2);
expect(cloned.items[0].getId()).toBe(1);
expect(cloned.items[1].getId()).toBe(2);
});

test('deepCopy still clones plain objects and primitive values', () => {
const values = {
name: 'test',
nested: { a: 1, b: 2 },
arr: [1, 2, 3],
date: new Date('2024-01-01'),
};

const cloned = deepCopy(values);

expect(cloned).not.toBe(values);
expect(cloned.name).toBe('test');
expect(cloned.nested).not.toBe(values.nested);
expect(cloned.nested).toEqual({ a: 1, b: 2 });
expect(cloned.arr).not.toBe(values.arr);
expect(cloned.arr).toEqual([1, 2, 3]);
});
});
Loading