Skip to content

Commit ef5a13c

Browse files
logaretmclaude
andcommitted
fix: handle class instances with private fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d8cc52 commit ef5a13c

9 files changed

Lines changed: 198 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'vee-validate': patch
3+
---
4+
5+
Fix class instances with private properties causing errors in form values (#4977)

packages/vee-validate/src/Form.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { klona as deepCopy } from 'klona/full';
21
import { defineComponent, h, PropType, resolveDynamicComponent, toRef, UnwrapRef, VNode } from 'vue';
32
import { FormContext, FormErrors, FormMeta, GenericObject, InvalidSubmissionHandler, SubmissionHandler } from './types';
43
import { useForm } from './useForm';
5-
import { isEvent, isFormSubmitEvent, normalizeChildren } from './utils';
4+
import { deepCopy, isEvent, isFormSubmitEvent, normalizeChildren } from './utils';
65

76
export type FormSlotProps = UnwrapRef<
87
Pick<

packages/vee-validate/src/useField.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
MaybeRefOrGetter,
1414
unref,
1515
} from 'vue';
16-
import { klona as deepCopy } from 'klona/full';
1716
import { validate as validateValue } from './validate';
1817
import {
1918
GenericValidateFunction,
@@ -27,6 +26,7 @@ import {
2726
InputType,
2827
} from './types';
2928
import {
29+
deepCopy,
3030
normalizeRules,
3131
extractLocators,
3232
normalizeEventValue,

packages/vee-validate/src/useFieldArray.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { Ref, unref, ref, onBeforeUnmount, watch, MaybeRefOrGetter, toValue } from 'vue';
2-
import { klona as deepCopy } from 'klona/full';
32
import { isNullOrUndefined } from '../../shared';
43
import { FormContextKey } from './symbols';
54
import { FieldArrayContext, FieldEntry, PrivateFieldArrayContext, PrivateFormContext } from './types';
6-
import { computedDeep, getFromPath, injectWithSelf, warn, isEqual, setInPath } from './utils';
5+
import { deepCopy, computedDeep, getFromPath, injectWithSelf, warn, isEqual, setInPath } from './utils';
76

87
export function useFieldArray<TValue = unknown>(arrayPath: MaybeRefOrGetter<string>): FieldArrayContext<TValue> {
98
const form = injectWithSelf(FormContextKey, undefined) as PrivateFormContext;

packages/vee-validate/src/useForm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
inject,
2020
} from 'vue';
2121
import { PartialDeep } from 'type-fest';
22-
import { klona as deepCopy } from 'klona/full';
2322
import {
2423
FieldMeta,
2524
SubmissionHandler,
@@ -50,6 +49,7 @@ import {
5049
ResetFormOpts,
5150
} from './types';
5251
import {
52+
deepCopy,
5353
getFromPath,
5454
keysOf,
5555
setInPath,

packages/vee-validate/src/utils/assertions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Locator } from '../types';
2-
import { isCallable, isObject } from '../../../shared';
2+
import { isCallable, isObject, isPlainObject } from '../../../shared';
33
import { IS_ABSENT } from '../symbols';
44
import { StandardSchemaV1 } from '@standard-schema/spec';
55

@@ -160,6 +160,10 @@ export function isEqual(a: any, b: any) {
160160
if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf();
161161
if (a.toString !== Object.prototype.toString) return a.toString() === b.toString();
162162

163+
// Class instances (non-plain objects) should use reference equality
164+
// to avoid errors with private properties (#4977)
165+
if (!isPlainObject(a) || !isPlainObject(b)) return a === b;
166+
163167
// Remove undefined values before object comparison
164168
a = normalizeObject(a);
165169
b = normalizeObject(b);

packages/vee-validate/src/utils/common.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,64 @@ import {
1111
MaybeRefOrGetter,
1212
toValue,
1313
} from 'vue';
14-
import { klona as deepCopy } from 'klona/full';
15-
import { isIndex, isNullOrUndefined, isObject, toNumber } from '../../../shared';
14+
import { klona } from 'klona/full';
15+
import { isIndex, isNullOrUndefined, isObject, isPlainObject, toNumber } from '../../../shared';
1616
import { isContainerValue, isEmptyContainer, isEqual, isNotNestedPath } from './assertions';
1717
import { GenericObject, IssueCollection, MaybePromise } from '../types';
1818
import { FormContextKey, FieldContextKey } from '../symbols';
1919
import { StandardSchemaV1 } from '@standard-schema/spec';
2020
import { getDotPath } from '@standard-schema/utils';
2121

22+
/**
23+
* A wrapper around klona that handles class instances with private properties.
24+
* Non-plain objects (class instances) are returned by reference instead of being deeply cloned,
25+
* which avoids errors when private fields are present (#4977).
26+
*/
27+
export function deepCopy<T>(value: T): T {
28+
if (typeof value !== 'object' || value === null) {
29+
return value;
30+
}
31+
32+
// Non-plain objects with [object Object] tag are class instances.
33+
// They may have private fields that cannot be cloned, so return by reference.
34+
if (isClassInstance(value)) {
35+
return value;
36+
}
37+
38+
// For arrays, recursively process each item to protect nested class instances
39+
if (Array.isArray(value)) {
40+
return value.map(item => deepCopy(item)) as T;
41+
}
42+
43+
// For other known types that klona handles (Date, RegExp, Map, Set, etc.), delegate to klona
44+
const tag = Object.prototype.toString.call(value);
45+
if (tag !== '[object Object]') {
46+
return klona(value);
47+
}
48+
49+
// For plain objects, recursively deep copy each property
50+
const result: Record<string, unknown> = {};
51+
for (const key of Object.keys(value as Record<string, unknown>)) {
52+
result[key] = deepCopy((value as Record<string, unknown>)[key]);
53+
}
54+
return result as T;
55+
}
56+
57+
function isClassInstance(value: unknown): boolean {
58+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
59+
return false;
60+
}
61+
62+
const tag = Object.prototype.toString.call(value);
63+
// Only treat [object Object] as potentially a class instance.
64+
// Other types (Date, RegExp, Map, Set, etc.) are safely handled by klona.
65+
if (tag !== '[object Object]') {
66+
return false;
67+
}
68+
69+
return !isPlainObject(value);
70+
}
71+
2272
export function cleanupNonNestedPath(path: string) {
2373
if (isNotNestedPath(path)) {
2474
return path.replace(/\[|\]/gi, '');

packages/vee-validate/src/validate.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { resolveRule } from './defineRule';
2-
import { klona as deepCopy } from 'klona/full';
3-
import { isLocator, normalizeRules, keysOf, getFromPath, isStandardSchema, combineStandardIssues } from './utils';
2+
import {
3+
deepCopy,
4+
isLocator,
5+
normalizeRules,
6+
keysOf,
7+
getFromPath,
8+
isStandardSchema,
9+
combineStandardIssues,
10+
} from './utils';
411
import { getConfig } from './config';
512
import {
613
ValidationResult,

packages/vee-validate/tests/utils/assertions.spec.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isEqual } from 'packages/vee-validate/src/utils';
1+
import { isEqual, deepCopy } from 'packages/vee-validate/src/utils';
22

33
describe('assertions', () => {
44
test('equal objects are equal', () => {
@@ -228,4 +228,126 @@ describe('assertions', () => {
228228
expect(isEqual(a6, b1)).toBe(false);
229229
expect(isEqual(a6, b2)).toBe(false);
230230
});
231+
232+
test('class instances with private properties do not throw in isEqual (#4977)', () => {
233+
class MyClass {
234+
#secret: string;
235+
public name: string;
236+
237+
constructor(name: string, secret: string) {
238+
this.name = name;
239+
this.#secret = secret;
240+
}
241+
242+
getSecret() {
243+
return this.#secret;
244+
}
245+
}
246+
247+
const a = new MyClass('test', 'secret1');
248+
const b = new MyClass('test', 'secret2');
249+
250+
// Same reference should be equal
251+
expect(isEqual(a, a)).toBe(true);
252+
// Different instances should not be equal (reference equality for class instances)
253+
expect(isEqual(a, b)).toBe(false);
254+
});
255+
256+
test('class instances with private properties nested in plain objects (#4977)', () => {
257+
class DataObj {
258+
#value: number;
259+
260+
constructor(value: number) {
261+
this.#value = value;
262+
}
263+
264+
getValue() {
265+
return this.#value;
266+
}
267+
}
268+
269+
const obj1 = { name: 'test', data: new DataObj(1) };
270+
const obj2 = { name: 'test', data: obj1.data };
271+
const obj3 = { name: 'test', data: new DataObj(1) };
272+
273+
// Same nested reference should be equal
274+
expect(isEqual(obj1, obj2)).toBe(true);
275+
// Different class instance references should not be equal
276+
expect(isEqual(obj1, obj3)).toBe(false);
277+
});
278+
279+
test('deepCopy does not throw on class instances with private properties (#4977)', () => {
280+
class MyClass {
281+
#secret: string;
282+
public name: string;
283+
284+
constructor(name: string, secret: string) {
285+
this.name = name;
286+
this.#secret = secret;
287+
}
288+
289+
getSecret() {
290+
return this.#secret;
291+
}
292+
}
293+
294+
const instance = new MyClass('test', 'secret');
295+
const values = { field1: 'hello', myObj: instance };
296+
297+
// Should not throw
298+
const cloned = deepCopy(values);
299+
300+
// The plain object should be cloned
301+
expect(cloned).not.toBe(values);
302+
expect(cloned.field1).toBe('hello');
303+
// The class instance should be returned by reference, not cloned
304+
expect(cloned.myObj).toBe(instance);
305+
expect(cloned.myObj.getSecret()).toBe('secret');
306+
});
307+
308+
test('deepCopy handles class instances nested in arrays (#4977)', () => {
309+
class Item {
310+
#id: number;
311+
312+
constructor(id: number) {
313+
this.#id = id;
314+
}
315+
316+
getId() {
317+
return this.#id;
318+
}
319+
}
320+
321+
const item1 = new Item(1);
322+
const item2 = new Item(2);
323+
const values = { items: [item1, item2] };
324+
325+
const cloned = deepCopy(values);
326+
327+
expect(cloned).not.toBe(values);
328+
expect(cloned.items).not.toBe(values.items);
329+
// Class instances should be the same references
330+
expect(cloned.items[0]).toBe(item1);
331+
expect(cloned.items[1]).toBe(item2);
332+
expect(cloned.items[0].getId()).toBe(1);
333+
expect(cloned.items[1].getId()).toBe(2);
334+
});
335+
336+
test('deepCopy still clones plain objects and primitive values', () => {
337+
const values = {
338+
name: 'test',
339+
nested: { a: 1, b: 2 },
340+
arr: [1, 2, 3],
341+
date: new Date('2024-01-01'),
342+
};
343+
344+
const cloned = deepCopy(values);
345+
346+
expect(cloned).not.toBe(values);
347+
expect(cloned.name).toBe('test');
348+
expect(cloned.nested).not.toBe(values.nested);
349+
expect(cloned.nested).toEqual({ a: 1, b: 2 });
350+
expect(cloned.arr).not.toBe(values.arr);
351+
expect(cloned.arr).toEqual([1, 2, 3]);
352+
});
231353
});

0 commit comments

Comments
 (0)