diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 648e6481b18..e550bfd83fa 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -245,7 +245,8 @@ export function watch( forceTrigger || (isMultiSource ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) - : hasChanged(newValue, oldValue)) + : hasChanged(newValue, oldValue)) || + (__COMPAT__ && (options as any).compatWatchArray && isArray(newValue)) ) { // cleanup before running cb again if (cleanup) { diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 8f6168cdf29..70f2b9dc6cb 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -7,9 +7,17 @@ import { type WatchHandle, type WatchSource, watch as baseWatch, + traverse, } from '@vue/reactivity' import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' -import { EMPTY_OBJ, NOOP, extend, isFunction, isString } from '@vue/shared' +import { + EMPTY_OBJ, + NOOP, + extend, + isArray, + isFunction, + isString, +} from '@vue/shared' import { type ComponentInternalInstance, currentInstance, @@ -19,6 +27,11 @@ import { import { callWithAsyncErrorHandling } from './errorHandling' import { queuePostRenderEffect } from './renderer' import { warn } from './warning' +import { + DeprecationTypes, + checkCompatEnabled, + isCompatEnabled, +} from './compat/compatConfig' import type { ObjectWatchOptionItem } from './componentOptions' import { useSSRContext } from './helpers/useSsrContext' @@ -236,6 +249,22 @@ function doWatch( return watchHandle } +export function createCompatWatchGetter( + baseGetter: () => any, + instance: ComponentInternalInstance, +) { + return (): any => { + const val = baseGetter() + if ( + isArray(val) && + checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance) + ) { + traverse(val, 1) + } + return val + } +} + // this.$watch export function instanceWatch( this: ComponentInternalInstance, @@ -244,7 +273,7 @@ export function instanceWatch( options?: WatchOptions, ): WatchHandle { const publicThis = this.proxy as any - const getter = isString(source) + let getter = isString(source) ? source.includes('.') ? createPathGetter(publicThis, source) : () => publicThis[source] @@ -256,6 +285,19 @@ export function instanceWatch( cb = value.handler as Function options = value } + + if ( + __COMPAT__ && + isString(source) && + isCompatEnabled(DeprecationTypes.WATCH_ARRAY, this) + ) { + const deep = options && options.deep + if (!deep) { + options = extend({ compatWatchArray: true }, options) + getter = createCompatWatchGetter(getter, this) + } + } + const reset = setCurrentInstance(this) const res = doWatch(getter, cb.bind(publicThis), options) reset() diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 5db6a0a1760..af21b0691f1 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -1,12 +1,11 @@ -import { - type Component, - type ComponentInternalInstance, - type ComponentInternalOptions, - type ConcreteComponent, - type Data, - type InternalRenderFunction, - type SetupContext, - currentInstance, +import type { + Component, + ComponentInternalInstance, + ComponentInternalOptions, + ConcreteComponent, + Data, + InternalRenderFunction, + SetupContext, } from './component' import { type LooseRequired, @@ -19,11 +18,12 @@ import { isPromise, isString, } from '@vue/shared' -import { type Ref, getCurrentScope, isRef, traverse } from '@vue/reactivity' +import { type Ref, isRef } from '@vue/reactivity' import { computed } from './apiComputed' import { type WatchCallback, type WatchOptions, + createCompatWatchGetter, createPathGetter, watch, } from './apiWatch' @@ -72,9 +72,9 @@ import { warn } from './warning' import type { VNodeChild } from './vnode' import { callWithAsyncErrorHandling } from './errorHandling' import { deepMergeData } from './compat/data' -import { DeprecationTypes, checkCompatEnabled } from './compat/compatConfig' import { type CompatConfig, + DeprecationTypes, isCompatEnabled, softAssertCompatEnabled, } from './compat/compatConfig' @@ -843,7 +843,7 @@ function callHook( ) } -export function createWatcher( +function createWatcher( raw: ComponentWatchOptionItem, ctx: Data, publicThis: ComponentPublicInstance, @@ -854,30 +854,14 @@ export function createWatcher( : () => (publicThis as any)[key] const options: WatchOptions = {} - if (__COMPAT__) { - const instance = - currentInstance && getCurrentScope() === currentInstance.scope - ? currentInstance - : null - - const newValue = getter() - if ( - isArray(newValue) && - isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance) - ) { - options.deep = true - } - - const baseGetter = getter - getter = () => { - const val = baseGetter() - if ( - isArray(val) && - checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance) - ) { - traverse(val) - } - return val + if ( + __COMPAT__ && + isCompatEnabled(DeprecationTypes.WATCH_ARRAY, publicThis.$) + ) { + const deep = isObject(raw) && !isArray(raw) && !isFunction(raw) && raw.deep + if (!deep) { + ;(options as any).compatWatchArray = true + getter = createCompatWatchGetter(getter, publicThis.$) } } diff --git a/packages/vue-compat/__tests__/misc.spec.ts b/packages/vue-compat/__tests__/misc.spec.ts index 17fddd94c4a..c51dd682694 100644 --- a/packages/vue-compat/__tests__/misc.spec.ts +++ b/packages/vue-compat/__tests__/misc.spec.ts @@ -47,26 +47,377 @@ test('mode as function', () => { expect(vm.$el.innerHTML).toBe(`
foo
bar
`) }) -test('WATCH_ARRAY', async () => { - const spy = vi.fn() - const vm = new Vue({ - data() { - return { - foo: [], - } - }, - watch: { - foo: spy, - }, - }) as any - expect( - deprecationData[DeprecationTypes.WATCH_ARRAY].message, - ).toHaveBeenWarned() +describe('WATCH_ARRAY', () => { + describe('watch option', () => { + test('basic usage', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: [], + } + }, + watch: { + foo: spy, + }, + }) as any + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).toHaveBeenWarned() - expect(spy).not.toHaveBeenCalled() - vm.foo.push(1) - await nextTick() - expect(spy).toHaveBeenCalledTimes(1) + expect(spy).not.toHaveBeenCalled() + vm.foo.push(1) + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + }) + + test('dynamic depth depending on the value', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: {}, + } + }, + watch: { + foo: spy, + }, + }) as any + + vm.foo.bar = 1 + await nextTick() + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + expect(spy).not.toHaveBeenCalled() + + vm.foo = [] + await nextTick() + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).toHaveBeenWarned() + expect(spy).toHaveBeenCalledTimes(1) + + vm.foo.push({}) + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo[0].bar = 2 + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo = {} + await nextTick() + expect(spy).toHaveBeenCalledTimes(3) + + vm.foo.bar = 3 + await nextTick() + expect(spy).toHaveBeenCalledTimes(3) + }) + + test('deep: true', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: {}, + } + }, + watch: { + foo: { + handler: spy, + deep: true, + }, + }, + }) as any + + vm.foo.bar = 1 + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + + vm.foo = [] + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo.push({}) + await nextTick() + expect(spy).toHaveBeenCalledTimes(3) + + vm.foo[0].bar = 2 + await nextTick() + expect(spy).toHaveBeenCalledTimes(4) + + vm.foo = {} + await nextTick() + expect(spy).toHaveBeenCalledTimes(5) + + vm.foo.bar = 3 + await nextTick() + expect(spy).toHaveBeenCalledTimes(6) + + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + }) + + test('checks correct instance for compat config', async () => { + const spy = vi.fn() + const vm = new Vue({ + compatConfig: { + WATCH_ARRAY: false, + }, + data() { + return { + foo: [], + } + }, + watch: { + foo: spy, + }, + }) as any + + vm.foo.push(1) + await nextTick() + expect(spy).not.toHaveBeenCalled() + + const orig = vm.foo + vm.foo = [] + vm.foo = orig + await nextTick() + expect(spy).not.toHaveBeenCalled() + + vm.foo = [] + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + }) + + test('passing other options', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: [], + } + }, + watch: { + foo: { + handler: spy, + immediate: true, + }, + }, + }) as any + + expect(spy).toHaveBeenCalledTimes(1) + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).toHaveBeenWarned() + + vm.foo.push({}) + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo[0].bar = 1 + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + }) + }) + + describe('$watch()', () => { + test('basic usage', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: [], + } + }, + }) as any + vm.$watch('foo', spy) + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).toHaveBeenWarned() + + expect(spy).not.toHaveBeenCalled() + vm.foo.push(1) + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + }) + + test('dynamic depth depending on the value', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: {}, + } + }, + }) as any + vm.$watch('foo', spy) + + vm.foo.bar = 1 + await nextTick() + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + expect(spy).not.toHaveBeenCalled() + + vm.foo = [] + await nextTick() + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).toHaveBeenWarned() + expect(spy).toHaveBeenCalledTimes(1) + + vm.foo.push({}) + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo[0].bar = 2 + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo = {} + await nextTick() + expect(spy).toHaveBeenCalledTimes(3) + + vm.foo.bar = 3 + await nextTick() + expect(spy).toHaveBeenCalledTimes(3) + }) + + test('deep: true', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: {}, + } + }, + }) as any + vm.$watch('foo', spy, { deep: true }) + + vm.foo.bar = 1 + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + + vm.foo = [] + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo.push({}) + await nextTick() + expect(spy).toHaveBeenCalledTimes(3) + + vm.foo[0].bar = 2 + await nextTick() + expect(spy).toHaveBeenCalledTimes(4) + + vm.foo = {} + await nextTick() + expect(spy).toHaveBeenCalledTimes(5) + + vm.foo.bar = 3 + await nextTick() + expect(spy).toHaveBeenCalledTimes(6) + + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + }) + + test('not deep for a function getter', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: [], + } + }, + }) as any + vm.$watch(() => vm.foo, spy) + + expect(spy).not.toHaveBeenCalled() + vm.foo.push(1) + await nextTick() + expect(spy).not.toHaveBeenCalled() + vm.foo = [] + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + vm.foo.push(1) + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + }) + + test('checks correct instance for compat config', async () => { + const spy = vi.fn() + const vm = new Vue({ + compatConfig: { + WATCH_ARRAY: false, + }, + data() { + return { + foo: [], + } + }, + }) as any + vm.$watch('foo', spy) + + vm.foo.push(1) + await nextTick() + expect(spy).not.toHaveBeenCalled() + + const orig = vm.foo + vm.foo = [] + vm.foo = orig + await nextTick() + expect(spy).not.toHaveBeenCalled() + + vm.foo = [] + await nextTick() + expect(spy).toHaveBeenCalledTimes(1) + + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).not.toHaveBeenWarned() + }) + + test('passing other options', async () => { + const spy = vi.fn() + const vm = new Vue({ + data() { + return { + foo: [], + } + }, + }) as any + + vm.$watch('foo', { + handler: spy, + immediate: true, + }) + + expect(spy).toHaveBeenCalledTimes(1) + expect( + deprecationData[DeprecationTypes.WATCH_ARRAY].message, + ).toHaveBeenWarned() + + vm.foo.push({}) + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + + vm.foo[0].bar = 1 + await nextTick() + expect(spy).toHaveBeenCalledTimes(2) + }) + }) }) test('PROPS_DEFAULT_THIS', () => {