diff --git a/packages/core/index.ts b/packages/core/index.ts index 8da1ef601d9..9c2770a5b6c 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -18,6 +18,7 @@ export * from './useBroadcastChannel' export * from './useBrowserLocation' export * from './useCached' export * from './useClipboard' +export * from './useCloned' export * from './useColorMode' export * from './useConfirmDialog' export * from './useCssVar' diff --git a/packages/core/useCloned/demo.vue b/packages/core/useCloned/demo.vue new file mode 100644 index 00000000000..9246fb256fd --- /dev/null +++ b/packages/core/useCloned/demo.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/core/useCloned/index.md b/packages/core/useCloned/index.md new file mode 100644 index 00000000000..71e4b1cdd22 --- /dev/null +++ b/packages/core/useCloned/index.md @@ -0,0 +1,53 @@ +--- +category: Utilities +--- + +# useCloned + +Reactive clone of a ref. By default, it use `JSON.parse(JSON.stringify())` to do the clone. + +## Usage + +```ts +import { useCloned } from '@vueuse/core' + +const original = ref({ key: 'value' }) + +const { cloned } = useCloned(original) + +original.key = 'some new value' + +console.log(cloned.value.key) // 'some new value' +``` + +## Manual cloning + +```ts +import { useCloned } from '@vueuse/core' + +const original = ref({ key: 'value' }) + +const { cloned, sync } = useCloned(original, { manual: true }) + +original.key = 'manual' + +console.log(cloned.value.key) // 'value' + +sync() + +console.log(cloned.value.key)// 'manual' +``` + +## Custom Clone Function + +Using [`klona`](https://www.npmjs.com/package/klona) for example: + +```ts +import { useCloned } from '@vueuse/core' +import { klona } from 'klona' + +const original = ref({ key: 'value' }) + +const { cloned, sync } = useCloned(original, { clone: klona }) +``` + diff --git a/packages/core/useCloned/index.test.ts b/packages/core/useCloned/index.test.ts new file mode 100644 index 00000000000..dfb299f6539 --- /dev/null +++ b/packages/core/useCloned/index.test.ts @@ -0,0 +1,84 @@ +import { useCloned } from '@vueuse/core' +import { expect } from 'vitest' +import { nextTick, ref } from 'vue-demi' + +describe('useCloned', () => { + it('works with simple objects', () => { + const data = { test: 'test' } + + const { cloned, sync } = useCloned(data) + + expect(cloned.value).toEqual(data) + + cloned.value = { test: 'failed' } + + sync() + + expect(cloned.value).toEqual(data) + }) + + it('works with refs', async () => { + const data = ref({ test: 'test' }) + + const { cloned } = useCloned(data) + + data.value.test = 'success' + + await nextTick() + + expect(cloned.value).toEqual(data.value) + }) + + it('works with refs and manual sync', async () => { + const data = ref({ test: 'test' }) + + const { cloned, sync } = useCloned(data, { manual: true }) + + data.value.test = 'success' + + expect(cloned.value).not.toEqual(data.value) + + sync() + + expect(cloned.value).toEqual(data.value) + }) + + it('works with custom clone function', async () => { + const data = ref>({ test: 'test' }) + + const { cloned } = useCloned(data, { + clone: source => ({ ...source, proxyTest: true }), + }) + + data.value.test = 'partial' + + await nextTick() + + expect(cloned.value.test).toBe('partial') + expect(cloned.value.proxyTest).toBe(true) + }) + + it('works with watch options', async () => { + const data = ref({ test: 'test' }) + + const { cloned } = useCloned(data, { immediate: false, deep: false }) + + await nextTick() + + // test immediate: false + expect(cloned.value).toEqual({}) + + data.value.test = 'not valid' + + await nextTick() + + // test deep: false + expect(cloned.value).toEqual({}) + + data.value = { test: 'valid' } + + await nextTick() + + expect(cloned.value).toEqual(data.value) + }) +}) diff --git a/packages/core/useCloned/index.ts b/packages/core/useCloned/index.ts new file mode 100644 index 00000000000..7c95cacd01d --- /dev/null +++ b/packages/core/useCloned/index.ts @@ -0,0 +1,65 @@ +import type { MaybeComputedRef } from '@vueuse/shared' +import type { ComputedRef, WatchOptions } from 'vue-demi' +import { isRef, ref, unref, watch } from 'vue-demi' + +export interface UseClonedOptions extends WatchOptions { + /** + * Custom clone function. + * + * By default, it use `JSON.parse(JSON.stringify(value))` to clone. + */ + clone?: (source: T) => T + + /** + * Manually sync the ref + * + * @default false + */ + manual?: boolean +} + +export interface UseClonedReturn { + /** + * Cloned ref + */ + cloned: ComputedRef + /** + * Sync cloned data with source manually + */ + sync: () => void +} + +function cloneFnJSON(source: T): T { + return JSON.parse(JSON.stringify(source)) +} + +export function useCloned( + source: MaybeComputedRef, + options: UseClonedOptions = {}, +) { + const cloned = ref({} as T) + const { + manual, + clone = cloneFnJSON, + // watch options + deep = true, + immediate = true, + } = options + + function sync() { + cloned.value = clone(unref(source)) + } + + if (!manual && isRef(source)) { + watch(source, sync, { + ...options, + deep, + immediate, + }) + } + else { + sync() + } + + return { cloned, sync } +}