From 352a6f8e6a2bacb2d3cfdda9595a9258dba88674 Mon Sep 17 00:00:00 2001 From: Eduardo Wesley Date: Mon, 1 Aug 2022 07:52:42 -0300 Subject: [PATCH 1/7] fix(isObject): not detect class instances as objects --- packages/shared/utils/index.test.ts | 18 ++++++++++++++++++ packages/shared/utils/is.ts | 2 +- packages/shared/utils/types.ts | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/shared/utils/index.test.ts b/packages/shared/utils/index.test.ts index d9d9c679f5b..160ff5b9216 100644 --- a/packages/shared/utils/index.test.ts +++ b/packages/shared/utils/index.test.ts @@ -105,3 +105,21 @@ describe('filters', () => { expect(debouncedFilterSpy).toHaveBeenCalledTimes(1) }) }) + +describe('is', () => { + it('isObject', () => { + expect(isObject({})).toBe(true) + expect(isObject(Object.create({}))).toBe(true) + expect(isObject([])).toBe(false) + expect(isObject(1)).toBe(false) + expect(isObject('1')).toBe(false) + expect(isObject(true)).toBe(false) + expect(isObject(null)).toBe(false) + expect(isObject(undefined)).toBe(false) + expect(isObject(() => {})).toBe(false) + expect(isObject(/a/)).toBe(false) + expect(isObject(new Date())).toBe(false) + expect(isObject(new Map())).toBe(false) + expect(isObject(new Set())).toBe(false) + }) +}) diff --git a/packages/shared/utils/is.ts b/packages/shared/utils/is.ts index 455aa546698..e75f8e0f608 100644 --- a/packages/shared/utils/is.ts +++ b/packages/shared/utils/is.ts @@ -10,7 +10,7 @@ export const isFunction = (val: any): val is T => typeof va export const isNumber = (val: any): val is number => typeof val === 'number' export const isString = (val: unknown): val is string => typeof val === 'string' export const isObject = (val: any): val is object => - toString.call(val) === '[object Object]' + val?.constructor?.name === 'Object' export const isWindow = (val: any): val is Window => typeof window !== 'undefined' && toString.call(val) === '[object Window]' export const now = () => Date.now() diff --git a/packages/shared/utils/types.ts b/packages/shared/utils/types.ts index daabeaf9fa2..84a6161f944 100644 --- a/packages/shared/utils/types.ts +++ b/packages/shared/utils/types.ts @@ -5,6 +5,11 @@ import type { Ref, WatchOptions, WatchSource } from 'vue-demi' */ export type Fn = () => void +/** + * Any object + */ +export type AnyObj = Record + /** * A ref that allow to set null or undefined */ From dc39c3c9a68fd3e9f383435f08a4cd8f68299626 Mon Sep 17 00:00:00 2001 From: Eduardo Wesley Date: Mon, 1 Aug 2022 07:54:29 -0300 Subject: [PATCH 2/7] feat: add simple clone deep function --- packages/shared/utils/index.test.ts | 16 +++++++++++++++- packages/shared/utils/index.ts | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/shared/utils/index.test.ts b/packages/shared/utils/index.test.ts index 160ff5b9216..6609fcdbc28 100644 --- a/packages/shared/utils/index.test.ts +++ b/packages/shared/utils/index.test.ts @@ -1,5 +1,5 @@ import { ref } from 'vue-demi' -import { createFilterWrapper, debounceFilter, increaseWithUnit, objectPick, throttleFilter } from '.' +import { cloneDeep, createFilterWrapper, debounceFilter, increaseWithUnit, isObject, objectPick, throttleFilter } from '.' describe('utils', () => { it('increaseWithUnit', () => { @@ -17,6 +17,20 @@ describe('utils', () => { expect(objectPick({ a: 1, b: 2, c: 3 }, ['a', 'b'])).toEqual({ a: 1, b: 2 }) expect(objectPick({ a: 1, b: 2, c: undefined }, ['a', 'b'], true)).toEqual({ a: 1, b: 2 }) }) + + it('cloneDeep', () => { + const obj = { + a: 1, + b: 2, + d: { + e: 3, + f: { g: 4 }, + }, + } + + expect(cloneDeep(obj)).toEqual(obj) + expect(cloneDeep(obj)).not.toBe(obj) + }) }) describe('filters', () => { diff --git a/packages/shared/utils/index.ts b/packages/shared/utils/index.ts index d9603ebd300..a85abc36760 100644 --- a/packages/shared/utils/index.ts +++ b/packages/shared/utils/index.ts @@ -1,3 +1,6 @@ +import type { AnyObj } from './types' +import { isObject } from './is' + export * from './is' export * from './filters' export * from './types' @@ -101,3 +104,20 @@ export function objectPick(obj: O, keys: T[], omitUndefine return n }, {} as Pick) } + +/** + * Simple recursive deep clone + * + * @category Object + */ +export const cloneDeep = (obj: any) => { + if (!isObject(obj)) + return obj + + const clone: AnyObj = {} + + for (const key in obj) + clone[key] = cloneDeep((obj as AnyObj)[key]) + + return clone +} From 9b094e3cf8b3834d0242e7b64fe8f8b92ace4c78 Mon Sep 17 00:00:00 2001 From: Eduardo Wesley Date: Mon, 1 Aug 2022 07:55:15 -0300 Subject: [PATCH 3/7] fix(useVModel): remove side effect when using object and passive --- packages/core/useVModel/index.test.ts | 25 +++++++++++++++++++++++++ packages/core/useVModel/index.ts | 7 +++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/core/useVModel/index.test.ts b/packages/core/useVModel/index.test.ts index 4b510dd28e9..7a54df29683 100644 --- a/packages/core/useVModel/index.test.ts +++ b/packages/core/useVModel/index.test.ts @@ -177,4 +177,29 @@ describe('useVModel', () => { expect(emitValue instanceof SomeClass).toBeTruthy() }) + + it('should be side effect free when using objects', async () => { + const emitMock = vitest.fn() + + const props = { + person: { + age: 18, + child: { age: 2 }, + }, + } + + const dataA = useVModel(props, 'person', emitMock, { passive: true }) + const dataB = useVModel(props, 'person', emitMock, { passive: true, deep: true }) + + dataA.value.age = 20 + + await nextTick() + expect(props.person).toEqual(expect.objectContaining({ age: 18 })) + + dataB.value.child.age = 3 + + expect(props.person).toEqual(expect.objectContaining({ + child: { age: 2 }, + })) + }) }) diff --git a/packages/core/useVModel/index.ts b/packages/core/useVModel/index.ts index 12ee6a5ad5c..f846d2a2b5d 100644 --- a/packages/core/useVModel/index.ts +++ b/packages/core/useVModel/index.ts @@ -1,4 +1,5 @@ -import { isDef } from '@vueuse/shared' +import type { AnyObj } from '@vueuse/shared' +import { cloneDeep, isDef, isObject } from '@vueuse/shared' import type { UnwrapRef } from 'vue-demi' import { computed, getCurrentInstance, isVue2, ref, watch } from 'vue-demi' @@ -72,9 +73,11 @@ export function useVModel

