diff --git a/packages/reactivity/__tests__/watch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts new file mode 100644 index 00000000000..7a4078166b7 --- /dev/null +++ b/packages/reactivity/__tests__/watch.spec.ts @@ -0,0 +1,196 @@ +import { + EffectScope, + type Ref, + WatchErrorCodes, + type WatchOptions, + type WatchScheduler, + onWatcherCleanup, + ref, + watch, +} from '../src' + +const queue: (() => void)[] = [] + +// a simple scheduler for testing purposes +let isFlushPending = false +const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise +const nextTick = (fn?: () => any) => + fn ? resolvedPromise.then(fn) : resolvedPromise + +const scheduler: WatchScheduler = (job, isFirstRun) => { + if (isFirstRun) { + job() + } else { + queue.push(job) + flushJobs() + } +} + +const flushJobs = () => { + if (isFlushPending) return + isFlushPending = true + resolvedPromise.then(() => { + queue.forEach(job => job()) + queue.length = 0 + isFlushPending = false + }) +} + +describe('watch', () => { + test('effect', () => { + let dummy: any + const source = ref(0) + watch(() => { + dummy = source.value + }) + expect(dummy).toBe(0) + source.value++ + expect(dummy).toBe(1) + }) + + test('with callback', () => { + let dummy: any + const source = ref(0) + watch(source, () => { + dummy = source.value + }) + expect(dummy).toBe(undefined) + source.value++ + expect(dummy).toBe(1) + }) + + test('call option with error handling', () => { + const onError = vi.fn() + const call: WatchOptions['call'] = function call(fn, type, args) { + if (Array.isArray(fn)) { + fn.forEach(f => call(f, type, args)) + return + } + try { + fn.apply(null, args) + } catch (e) { + onError(e, type) + } + } + + watch( + () => { + throw 'oops in effect' + }, + null, + { call }, + ) + + const source = ref(0) + const effect = watch( + source, + () => { + onWatcherCleanup(() => { + throw 'oops in cleanup' + }) + throw 'oops in watch' + }, + { call }, + ) + + expect(onError.mock.calls.length).toBe(1) + expect(onError.mock.calls[0]).toMatchObject([ + 'oops in effect', + WatchErrorCodes.WATCH_CALLBACK, + ]) + + source.value++ + expect(onError.mock.calls.length).toBe(2) + expect(onError.mock.calls[1]).toMatchObject([ + 'oops in watch', + WatchErrorCodes.WATCH_CALLBACK, + ]) + + effect!.stop() + source.value++ + expect(onError.mock.calls.length).toBe(3) + expect(onError.mock.calls[2]).toMatchObject([ + 'oops in cleanup', + WatchErrorCodes.WATCH_CLEANUP, + ]) + }) + + test('watch with onWatcherCleanup', async () => { + let dummy = 0 + let source: Ref + const scope = new EffectScope() + + scope.run(() => { + source = ref(0) + watch(onCleanup => { + source.value + + onCleanup(() => (dummy += 2)) + onWatcherCleanup(() => (dummy += 3)) + onWatcherCleanup(() => (dummy += 5)) + }) + }) + expect(dummy).toBe(0) + + scope.run(() => { + source.value++ + }) + expect(dummy).toBe(10) + + scope.run(() => { + source.value++ + }) + expect(dummy).toBe(20) + + scope.stop() + expect(dummy).toBe(30) + }) + + test('nested calls to baseWatch and onWatcherCleanup', async () => { + let calls: string[] = [] + let source: Ref + let copyist: Ref + const scope = new EffectScope() + + scope.run(() => { + source = ref(0) + copyist = ref(0) + // sync by default + watch( + () => { + const current = (copyist.value = source.value) + onWatcherCleanup(() => calls.push(`sync ${current}`)) + }, + null, + {}, + ) + // with scheduler + watch( + () => { + const current = copyist.value + onWatcherCleanup(() => calls.push(`post ${current}`)) + }, + null, + { scheduler }, + ) + }) + + await nextTick() + expect(calls).toEqual([]) + + scope.run(() => source.value++) + expect(calls).toEqual(['sync 0']) + await nextTick() + expect(calls).toEqual(['sync 0', 'post 0']) + calls.length = 0 + + scope.run(() => source.value++) + expect(calls).toEqual(['sync 1']) + await nextTick() + expect(calls).toEqual(['sync 1', 'post 1']) + calls.length = 0 + + scope.stop() + expect(calls).toEqual(['sync 2', 'post 2']) + }) +}) diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index b320f1f8cb0..47302b224d7 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -80,3 +80,14 @@ export { } from './effectScope' export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' +export { + watch, + getCurrentWatcher, + traverse, + onWatcherCleanup, + WatchErrorCodes, + type WatchOptions, + type WatchScheduler, + type WatchStopHandle, + type WatchHandle, +} from './watch' diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts new file mode 100644 index 00000000000..2104896b7ae --- /dev/null +++ b/packages/reactivity/src/watch.ts @@ -0,0 +1,368 @@ +import { + EMPTY_OBJ, + NOOP, + hasChanged, + isArray, + isFunction, + isMap, + isObject, + isPlainObject, + isSet, + remove, +} from '@vue/shared' +import { warn } from './warning' +import type { ComputedRef } from './computed' +import { ReactiveFlags } from './constants' +import { + type DebuggerOptions, + EffectFlags, + type EffectScheduler, + ReactiveEffect, + pauseTracking, + resetTracking, +} from './effect' +import { isReactive, isShallow } from './reactive' +import { type Ref, isRef } from './ref' +import { getCurrentScope } from './effectScope' + +// These errors were transferred from `packages/runtime-core/src/errorHandling.ts` +// to @vue/reactivity to allow co-location with the moved base watch logic, hence +// it is essential to keep these values unchanged. +export enum WatchErrorCodes { + WATCH_GETTER = 2, + WATCH_CALLBACK, + WATCH_CLEANUP, +} + +type WatchEffect = (onCleanup: OnCleanup) => void +type WatchSource = Ref | ComputedRef | (() => T) +type WatchCallback = ( + value: V, + oldValue: OV, + onCleanup: OnCleanup, +) => any +type OnCleanup = (cleanupFn: () => void) => void + +export interface WatchOptions extends DebuggerOptions { + immediate?: Immediate + deep?: boolean | number + once?: boolean + scheduler?: WatchScheduler + onWarn?: (msg: string, ...args: any[]) => void + /** + * @internal + */ + augmentJob?: (job: (...args: any[]) => void) => void + /** + * @internal + */ + call?: ( + fn: Function | Function[], + type: WatchErrorCodes, + args?: unknown[], + ) => void +} + +export type WatchStopHandle = () => void + +export interface WatchHandle extends WatchStopHandle { + pause: () => void + resume: () => void + stop: () => void +} + +// initial value for watchers to trigger on undefined initial values +const INITIAL_WATCHER_VALUE = {} + +export type WatchScheduler = (job: () => void, isFirstRun: boolean) => void + +const cleanupMap: WeakMap void)[]> = new WeakMap() +let activeWatcher: ReactiveEffect | undefined = undefined + +/** + * Returns the current active effect if there is one. + */ +export function getCurrentWatcher(): ReactiveEffect | undefined { + return activeWatcher +} + +/** + * Registers a cleanup callback on the current active effect. This + * registered cleanup callback will be invoked right before the + * associated effect re-runs. + * + * @param cleanupFn - The callback function to attach to the effect's cleanup. + */ +export function onWatcherCleanup( + cleanupFn: () => void, + failSilently = false, + owner: ReactiveEffect | undefined = activeWatcher, +): void { + if (owner) { + let cleanups = cleanupMap.get(owner) + if (!cleanups) cleanupMap.set(owner, (cleanups = [])) + cleanups.push(cleanupFn) + } else if (__DEV__ && !failSilently) { + warn( + `onWatcherCleanup() was called when there was no active watcher` + + ` to associate with.`, + ) + } +} + +export function watch( + source: WatchSource | WatchSource[] | WatchEffect | object, + cb?: WatchCallback | null, + options: WatchOptions = EMPTY_OBJ, +): WatchHandle { + const { immediate, deep, once, scheduler, augmentJob, call } = options + + const warnInvalidSource = (s: unknown) => { + ;(options.onWarn || warn)( + `Invalid watch source: `, + s, + `A watch source can only be a getter/effect function, a ref, ` + + `a reactive object, or an array of these types.`, + ) + } + + const reactiveGetter = (source: object) => { + // traverse will happen in wrapped getter below + if (deep) return source + // for `deep: false | 0` or shallow reactive, only traverse root-level properties + if (isShallow(source) || deep === false || deep === 0) + return traverse(source, 1) + // for `deep: undefined` on a reactive object, deeply traverse all properties + return traverse(source) + } + + let effect: ReactiveEffect + let getter: () => any + let cleanup: (() => void) | undefined + let boundCleanup: typeof onWatcherCleanup + let forceTrigger = false + let isMultiSource = false + + if (isRef(source)) { + getter = () => source.value + forceTrigger = isShallow(source) + } else if (isReactive(source)) { + getter = () => reactiveGetter(source) + forceTrigger = true + } else if (isArray(source)) { + isMultiSource = true + forceTrigger = source.some(s => isReactive(s) || isShallow(s)) + getter = () => + source.map(s => { + if (isRef(s)) { + return s.value + } else if (isReactive(s)) { + return reactiveGetter(s) + } else if (isFunction(s)) { + return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s() + } else { + __DEV__ && warnInvalidSource(s) + } + }) + } else if (isFunction(source)) { + if (cb) { + // getter with cb + getter = call + ? () => call(source, WatchErrorCodes.WATCH_GETTER) + : (source as () => any) + } else { + // no cb -> simple effect + getter = () => { + if (cleanup) { + pauseTracking() + try { + cleanup() + } finally { + resetTracking() + } + } + const currentEffect = activeWatcher + activeWatcher = effect + try { + return call + ? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup]) + : source(boundCleanup) + } finally { + activeWatcher = currentEffect + } + } + } + } else { + getter = NOOP + __DEV__ && warnInvalidSource(source) + } + + if (cb && deep) { + const baseGetter = getter + const depth = deep === true ? Infinity : deep + getter = () => traverse(baseGetter(), depth) + } + + if (once) { + if (cb) { + const _cb = cb + cb = (...args) => { + _cb(...args) + effect.stop() + } + } else { + const _getter = getter + getter = () => { + _getter() + effect.stop() + } + } + } + + let oldValue: any = isMultiSource + ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) + : INITIAL_WATCHER_VALUE + + const job = (immediateFirstRun?: boolean) => { + if ( + !(effect.flags & EffectFlags.ACTIVE) || + (!effect.dirty && !immediateFirstRun) + ) { + return + } + if (cb) { + // watch(source, cb) + const newValue = effect.run() + if ( + deep || + forceTrigger || + (isMultiSource + ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) + : hasChanged(newValue, oldValue)) + ) { + // cleanup before running cb again + if (cleanup) { + cleanup() + } + const currentWatcher = activeWatcher + activeWatcher = effect + try { + const args = [ + newValue, + // pass undefined as the old value when it's changed for the first time + oldValue === INITIAL_WATCHER_VALUE + ? undefined + : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE + ? [] + : oldValue, + boundCleanup, + ] + call + ? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args) + : // @ts-expect-error + cb!(...args) + oldValue = newValue + } finally { + activeWatcher = currentWatcher + } + } + } else { + // watchEffect + effect.run() + } + } + + if (augmentJob) { + augmentJob(job) + } + + effect = new ReactiveEffect(getter) + + effect.scheduler = scheduler + ? () => scheduler(job, false) + : (job as EffectScheduler) + + boundCleanup = fn => onWatcherCleanup(fn, false, effect) + + cleanup = effect.onStop = () => { + const cleanups = cleanupMap.get(effect) + if (cleanups) { + if (call) { + call(cleanups, WatchErrorCodes.WATCH_CLEANUP) + } else { + for (const cleanup of cleanups) cleanup() + } + cleanupMap.delete(effect) + } + } + + if (__DEV__) { + effect.onTrack = options.onTrack + effect.onTrigger = options.onTrigger + } + + // initial run + if (cb) { + if (immediate) { + job(true) + } else { + oldValue = effect.run() + } + } else if (scheduler) { + scheduler(job.bind(null, true), true) + } else { + effect.run() + } + + const scope = getCurrentScope() + const watchHandle: WatchHandle = () => { + effect.stop() + if (scope) { + remove(scope.effects, effect) + } + } + + watchHandle.pause = effect.pause.bind(effect) + watchHandle.resume = effect.resume.bind(effect) + watchHandle.stop = watchHandle + + return watchHandle +} + +export function traverse( + value: unknown, + depth: number = Infinity, + seen?: Set, +): unknown { + if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { + return value + } + + seen = seen || new Set() + if (seen.has(value)) { + return value + } + seen.add(value) + depth-- + if (isRef(value)) { + traverse(value.value, depth, seen) + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], depth, seen) + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v: any) => { + traverse(v, depth, seen) + }) + } else if (isPlainObject(value)) { + for (const key in value) { + traverse(value[key], depth, seen) + } + for (const key of Object.getOwnPropertySymbols(value)) { + if (Object.prototype.propertyIsEnumerable.call(value, key)) { + traverse(value[key as any], depth, seen) + } + } + } + return value +} diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts index 67f0bc160e3..b1eb85f8a13 100644 --- a/packages/runtime-core/__tests__/apiWatch.spec.ts +++ b/packages/runtime-core/__tests__/apiWatch.spec.ts @@ -6,6 +6,7 @@ import { getCurrentInstance, nextTick, onErrorCaptured, + onWatcherCleanup, reactive, ref, watch, @@ -435,6 +436,35 @@ describe('api: watch', () => { expect(cleanup).toHaveBeenCalledTimes(2) }) + it('onWatcherCleanup', async () => { + const count = ref(0) + const cleanupEffect = vi.fn() + const cleanupWatch = vi.fn() + + const stopEffect = watchEffect(() => { + onWatcherCleanup(cleanupEffect) + count.value + }) + const stopWatch = watch(count, () => { + onWatcherCleanup(cleanupWatch) + }) + + count.value++ + await nextTick() + expect(cleanupEffect).toHaveBeenCalledTimes(1) + expect(cleanupWatch).toHaveBeenCalledTimes(0) + + count.value++ + await nextTick() + expect(cleanupEffect).toHaveBeenCalledTimes(2) + expect(cleanupWatch).toHaveBeenCalledTimes(1) + + stopEffect() + expect(cleanupEffect).toHaveBeenCalledTimes(3) + stopWatch() + expect(cleanupWatch).toHaveBeenCalledTimes(2) + }) + it('flush timing: pre (default)', async () => { const count = ref(0) const count2 = ref(0) diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 35b488052f9..3304f2c75b6 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -1,50 +1,28 @@ import { + type WatchOptions as BaseWatchOptions, type ComputedRef, type DebuggerOptions, - EffectFlags, - type EffectScheduler, - ReactiveEffect, - ReactiveFlags, type ReactiveMarker, type Ref, - getCurrentScope, - isReactive, - isRef, - isShallow, + type WatchHandle, + watch as baseWatch, } from '@vue/reactivity' import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' -import { - EMPTY_OBJ, - NOOP, - extend, - hasChanged, - isArray, - isFunction, - isMap, - isObject, - isPlainObject, - isSet, - isString, - remove, -} from '@vue/shared' +import { EMPTY_OBJ, NOOP, extend, isFunction, isString } from '@vue/shared' import { type ComponentInternalInstance, currentInstance, isInSSRComponentSetup, setCurrentInstance, } from './component' -import { - ErrorCodes, - callWithAsyncErrorHandling, - callWithErrorHandling, -} from './errorHandling' +import { callWithAsyncErrorHandling } from './errorHandling' import { queuePostRenderEffect } from './renderer' import { warn } from './warning' -import { DeprecationTypes } from './compat/compatConfig' -import { checkCompatEnabled, isCompatEnabled } from './compat/compatConfig' import type { ObjectWatchOptionItem } from './componentOptions' import { useSSRContext } from './helpers/useSsrContext' +export type { WatchHandle, WatchStopHandle } from '@vue/reactivity' + export type WatchEffect = (onCleanup: OnCleanup) => void export type WatchSource = Ref | ComputedRef | (() => T) @@ -77,14 +55,6 @@ export interface WatchOptions extends WatchOptionsBase { once?: boolean } -export type WatchStopHandle = () => void - -export interface WatchHandle extends WatchStopHandle { - pause: () => void - resume: () => void - stop: () => void -} - // Simple effect. export function watchEffect( effect: WatchEffect, @@ -96,7 +66,7 @@ export function watchEffect( export function watchPostEffect( effect: WatchEffect, options?: DebuggerOptions, -): WatchStopHandle { +): WatchHandle { return doWatch( effect, null, @@ -107,7 +77,7 @@ export function watchPostEffect( export function watchSyncEffect( effect: WatchEffect, options?: DebuggerOptions, -): WatchStopHandle { +): WatchHandle { return doWatch( effect, null, @@ -115,9 +85,6 @@ export function watchSyncEffect( ) } -// initial value for watchers to trigger on undefined initial values -const INITIAL_WATCHER_VALUE = {} - export type MultiWatchSources = (WatchSource | object)[] // overload: single source + cb @@ -178,22 +145,9 @@ export function watch = false>( function doWatch( source: WatchSource | WatchSource[] | WatchEffect | object, cb: WatchCallback | null, - { - immediate, - deep, - flush, - once, - onTrack, - onTrigger, - }: WatchOptions = EMPTY_OBJ, + options: WatchOptions = EMPTY_OBJ, ): WatchHandle { - if (cb && once) { - const _cb = cb - cb = (...args) => { - _cb(...args) - watchHandle() - } - } + const { immediate, deep, flush, once } = options if (__DEV__ && !cb) { if (immediate !== undefined) { @@ -216,230 +170,65 @@ function doWatch( } } - const warnInvalidSource = (s: unknown) => { - warn( - `Invalid watch source: `, - s, - `A watch source can only be a getter/effect function, a ref, ` + - `a reactive object, or an array of these types.`, - ) - } - - const instance = currentInstance - const reactiveGetter = (source: object) => { - // traverse will happen in wrapped getter below - if (deep) return source - // for `deep: false | 0` or shallow reactive, only traverse root-level properties - if (isShallow(source) || deep === false || deep === 0) - return traverse(source, 1) - // for `deep: undefined` on a reactive object, deeply traverse all properties - return traverse(source) - } - - let getter: () => any - let forceTrigger = false - let isMultiSource = false - - if (isRef(source)) { - getter = () => source.value - forceTrigger = isShallow(source) - } else if (isReactive(source)) { - getter = () => reactiveGetter(source) - forceTrigger = true - } else if (isArray(source)) { - isMultiSource = true - forceTrigger = source.some(s => isReactive(s) || isShallow(s)) - getter = () => - source.map(s => { - if (isRef(s)) { - return s.value - } else if (isReactive(s)) { - return reactiveGetter(s) - } else if (isFunction(s)) { - return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER) - } else { - __DEV__ && warnInvalidSource(s) - } - }) - } else if (isFunction(source)) { - if (cb) { - // getter with cb - getter = () => - callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER) - } else { - // no cb -> simple effect - getter = () => { - if (cleanup) { - cleanup() - } - return callWithAsyncErrorHandling( - source, - instance, - ErrorCodes.WATCH_CALLBACK, - [onCleanup], - ) - } - } - } else { - getter = NOOP - __DEV__ && warnInvalidSource(source) - } - - // 2.x array mutation watch compat - if (__COMPAT__ && cb && !deep) { - const baseGetter = getter - getter = () => { - const val = baseGetter() - if ( - isArray(val) && - checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance) - ) { - traverse(val) - } - return val - } - } - - if (cb && deep) { - const baseGetter = getter - const depth = deep === true ? Infinity : deep - getter = () => traverse(baseGetter(), depth) - } + const baseWatchOptions: BaseWatchOptions = extend({}, options) - let cleanup: (() => void) | undefined - let onCleanup: OnCleanup = (fn: () => void) => { - cleanup = effect.onStop = () => { - callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP) - cleanup = effect.onStop = undefined - } - } + if (__DEV__) baseWatchOptions.onWarn = warn - // in SSR there is no need to setup an actual effect, and it should be noop - // unless it's eager or sync flush let ssrCleanup: (() => void)[] | undefined if (__SSR__ && isInSSRComponentSetup) { - // we will also not call the invalidate callback (+ runner is not set up) - onCleanup = NOOP - if (!cb) { - getter() - } else if (immediate) { - callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [ - getter(), - isMultiSource ? [] : undefined, - onCleanup, - ]) - } if (flush === 'sync') { const ctx = useSSRContext()! ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = []) + } else if (!cb || immediate) { + // immediately watch or watchEffect + baseWatchOptions.once = true } else { - const watchHandle: WatchHandle = () => {} - watchHandle.stop = NOOP - watchHandle.resume = NOOP - watchHandle.pause = NOOP - return watchHandle + return { + stop: NOOP, + resume: NOOP, + pause: NOOP, + } as WatchHandle } } - let oldValue: any = isMultiSource - ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) - : INITIAL_WATCHER_VALUE - const job: SchedulerJob = (immediateFirstRun?: boolean) => { - if ( - !(effect.flags & EffectFlags.ACTIVE) || - (!effect.dirty && !immediateFirstRun) - ) { - return + const instance = currentInstance + baseWatchOptions.call = (fn, type, args) => + callWithAsyncErrorHandling(fn, instance, type, args) + + // scheduler + let isPre = false + if (flush === 'post') { + baseWatchOptions.scheduler = job => { + queuePostRenderEffect(job, instance && instance.suspense) } - if (cb) { - // watch(source, cb) - const newValue = effect.run() - if ( - deep || - forceTrigger || - (isMultiSource - ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) - : hasChanged(newValue, oldValue)) || - (__COMPAT__ && - isArray(newValue) && - isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance)) - ) { - // cleanup before running cb again - if (cleanup) { - cleanup() - } - callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [ - newValue, - // pass undefined as the old value when it's changed for the first time - oldValue === INITIAL_WATCHER_VALUE - ? undefined - : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE - ? [] - : oldValue, - onCleanup, - ]) - oldValue = newValue + } else if (flush !== 'sync') { + // default: 'pre' + isPre = true + baseWatchOptions.scheduler = (job, isFirstRun) => { + if (isFirstRun) { + job() + } else { + queueJob(job) } - } else { - // watchEffect - effect.run() } } - // important: mark the job as a watcher callback so that scheduler knows - // it is allowed to self-trigger (#1727) - if (cb) job.flags! |= SchedulerJobFlags.ALLOW_RECURSE - - const effect = new ReactiveEffect(getter) - - let scheduler: EffectScheduler - if (flush === 'sync') { - scheduler = job as any // the scheduler function gets called directly - } else if (flush === 'post') { - scheduler = () => queuePostRenderEffect(job, instance && instance.suspense) - } else { - // default: 'pre' - job.flags! |= SchedulerJobFlags.PRE - if (instance) { - job.id = instance.uid - job.i = instance + baseWatchOptions.augmentJob = (job: SchedulerJob) => { + // important: mark the job as a watcher callback so that scheduler knows + // it is allowed to self-trigger (#1727) + if (cb) { + job.flags! |= SchedulerJobFlags.ALLOW_RECURSE } - scheduler = () => queueJob(job) - } - effect.scheduler = scheduler - - const scope = getCurrentScope() - const watchHandle: WatchHandle = () => { - effect.stop() - if (scope) { - remove(scope.effects, effect) + if (isPre) { + job.flags! |= SchedulerJobFlags.PRE + if (instance) { + job.id = instance.uid + ;(job as SchedulerJob).i = instance + } } } - watchHandle.pause = effect.pause.bind(effect) - watchHandle.resume = effect.resume.bind(effect) - watchHandle.stop = watchHandle - - if (__DEV__) { - effect.onTrack = onTrack - effect.onTrigger = onTrigger - } - - // initial run - if (cb) { - if (immediate) { - job(true) - } else { - oldValue = effect.run() - } - } else if (flush === 'post') { - queuePostRenderEffect( - effect.run.bind(effect), - instance && instance.suspense, - ) - } else { - effect.run() - } + const watchHandle = baseWatch(source, cb, baseWatchOptions) if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle) return watchHandle @@ -481,41 +270,3 @@ export function createPathGetter(ctx: any, path: string) { return cur } } - -export function traverse( - value: unknown, - depth: number = Infinity, - seen?: Set, -): unknown { - if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { - return value - } - - seen = seen || new Set() - if (seen.has(value)) { - return value - } - seen.add(value) - depth-- - if (isRef(value)) { - traverse(value.value, depth, seen) - } else if (isArray(value)) { - for (let i = 0; i < value.length; i++) { - traverse(value[i], depth, seen) - } - } else if (isSet(value) || isMap(value)) { - value.forEach((v: any) => { - traverse(v, depth, seen) - }) - } else if (isPlainObject(value)) { - for (const key in value) { - traverse(value[key], depth, seen) - } - for (const key of Object.getOwnPropertySymbols(value)) { - if (Object.prototype.propertyIsEnumerable.call(value, key)) { - traverse(value[key as any], depth, seen) - } - } - } - return value -} diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index c59b0428f40..2a39f45b685 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -1,11 +1,12 @@ -import type { - Component, - ComponentInternalInstance, - ComponentInternalOptions, - ConcreteComponent, - Data, - InternalRenderFunction, - SetupContext, +import { + type Component, + type ComponentInternalInstance, + type ComponentInternalOptions, + type ConcreteComponent, + type Data, + type InternalRenderFunction, + type SetupContext, + currentInstance, } from './component' import { type LooseRequired, @@ -18,7 +19,7 @@ import { isPromise, isString, } from '@vue/shared' -import { type Ref, isRef } from '@vue/reactivity' +import { type Ref, getCurrentScope, isRef, traverse } from '@vue/reactivity' import { computed } from './apiComputed' import { type WatchCallback, @@ -71,7 +72,7 @@ import { warn } from './warning' import type { VNodeChild } from './vnode' import { callWithAsyncErrorHandling } from './errorHandling' import { deepMergeData } from './compat/data' -import { DeprecationTypes } from './compat/compatConfig' +import { DeprecationTypes, checkCompatEnabled } from './compat/compatConfig' import { type CompatConfig, isCompatEnabled, @@ -848,18 +849,55 @@ export function createWatcher( publicThis: ComponentPublicInstance, key: string, ): void { - const getter = key.includes('.') + let getter = key.includes('.') ? createPathGetter(publicThis, key) : () => (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 (isString(raw)) { const handler = ctx[raw] if (isFunction(handler)) { - watch(getter, handler as WatchCallback) + if (__COMPAT__) { + watch(getter, handler as WatchCallback, options) + } else { + watch(getter, handler as WatchCallback) + } } else if (__DEV__) { warn(`Invalid watch handler specified by key "${raw}"`, handler) } } else if (isFunction(raw)) { - watch(getter, raw.bind(publicThis)) + if (__COMPAT__) { + watch(getter, raw.bind(publicThis), options) + } else { + watch(getter, raw.bind(publicThis)) + } } else if (isObject(raw)) { if (isArray(raw)) { raw.forEach(r => createWatcher(r, ctx, publicThis, key)) @@ -868,7 +906,7 @@ export function createWatcher( ? raw.handler.bind(publicThis) : (ctx[raw.handler] as WatchCallback) if (isFunction(handler)) { - watch(getter, handler, raw) + watch(getter, handler, __COMPAT__ ? extend(raw, options) : raw) } else if (__DEV__) { warn(`Invalid watch handler specified by key "${raw.handler}"`, handler) } diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts index 964bb7dc208..f6a33f5a289 100644 --- a/packages/runtime-core/src/directives.ts +++ b/packages/runtime-core/src/directives.ts @@ -23,8 +23,7 @@ import { currentRenderingInstance } from './componentRenderContext' import { ErrorCodes, callWithAsyncErrorHandling } from './errorHandling' import type { ComponentPublicInstance } from './componentPublicInstance' import { mapCompatDirectiveHook } from './compat/customDirective' -import { pauseTracking, resetTracking } from '@vue/reactivity' -import { traverse } from './apiWatch' +import { pauseTracking, resetTracking, traverse } from '@vue/reactivity' export interface DirectiveBinding< Value = any, diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index 05cee54fffc..c4bdf0baccd 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -4,16 +4,20 @@ import type { ComponentInternalInstance } from './component' import { popWarningContext, pushWarningContext, warn } from './warning' import { EMPTY_OBJ, isArray, isFunction, isPromise } from '@vue/shared' import { LifecycleHooks } from './enums' +import { WatchErrorCodes } from '@vue/reactivity' // contexts where user provided function may be executed, in addition to // lifecycle hooks. export enum ErrorCodes { SETUP_FUNCTION, RENDER_FUNCTION, - WATCH_GETTER, - WATCH_CALLBACK, - WATCH_CLEANUP, - NATIVE_EVENT_HANDLER, + // The error codes for the watch have been transferred to the reactivity + // package along with baseWatch to maintain code compatibility. Hence, + // it is essential to keep these values unchanged. + // WATCH_GETTER, + // WATCH_CALLBACK, + // WATCH_CLEANUP, + NATIVE_EVENT_HANDLER = 5, COMPONENT_EVENT_HANDLER, VNODE_HOOK, DIRECTIVE_HOOK, @@ -27,7 +31,7 @@ export enum ErrorCodes { APP_UNMOUNT_CLEANUP, } -export const ErrorTypeStrings: Record = { +export const ErrorTypeStrings: Record = { [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook', [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook', [LifecycleHooks.CREATED]: 'created hook', @@ -44,9 +48,9 @@ export const ErrorTypeStrings: Record = { [LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook', [ErrorCodes.SETUP_FUNCTION]: 'setup function', [ErrorCodes.RENDER_FUNCTION]: 'render function', - [ErrorCodes.WATCH_GETTER]: 'watcher getter', - [ErrorCodes.WATCH_CALLBACK]: 'watcher callback', - [ErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function', + [WatchErrorCodes.WATCH_GETTER]: 'watcher getter', + [WatchErrorCodes.WATCH_CALLBACK]: 'watcher callback', + [WatchErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function', [ErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler', [ErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler', [ErrorCodes.VNODE_HOOK]: 'vnode hook', @@ -61,7 +65,7 @@ export const ErrorTypeStrings: Record = { [ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function', } -export type ErrorTypes = LifecycleHooks | ErrorCodes +export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes export function callWithErrorHandling( fn: Function, diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index f20baf2410b..68a6aac9027 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -28,6 +28,8 @@ export { // effect effect, stop, + getCurrentWatcher, + onWatcherCleanup, ReactiveEffect, // effect scope effectScope,