From ef5a13c3d2d834018d0d0c16ea0618c7420be067 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 4 Mar 2026 01:49:00 -0500 Subject: [PATCH] fix: handle class instances with private fields Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-4977-private-properties.md | 5 + packages/vee-validate/src/Form.ts | 3 +- packages/vee-validate/src/useField.ts | 2 +- packages/vee-validate/src/useFieldArray.ts | 3 +- packages/vee-validate/src/useForm.ts | 2 +- packages/vee-validate/src/utils/assertions.ts | 6 +- packages/vee-validate/src/utils/common.ts | 54 +++++++- packages/vee-validate/src/validate.ts | 11 +- .../tests/utils/assertions.spec.ts | 124 +++++++++++++++++- 9 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-4977-private-properties.md diff --git a/.changeset/fix-4977-private-properties.md b/.changeset/fix-4977-private-properties.md new file mode 100644 index 000000000..c9b531b99 --- /dev/null +++ b/.changeset/fix-4977-private-properties.md @@ -0,0 +1,5 @@ +--- +'vee-validate': patch +--- + +Fix class instances with private properties causing errors in form values (#4977) diff --git a/packages/vee-validate/src/Form.ts b/packages/vee-validate/src/Form.ts index 97a43177d..7ef5fc727 100644 --- a/packages/vee-validate/src/Form.ts +++ b/packages/vee-validate/src/Form.ts @@ -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< diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts index 423683d1b..6fd60861f 100644 --- a/packages/vee-validate/src/useField.ts +++ b/packages/vee-validate/src/useField.ts @@ -13,7 +13,6 @@ import { MaybeRefOrGetter, unref, } from 'vue'; -import { klona as deepCopy } from 'klona/full'; import { validate as validateValue } from './validate'; import { GenericValidateFunction, @@ -27,6 +26,7 @@ import { InputType, } from './types'; import { + deepCopy, normalizeRules, extractLocators, normalizeEventValue, diff --git a/packages/vee-validate/src/useFieldArray.ts b/packages/vee-validate/src/useFieldArray.ts index af9a6ffc9..83a51767a 100644 --- a/packages/vee-validate/src/useFieldArray.ts +++ b/packages/vee-validate/src/useFieldArray.ts @@ -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(arrayPath: MaybeRefOrGetter): FieldArrayContext { const form = injectWithSelf(FormContextKey, undefined) as PrivateFormContext; diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index f5e7329b8..c7827f73f 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -19,7 +19,6 @@ import { inject, } from 'vue'; import { PartialDeep } from 'type-fest'; -import { klona as deepCopy } from 'klona/full'; import { FieldMeta, SubmissionHandler, @@ -50,6 +49,7 @@ import { ResetFormOpts, } from './types'; import { + deepCopy, getFromPath, keysOf, setInPath, diff --git a/packages/vee-validate/src/utils/assertions.ts b/packages/vee-validate/src/utils/assertions.ts index 9e177abbc..190296a93 100644 --- a/packages/vee-validate/src/utils/assertions.ts +++ b/packages/vee-validate/src/utils/assertions.ts @@ -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'; @@ -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); diff --git a/packages/vee-validate/src/utils/common.ts b/packages/vee-validate/src/utils/common.ts index 3f6889101..e54e1e1ff 100644 --- a/packages/vee-validate/src/utils/common.ts +++ b/packages/vee-validate/src/utils/common.ts @@ -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(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); + } + + // For plain objects, recursively deep copy each property + const result: Record = {}; + for (const key of Object.keys(value as Record)) { + result[key] = deepCopy((value as Record)[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, ''); diff --git a/packages/vee-validate/src/validate.ts b/packages/vee-validate/src/validate.ts index f8acbe8bf..abfbc81da 100644 --- a/packages/vee-validate/src/validate.ts +++ b/packages/vee-validate/src/validate.ts @@ -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, diff --git a/packages/vee-validate/tests/utils/assertions.spec.ts b/packages/vee-validate/tests/utils/assertions.spec.ts index ad6b9122c..b2a0da524 100644 --- a/packages/vee-validate/tests/utils/assertions.spec.ts +++ b/packages/vee-validate/tests/utils/assertions.spec.ts @@ -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', () => { @@ -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]); + }); });