isDef(props[key!]) ? props[key!] : defaultValue + const cloneObj = (obj: AnyObj) => deep ? cloneDeep(obj) : ({ ...obj }) if (passive) { - const proxy = ref(getValue()!) + const initialValue = getValue() + const proxy = ref(isObject(initialValue) ? cloneObj(initialValue) : initialValue!) watch(() => props[key!], v => proxy.value = v as UnwrapRef) From e2e62e4e7ea79ce4e2ca74fd0c37cae15efb0b7a Mon Sep 17 00:00:00 2001 From: Eduardo Wesley Date: Fri, 5 Aug 2022 00:19:41 -0300 Subject: [PATCH 4/7] refactor(useVModel): use clone object as option --- packages/core/useVModel/index.test.ts | 37 +++++++++++++++++++++++---- packages/core/useVModel/index.ts | 19 +++++++++++--- packages/shared/utils/index.test.ts | 34 +----------------------- packages/shared/utils/index.ts | 20 --------------- packages/shared/utils/is.ts | 2 +- packages/shared/utils/types.ts | 5 ---- 6 files changed, 49 insertions(+), 68 deletions(-) diff --git a/packages/core/useVModel/index.test.ts b/packages/core/useVModel/index.test.ts index 7a54df29683..29fbe796e60 100644 --- a/packages/core/useVModel/index.test.ts +++ b/packages/core/useVModel/index.test.ts @@ -178,7 +178,7 @@ describe('useVModel', () => { expect(emitValue instanceof SomeClass).toBeTruthy() }) - it('should be side effect free when using objects', async () => { + it('should clone object', async () => { const emitMock = vitest.fn() const props = { @@ -188,18 +188,45 @@ describe('useVModel', () => { }, } - const dataA = useVModel(props, 'person', emitMock, { passive: true }) - const dataB = useVModel(props, 'person', emitMock, { passive: true, deep: true }) + const data = useVModel(props, 'person', emitMock, { passive: true, clone: true }) + const dataDeep = useVModel(props, 'person', emitMock, { passive: true, clone: true, deep: true }) - dataA.value.age = 20 + data.value.age = 20 await nextTick() + expect(props.person).not.toBe(data.value) expect(props.person).toEqual(expect.objectContaining({ age: 18 })) - dataB.value.child.age = 3 + dataDeep.value.child.age = 3 + expect(props.person).not.toBe(dataDeep.value) expect(props.person).toEqual(expect.objectContaining({ child: { age: 2 }, })) }) + + it('should deep clone object with clone function', async () => { + const emitMock = vitest.fn() + const clone = vitest.fn(x => JSON.parse(JSON.stringify(x))) + + const props = { + person: { + age: 18, + child: { age: 2 }, + }, + } + + const data = useVModel(props, 'person', emitMock, { passive: true, clone, deep: true }) + + data.value.age = 20 + data.value.child.age = 3 + + await nextTick() + expect(clone).toHaveBeenCalled() + expect(props.person).not.toBe(data.value) + expect(props.person).toEqual({ + age: 18, + child: { age: 2 }, + }) + }) }) diff --git a/packages/core/useVModel/index.ts b/packages/core/useVModel/index.ts index f846d2a2b5d..9e7cdb36245 100644 --- a/packages/core/useVModel/index.ts +++ b/packages/core/useVModel/index.ts @@ -1,8 +1,9 @@ -import type { AnyObj } from '@vueuse/shared' -import { cloneDeep, isDef, isObject } from '@vueuse/shared' +import { isDef, isFunction } from '@vueuse/shared' import type { UnwrapRef } from 'vue-demi' import { computed, getCurrentInstance, isVue2, ref, watch } from 'vue-demi' +export type CloneFn = (x: F) => T + export interface UseVModelOptions { /** * When passive is set to `true`, it will use `watch` to sync with props and ref. @@ -30,8 +31,17 @@ export interface UseVModelOptions { * @default undefined */ defaultValue?: T + /** + * Clone when getting the value from props, shortcut for: JSON.parse(JSON.stringify(value)). + * Default to false + * + * @default false + */ + clone?: boolean | CloneFn } +const defaultCloneFn = (v: F): T => JSON.parse(JSON.stringify(v)) + /** * Shorthand for v-model binding, props + emit -> ref * @@ -47,6 +57,7 @@ export function useVModel

= {}, ) { const { + clone = false, passive = false, eventName, deep = false, @@ -73,11 +84,11 @@ export function useVModel

isDef(props[key!]) ? props[key!] : defaultValue - const cloneObj = (obj: AnyObj) => deep ? cloneDeep(obj) : ({ ...obj }) + const cloneFn = (val: P[K]) => isFunction(clone) ? clone(val) : defaultCloneFn(val) if (passive) { const initialValue = getValue() - const proxy = ref(isObject(initialValue) ? cloneObj(initialValue) : initialValue!) + const proxy = ref(clone && initialValue ? cloneFn(initialValue) : initialValue!) watch(() => props[key!], v => proxy.value = v as UnwrapRef) diff --git a/packages/shared/utils/index.test.ts b/packages/shared/utils/index.test.ts index 6609fcdbc28..d9d9c679f5b 100644 --- a/packages/shared/utils/index.test.ts +++ b/packages/shared/utils/index.test.ts @@ -1,5 +1,5 @@ import { ref } from 'vue-demi' -import { cloneDeep, createFilterWrapper, debounceFilter, increaseWithUnit, isObject, objectPick, throttleFilter } from '.' +import { createFilterWrapper, debounceFilter, increaseWithUnit, objectPick, throttleFilter } from '.' describe('utils', () => { it('increaseWithUnit', () => { @@ -17,20 +17,6 @@ describe('utils', () => { expect(objectPick({ a: 1, b: 2, c: 3 }, ['a', 'b'])).toEqual({ a: 1, b: 2 }) expect(objectPick({ a: 1, b: 2, c: undefined }, ['a', 'b'], true)).toEqual({ a: 1, b: 2 }) }) - - it('cloneDeep', () => { - const obj = { - a: 1, - b: 2, - d: { - e: 3, - f: { g: 4 }, - }, - } - - expect(cloneDeep(obj)).toEqual(obj) - expect(cloneDeep(obj)).not.toBe(obj) - }) }) describe('filters', () => { @@ -119,21 +105,3 @@ describe('filters', () => { expect(debouncedFilterSpy).toHaveBeenCalledTimes(1) }) }) - -describe('is', () => { - it('isObject', () => { - expect(isObject({})).toBe(true) - expect(isObject(Object.create({}))).toBe(true) - expect(isObject([])).toBe(false) - expect(isObject(1)).toBe(false) - expect(isObject('1')).toBe(false) - expect(isObject(true)).toBe(false) - expect(isObject(null)).toBe(false) - expect(isObject(undefined)).toBe(false) - expect(isObject(() => {})).toBe(false) - expect(isObject(/a/)).toBe(false) - expect(isObject(new Date())).toBe(false) - expect(isObject(new Map())).toBe(false) - expect(isObject(new Set())).toBe(false) - }) -}) diff --git a/packages/shared/utils/index.ts b/packages/shared/utils/index.ts index a85abc36760..d9603ebd300 100644 --- a/packages/shared/utils/index.ts +++ b/packages/shared/utils/index.ts @@ -1,6 +1,3 @@ -import type { AnyObj } from './types' -import { isObject } from './is' - export * from './is' export * from './filters' export * from './types' @@ -104,20 +101,3 @@ export function objectPick(obj: O, keys: T[], omitUndefine return n }, {} as Pick) } - -/** - * Simple recursive deep clone - * - * @category Object - */ -export const cloneDeep = (obj: any) => { - if (!isObject(obj)) - return obj - - const clone: AnyObj = {} - - for (const key in obj) - clone[key] = cloneDeep((obj as AnyObj)[key]) - - return clone -} diff --git a/packages/shared/utils/is.ts b/packages/shared/utils/is.ts index e75f8e0f608..455aa546698 100644 --- a/packages/shared/utils/is.ts +++ b/packages/shared/utils/is.ts @@ -10,7 +10,7 @@ export const isFunction = (val: any): val is T => typeof va export const isNumber = (val: any): val is number => typeof val === 'number' export const isString = (val: unknown): val is string => typeof val === 'string' export const isObject = (val: any): val is object => - val?.constructor?.name === 'Object' + toString.call(val) === '[object Object]' export const isWindow = (val: any): val is Window => typeof window !== 'undefined' && toString.call(val) === '[object Window]' export const now = () => Date.now() diff --git a/packages/shared/utils/types.ts b/packages/shared/utils/types.ts index 84a6161f944..daabeaf9fa2 100644 --- a/packages/shared/utils/types.ts +++ b/packages/shared/utils/types.ts @@ -5,11 +5,6 @@ import type { Ref, WatchOptions, WatchSource } from 'vue-demi' */ export type Fn = () => void -/** - * Any object - */ -export type AnyObj = Record - /** * A ref that allow to set null or undefined */ From a198c71f6f494cf37bcc758a8e0b4c9c00836cbe Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 5 Sep 2022 21:42:21 +0800 Subject: [PATCH 5/7] chore: update --- packages/core/useCloned/index.ts | 4 ++- packages/core/useVModel/index.ts | 43 ++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/core/useCloned/index.ts b/packages/core/useCloned/index.ts index 7c95cacd01d..d40080181f0 100644 --- a/packages/core/useCloned/index.ts +++ b/packages/core/useCloned/index.ts @@ -29,7 +29,9 @@ export interface UseClonedReturn { sync: () => void } -function cloneFnJSON(source: T): T { +export type CloneFn = (x: F) => T + +export function cloneFnJSON(source: T): T { return JSON.parse(JSON.stringify(source)) } diff --git a/packages/core/useVModel/index.ts b/packages/core/useVModel/index.ts index 9e7cdb36245..2e7e3af4608 100644 --- a/packages/core/useVModel/index.ts +++ b/packages/core/useVModel/index.ts @@ -1,8 +1,8 @@ import { isDef, isFunction } from '@vueuse/shared' import type { UnwrapRef } from 'vue-demi' import { computed, getCurrentInstance, isVue2, ref, watch } from 'vue-demi' - -export type CloneFn = (x: F) => T +import type { CloneFn } from '../useCloned' +import { cloneFnJSON } from '../useCloned' export interface UseVModelOptions { /** @@ -32,16 +32,15 @@ export interface UseVModelOptions { */ defaultValue?: T /** - * Clone when getting the value from props, shortcut for: JSON.parse(JSON.stringify(value)). - * Default to false + * Clone the props. + * Accepts a custom clone function. + * When setting to `true`, it will use `JSON.parse(JSON.stringify(value))` to clone. * * @default false */ clone?: boolean | CloneFn } -const defaultCloneFn = (v: F): T => JSON.parse(JSON.stringify(v)) - /** * Shorthand for v-model binding, props + emit -> ref * @@ -83,21 +82,33 @@ export function useVModel

isDef(props[key!]) ? props[key!] : defaultValue - const cloneFn = (val: P[K]) => isFunction(clone) ? clone(val) : defaultCloneFn(val) + const cloneFn = (val: P[K]) => !clone + ? val + : isFunction(clone) + ? clone(val) + : cloneFnJSON(val) + + const getValue = () => isDef(props[key!]) + ? cloneFn(props[key!]) + : defaultValue if (passive) { const initialValue = getValue() - const proxy = ref(clone && initialValue ? cloneFn(initialValue) : initialValue!) + const proxy = ref(initialValue!) - watch(() => props[key!], v => proxy.value = v as UnwrapRef) + watch( + () => props[key!], + v => proxy.value = cloneFn(v) as UnwrapRef, + ) - watch(proxy, (v) => { - if (v !== props[key!] || deep) - _emit(event, v) - }, { - deep, - }) + watch( + proxy, + (v) => { + if (v !== props[key!] || deep) + _emit(event, v) + }, + { deep }, + ) return proxy } From 781d38aa4324d1daf4f192a249998eaa36949e31 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 5 Sep 2022 21:47:56 +0800 Subject: [PATCH 6/7] chore: update --- packages/core/useManualRefHistory/index.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/core/useManualRefHistory/index.ts b/packages/core/useManualRefHistory/index.ts index 35b2b652e35..6aa7a2a31ee 100644 --- a/packages/core/useManualRefHistory/index.ts +++ b/packages/core/useManualRefHistory/index.ts @@ -1,20 +1,19 @@ import { isFunction, timestamp } from '@vueuse/shared' import type { Ref } from 'vue-demi' import { computed, markRaw, ref } from 'vue-demi' +import type { CloneFn } from '../useCloned' +import { cloneFnJSON } from '../useCloned' export interface UseRefHistoryRecord { snapshot: T timestamp: number } -export type CloneFn = (x: F) => T - export interface UseManualRefHistoryOptions { /** * Maximum number of history to be kept. Default to unlimited. */ capacity?: number - /** * Clone when taking a snapshot, shortcut for dump: JSON.parse(JSON.stringify(value)). * Default to false @@ -99,18 +98,17 @@ export interface UseManualRefHistoryReturn { reset: () => void } -const fnClone = (v: F): T => JSON.parse(JSON.stringify(v)) const fnBypass = (v: F) => v as unknown as T const fnSetSource = (source: Ref, value: F) => source.value = value type FnCloneOrBypass = (v: F) => T function defaultDump(clone?: boolean | CloneFn) { - return (clone ? isFunction(clone) ? clone : fnClone : fnBypass) as unknown as FnCloneOrBypass + return (clone ? isFunction(clone) ? clone : cloneFnJSON : fnBypass) as unknown as FnCloneOrBypass } function defaultParse(clone?: boolean | CloneFn) { - return (clone ? isFunction(clone) ? clone : fnClone : fnBypass) as unknown as FnCloneOrBypass + return (clone ? isFunction(clone) ? clone : cloneFnJSON : fnBypass) as unknown as FnCloneOrBypass } /** From 47c8dfa3c65486c2a967e5fd05208abbd3bdeacb Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Mon, 5 Sep 2022 21:58:05 +0800 Subject: [PATCH 7/7] chore: update --- packages/core/useCloned/index.md | 1 - packages/core/useRefHistory/index.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/useCloned/index.md b/packages/core/useCloned/index.md index 71e4b1cdd22..58bf6fdffdb 100644 --- a/packages/core/useCloned/index.md +++ b/packages/core/useCloned/index.md @@ -50,4 +50,3 @@ const original = ref({ key: 'value' }) const { cloned, sync } = useCloned(original, { clone: klona }) ``` - diff --git a/packages/core/useRefHistory/index.ts b/packages/core/useRefHistory/index.ts index 8c710cad739..e3e17e340c3 100644 --- a/packages/core/useRefHistory/index.ts +++ b/packages/core/useRefHistory/index.ts @@ -1,7 +1,8 @@ import type { ConfigurableEventFilter, Fn } from '@vueuse/shared' import { pausableFilter, watchIgnorable } from '@vueuse/shared' import type { Ref } from 'vue-demi' -import type { CloneFn, UseManualRefHistoryReturn } from '../useManualRefHistory' +import type { CloneFn } from '../useCloned' +import type { UseManualRefHistoryReturn } from '../useManualRefHistory' import { useManualRefHistory } from '../useManualRefHistory' export interface UseRefHistoryOptions extends ConfigurableEventFilter {