diff --git a/packages/reactivity/__tests__/baseWatch.spec.ts b/packages/reactivity/__tests__/baseWatch.spec.ts new file mode 100644 index 00000000000..3112a69981d --- /dev/null +++ b/packages/reactivity/__tests__/baseWatch.spec.ts @@ -0,0 +1,183 @@ +import { + BaseWatchErrorCodes, + EffectScope, + type Ref, + type SchedulerJob, + type WatchScheduler, + baseWatch, + onWatcherCleanup, + ref, +} from '../src' + +const queue: SchedulerJob[] = [] + +// these codes are a simple scheduler +let isFlushPending = false +const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise +const nextTick = (fn?: () => any) => + fn ? resolvedPromise.then(fn) : resolvedPromise +const scheduler: WatchScheduler = (job, effect, immediateFirstRun, hasCb) => { + if (immediateFirstRun) { + !hasCb && effect.run() + } else { + queue.push(() => job(immediateFirstRun)) + flushJobs() + } +} +const flushJobs = () => { + if (isFlushPending) return + isFlushPending = true + resolvedPromise.then(() => { + queue.forEach(job => job()) + queue.length = 0 + isFlushPending = false + }) +} + +describe('baseWatch', () => { + test('effect', () => { + let dummy: any + const source = ref(0) + baseWatch(() => { + dummy = source.value + }) + expect(dummy).toBe(0) + source.value++ + expect(dummy).toBe(1) + }) + + test('watch', () => { + let dummy: any + const source = ref(0) + baseWatch(source, () => { + dummy = source.value + }) + expect(dummy).toBe(undefined) + source.value++ + expect(dummy).toBe(1) + }) + + test('custom error handler', () => { + const onError = vi.fn() + + baseWatch( + () => { + throw 'oops in effect' + }, + null, + { onError }, + ) + + const source = ref(0) + const effect = baseWatch( + source, + () => { + onWatcherCleanup(() => { + throw 'oops in cleanup' + }) + throw 'oops in watch' + }, + { onError }, + ) + + expect(onError.mock.calls.length).toBe(1) + expect(onError.mock.calls[0]).toMatchObject([ + 'oops in effect', + BaseWatchErrorCodes.WATCH_CALLBACK, + ]) + + source.value++ + expect(onError.mock.calls.length).toBe(2) + expect(onError.mock.calls[1]).toMatchObject([ + 'oops in watch', + BaseWatchErrorCodes.WATCH_CALLBACK, + ]) + + effect!.stop() + source.value++ + expect(onError.mock.calls.length).toBe(3) + expect(onError.mock.calls[2]).toMatchObject([ + 'oops in cleanup', + BaseWatchErrorCodes.WATCH_CLEANUP, + ]) + }) + + test('baseWatch with onEffectCleanup', async () => { + let dummy = 0 + let source: Ref + const scope = new EffectScope() + + scope.run(() => { + source = ref(0) + baseWatch(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 onEffectCleanup', 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 + baseWatch( + () => { + const current = (copyist.value = source.value) + onWatcherCleanup(() => calls.push(`sync ${current}`)) + }, + null, + {}, + ) + // with scheduler + baseWatch( + () => { + 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/baseWatch.ts b/packages/reactivity/src/baseWatch.ts new file mode 100644 index 00000000000..145f894abed --- /dev/null +++ b/packages/reactivity/src/baseWatch.ts @@ -0,0 +1,405 @@ +import { + EMPTY_OBJ, + NOOP, + hasChanged, + isArray, + isFunction, + isMap, + isObject, + isPlainObject, + isPromise, + isSet, +} from '@vue/shared' +import { warn } from './warning' +import type { ComputedRef } from './computed' +import { ReactiveFlags } from './constants' +import { + type DebuggerOptions, + EffectFlags, + ReactiveEffect, + pauseTracking, + resetTracking, +} from './effect' +import { isReactive, isShallow } from './reactive' +import { type Ref, isRef } from './ref' +import { type SchedulerJob, SchedulerJobFlags } from './scheduler' + +// These errors were transferred from `packages/runtime-core/src/errorHandling.ts` +// along with baseWatch to maintain code compatibility. Hence, +// it is essential to keep these values unchanged. +export enum BaseWatchErrorCodes { + 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 BaseWatchOptions extends DebuggerOptions { + immediate?: Immediate + deep?: boolean + once?: boolean + scheduler?: WatchScheduler + onError?: HandleError + onWarn?: HandleWarn +} + +// initial value for watchers to trigger on undefined initial values +const INITIAL_WATCHER_VALUE = {} + +export type WatchScheduler = ( + job: SchedulerJob, + effect: ReactiveEffect, + immediateFirstRun: boolean, + hasCb: boolean, +) => void +export type HandleError = (err: unknown, type: BaseWatchErrorCodes) => void +export type HandleWarn = (msg: string, ...args: any[]) => void + +const DEFAULT_SCHEDULER: WatchScheduler = ( + job, + effect, + immediateFirstRun, + hasCb, +) => { + if (immediateFirstRun) { + !hasCb && effect.run() + } else { + job() + } +} +const DEFAULT_HANDLE_ERROR: HandleError = (err: unknown) => { + throw err +} + +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, +): void { + if (activeWatcher) { + const cleanups = + cleanupMap.get(activeWatcher) || + cleanupMap.set(activeWatcher, []).get(activeWatcher)! + cleanups.push(cleanupFn) + } else if (__DEV__ && !failSilently) { + warn( + `onWatcherCleanup() was called when there was no active watcher` + + ` to associate with.`, + ) + } +} + +export function baseWatch( + source: WatchSource | WatchSource[] | WatchEffect | object, + cb?: WatchCallback | null, + { + immediate, + deep, + once, + scheduler = DEFAULT_SCHEDULER, + onWarn = __DEV__ ? warn : NOOP, + onError = DEFAULT_HANDLE_ERROR, + onTrack, + onTrigger, + }: BaseWatchOptions = EMPTY_OBJ, +): ReactiveEffect { + const warnInvalidSource = (s: unknown) => { + onWarn( + `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 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, + onError, + BaseWatchErrorCodes.WATCH_GETTER, + ) + } else { + __DEV__ && warnInvalidSource(s) + } + }) + } else if (isFunction(source)) { + if (cb) { + // getter with cb + getter = () => + callWithErrorHandling(source, onError, BaseWatchErrorCodes.WATCH_GETTER) + } else { + // no cb -> simple effect + getter = () => { + if (cleanup) { + pauseTracking() + try { + cleanup() + } finally { + resetTracking() + } + } + const currentEffect = activeWatcher + activeWatcher = effect + try { + return callWithAsyncErrorHandling( + source, + onError, + BaseWatchErrorCodes.WATCH_CALLBACK, + [onWatcherCleanup], + ) + } 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: SchedulerJob = (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 { + callWithAsyncErrorHandling( + cb!, + onError, + BaseWatchErrorCodes.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, + onWatcherCleanup, + ], + ) + oldValue = newValue + } finally { + activeWatcher = currentWatcher + } + } + } 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 + + effect = new ReactiveEffect(getter) + effect.scheduler = () => scheduler(job, effect, false, !!cb) + + cleanup = effect.onStop = () => { + const cleanups = cleanupMap.get(effect) + if (cleanups) { + cleanups.forEach(cleanup => + callWithErrorHandling( + cleanup, + onError, + BaseWatchErrorCodes.WATCH_CLEANUP, + ), + ) + cleanupMap.delete(effect) + } + } + + if (__DEV__) { + effect.onTrack = onTrack + effect.onTrigger = onTrigger + } + + // initial run + if (cb) { + scheduler(job, effect, true, !!cb) + if (immediate) { + job(true) + } else { + oldValue = effect.run() + } + } else { + scheduler(job, effect, true, !!cb) + } + + return effect +} + +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 +} + +function callWithErrorHandling( + fn: Function, + handleError: HandleError, + type: BaseWatchErrorCodes, + args?: unknown[], +) { + let res + try { + res = args ? fn(...args) : fn() + } catch (err) { + handleError(err, type) + } + return res +} + +function callWithAsyncErrorHandling( + fn: Function | Function[], + handleError: HandleError, + type: BaseWatchErrorCodes, + args?: unknown[], +): any[] { + if (isFunction(fn)) { + const res = callWithErrorHandling(fn, handleError, type, args) + if (res && isPromise(res)) { + res.catch(err => { + handleError(err, type) + }) + } + return res + } + + const values = [] + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], handleError, type, args)) + } + return values +} diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index b320f1f8cb0..06ba0b05dd9 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -80,3 +80,13 @@ export { } from './effectScope' export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' +export { + baseWatch, + getCurrentWatcher, + traverse, + onWatcherCleanup, + BaseWatchErrorCodes, + type BaseWatchOptions, + type WatchScheduler, +} from './baseWatch' +export { type SchedulerJob, SchedulerJobFlags } from './scheduler' diff --git a/packages/reactivity/src/scheduler.ts b/packages/reactivity/src/scheduler.ts new file mode 100644 index 00000000000..709b12cbf52 --- /dev/null +++ b/packages/reactivity/src/scheduler.ts @@ -0,0 +1,30 @@ +export enum SchedulerJobFlags { + QUEUED = 1 << 0, + PRE = 1 << 1, + /** + * Indicates whether the effect is allowed to recursively trigger itself + * when managed by the scheduler. + * + * By default, a job cannot trigger itself because some built-in method calls, + * e.g. Array.prototype.push actually performs reads as well (#1740) which + * can lead to confusing infinite loops. + * The allowed cases are component update functions and watch callbacks. + * Component update functions may update child component props, which in turn + * trigger flush: "pre" watch callbacks that mutates state that the parent + * relies on (#1801). Watch callbacks doesn't track its dependencies so if it + * triggers itself again, it's likely intentional and it is the user's + * responsibility to perform recursive state mutation that eventually + * stabilizes (#1727). + */ + ALLOW_RECURSE = 1 << 2, + DISPOSED = 1 << 3, +} + +export interface SchedulerJob extends Function { + id?: number + /** + * flags can technically be undefined, but it can still be used in bitwise + * operations just like 0. + */ + flags?: SchedulerJobFlags +} diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts index 85afec24ceb..dc01bf0463c 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/__tests__/scheduler.spec.ts b/packages/runtime-core/__tests__/scheduler.spec.ts index 079ced4bd1a..f8ad893354e 100644 --- a/packages/runtime-core/__tests__/scheduler.spec.ts +++ b/packages/runtime-core/__tests__/scheduler.spec.ts @@ -1,6 +1,6 @@ +import { SchedulerJobFlags } from '@vue/reactivity' import { type SchedulerJob, - SchedulerJobFlags, flushPostFlushCbs, flushPreFlushCbs, invalidateJob, diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 2ad887a349d..069a181ac04 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -1,29 +1,23 @@ import { + type BaseWatchErrorCodes, + type BaseWatchOptions, type ComputedRef, type DebuggerOptions, - EffectFlags, - type EffectScheduler, - ReactiveEffect, - ReactiveFlags, type ReactiveMarker, type Ref, + baseWatch, getCurrentScope, - isReactive, - isRef, - isShallow, } from '@vue/reactivity' -import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' +import { + type SchedulerFactory, + createPreScheduler, + createSyncScheduler, +} from './scheduler' import { EMPTY_OBJ, NOOP, extend, - hasChanged, - isArray, isFunction, - isMap, - isObject, - isPlainObject, - isSet, isString, remove, } from '@vue/shared' @@ -33,15 +27,9 @@ import { isInSSRComponentSetup, setCurrentInstance, } from './component' -import { - ErrorCodes, - callWithAsyncErrorHandling, - callWithErrorHandling, -} from './errorHandling' -import { queuePostRenderEffect } from './renderer' +import { handleError as handleErrorWithInstance } from './errorHandling' +import { createPostRenderScheduler } 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' @@ -115,9 +103,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 @@ -175,25 +160,23 @@ export function watch = false>( return doWatch(source as any, cb, options) } +function getScheduler(flush: WatchOptionsBase['flush']): SchedulerFactory { + if (flush === 'post') { + return createPostRenderScheduler + } + if (flush === 'sync') { + return createSyncScheduler + } + // default: 'pre' + return createPreScheduler +} + 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,122 +199,18 @@ 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 extendOptions: BaseWatchOptions = {} - 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) - } - - let cleanup: (() => void) | undefined - let onCleanup: OnCleanup = (fn: () => void) => { - cleanup = effect.onStop = () => { - callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP) - cleanup = effect.onStop = undefined - } - } + if (__DEV__) extendOptions.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 + extendOptions.once = true } else { const watchHandle: WatchHandle = () => {} watchHandle.stop = NOOP @@ -341,71 +220,12 @@ function doWatch( } } - 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 - } - 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 { - // 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') { - effect.flags |= EffectFlags.NO_BATCH - 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 - scheduler = () => queueJob(job) - } - effect.scheduler = scheduler + const instance = currentInstance + extendOptions.onError = (err: unknown, type: BaseWatchErrorCodes) => + handleErrorWithInstance(err, instance, type) + extendOptions.scheduler = getScheduler(flush)(instance) + const effect = baseWatch(source, cb, extend({}, options, extendOptions)) const scope = getCurrentScope() const watchHandle: WatchHandle = () => { effect.stop() @@ -418,27 +238,6 @@ function doWatch( 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() - } - if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle) return watchHandle } @@ -479,41 +278,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 07955f84101..ba12749f9d6 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,47 @@ 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) + watch(getter, handler as WatchCallback, options) } else if (__DEV__) { warn(`Invalid watch handler specified by key "${raw}"`, handler) } } else if (isFunction(raw)) { - watch(getter, raw.bind(publicThis)) + watch(getter, raw.bind(publicThis), options) } else if (isObject(raw)) { if (isArray(raw)) { raw.forEach(r => createWatcher(r, ctx, publicThis, key)) @@ -868,7 +898,7 @@ export function createWatcher( ? raw.handler.bind(publicThis) : (ctx[raw.handler] as WatchCallback) if (isFunction(handler)) { - watch(getter, handler, raw) + watch(getter, handler, extend(raw, options)) } else if (__DEV__) { warn(`Invalid watch handler specified by key "${raw.handler}"`, handler) } diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index a31f28b2388..f40f3365ac0 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -14,13 +14,12 @@ import { } from '../vnode' import { warn } from '../warning' import { isKeepAlive } from './KeepAlive' -import { toRaw } from '@vue/reactivity' +import { SchedulerJobFlags, toRaw } from '@vue/reactivity' import { ErrorCodes, callWithAsyncErrorHandling } from '../errorHandling' import { PatchFlags, ShapeFlags, isArray, isFunction } from '@vue/shared' import { onBeforeUnmount, onMounted } from '../apiLifecycle' import { isTeleport } from './Teleport' import type { RendererElement } from '../renderer' -import { SchedulerJobFlags } from '../scheduler' type Hook void> = T | T[] 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..b7f955bbe4b 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 { BaseWatchErrorCodes } 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,9 @@ export enum ErrorCodes { APP_UNMOUNT_CLEANUP, } -export const ErrorTypeStrings: Record = { +export type ErrorTypes = LifecycleHooks | ErrorCodes | BaseWatchErrorCodes + +export const ErrorTypeStrings: Record = { [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook', [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook', [LifecycleHooks.CREATED]: 'created hook', @@ -44,9 +50,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', + [BaseWatchErrorCodes.WATCH_GETTER]: 'watcher getter', + [BaseWatchErrorCodes.WATCH_CALLBACK]: 'watcher callback', + [BaseWatchErrorCodes.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,8 +67,6 @@ export const ErrorTypeStrings: Record = { [ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function', } -export type ErrorTypes = LifecycleHooks | ErrorCodes - export function callWithErrorHandling( fn: Function, instance: ComponentInternalInstance | null | undefined, 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, diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 3d1cc6849c7..83d7926c89d 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -40,8 +40,8 @@ import { isReservedProp, } from '@vue/shared' import { + type SchedulerFactory, type SchedulerJob, - SchedulerJobFlags, type SchedulerJobs, flushPostFlushCbs, flushPreFlushCbs, @@ -52,6 +52,7 @@ import { import { EffectFlags, ReactiveEffect, + SchedulerJobFlags, pauseTracking, resetTracking, } from '@vue/reactivity' @@ -294,6 +295,18 @@ export const queuePostRenderEffect: ( : queueEffectWithSuspense : queuePostFlushCb +export const createPostRenderScheduler: SchedulerFactory = + instance => (job, effect, immediateFirstRun, hasCb) => { + if (!immediateFirstRun) { + queuePostRenderEffect(job, instance && instance.suspense) + } else if (!hasCb) { + queuePostRenderEffect( + effect.run.bind(effect), + instance && instance.suspense, + ) + } + } + /** * The createRenderer function accepts two generic arguments: * HostNode and HostElement, corresponding to Node and Element types in the diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index 354ebb3a4e8..4818fa52fd1 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -1,36 +1,14 @@ import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling' import { type Awaited, NOOP, isArray } from '@vue/shared' import { type ComponentInternalInstance, getComponentName } from './component' +import { + type SchedulerJob as BaseSchedulerJob, + EffectFlags, + SchedulerJobFlags, + type WatchScheduler, +} from '@vue/reactivity' -export enum SchedulerJobFlags { - QUEUED = 1 << 0, - PRE = 1 << 1, - /** - * Indicates whether the effect is allowed to recursively trigger itself - * when managed by the scheduler. - * - * By default, a job cannot trigger itself because some built-in method calls, - * e.g. Array.prototype.push actually performs reads as well (#1740) which - * can lead to confusing infinite loops. - * The allowed cases are component update functions and watch callbacks. - * Component update functions may update child component props, which in turn - * trigger flush: "pre" watch callbacks that mutates state that the parent - * relies on (#1801). Watch callbacks doesn't track its dependencies so if it - * triggers itself again, it's likely intentional and it is the user's - * responsibility to perform recursive state mutation that eventually - * stabilizes (#1727). - */ - ALLOW_RECURSE = 1 << 2, - DISPOSED = 1 << 3, -} - -export interface SchedulerJob extends Function { - id?: number - /** - * flags can technically be undefined, but it can still be used in bitwise - * operations just like 0. - */ - flags?: SchedulerJobFlags +export interface SchedulerJob extends BaseSchedulerJob { /** * Attached by renderer.ts when setting up a component's render effect * Used to obtain component information when reporting max recursive updates. @@ -300,3 +278,28 @@ function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) { } } } + +export type SchedulerFactory = ( + instance: ComponentInternalInstance | null, +) => WatchScheduler + +export const createSyncScheduler: SchedulerFactory = + instance => (job, effect, immediateFirstRun, hasCb) => { + if (immediateFirstRun) { + effect.flags |= EffectFlags.NO_BATCH + if (!hasCb) effect.run() + } else { + job() + } + } + +export const createPreScheduler: SchedulerFactory = + instance => (job, effect, immediateFirstRun, hasCb) => { + if (!immediateFirstRun) { + job.flags! |= SchedulerJobFlags.PRE + if (instance) job.id = instance.uid + queueJob(job) + } else if (!hasCb) { + effect.run() + } + }