]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(reactivity): base `watch`, `getCurrentWatcher`, and `onWatcherCleanup` (#9927)
authorRizumu Ayaka <rizumu@ayaka.moe>
Tue, 20 Aug 2024 00:21:44 +0000 (08:21 +0800)
committerGitHub <noreply@github.com>
Tue, 20 Aug 2024 00:21:44 +0000 (08:21 +0800)
packages/reactivity/__tests__/watch.spec.ts [new file with mode: 0644]
packages/reactivity/src/index.ts
packages/reactivity/src/watch.ts [new file with mode: 0644]
packages/runtime-core/__tests__/apiWatch.spec.ts
packages/runtime-core/src/apiWatch.ts
packages/runtime-core/src/componentOptions.ts
packages/runtime-core/src/directives.ts
packages/runtime-core/src/errorHandling.ts
packages/runtime-core/src/index.ts

diff --git a/packages/reactivity/__tests__/watch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts
new file mode 100644 (file)
index 0000000..7a40781
--- /dev/null
@@ -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<any>
+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<number>
+    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<number>
+    let copyist: Ref<number>
+    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'])
+  })
+})
index b320f1f8cb07b2e54d03f5adb02b3c7b92e6268c..47302b224d73fada17c8055e8630eecf01f1f881 100644 (file)
@@ -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 (file)
index 0000000..2104896
--- /dev/null
@@ -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<T = any> = Ref<T> | ComputedRef<T> | (() => T)
+type WatchCallback<V = any, OV = any> = (
+  value: V,
+  oldValue: OV,
+  onCleanup: OnCleanup,
+) => any
+type OnCleanup = (cleanupFn: () => void) => void
+
+export interface WatchOptions<Immediate = boolean> 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<ReactiveEffect, (() => void)[]> = new WeakMap()
+let activeWatcher: ReactiveEffect | undefined = undefined
+
+/**
+ * Returns the current active effect if there is one.
+ */
+export function getCurrentWatcher(): ReactiveEffect<any> | 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>,
+): 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
+}
index 67f0bc160e30dce4d7cdab90818421460580c8b1..b1eb85f8a130cc382c0b2f446f20c7c5dfef8d9b 100644 (file)
@@ -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)
index 35b488052f9f7a4eecfbc72b0c5ad7f55825c049..3304f2c75b618c147dc3734a44f9ef34881c53d1 100644 (file)
@@ -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<T = any> = Ref<T, any> | ComputedRef<T> | (() => T)
@@ -77,14 +55,6 @@ export interface WatchOptions<Immediate = boolean> 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<unknown> | object)[]
 
 // overload: single source + cb
@@ -178,22 +145,9 @@ export function watch<T = any, Immediate extends Readonly<boolean> = 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>,
-): 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
-}
index c59b0428f40089aaf579dd95c90972a6f54b4ba2..2a39f45b685e81457d9b03f99b1642b224397e0d 100644 (file)
@@ -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)
       }
index 964bb7dc208fba3ec07da72b6e647b2cc29e6cc7..f6a33f5a289159837bd3e9f7bcca62358b42d3fa 100644 (file)
@@ -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,
index 05cee54fffccc0c2f3bd98b36cea068a638771f5..c4bdf0baccd6a15bbb20658e465cdb3021d9387f 100644 (file)
@@ -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<LifecycleHooks | ErrorCodes, string> = {
+export const ErrorTypeStrings: Record<ErrorTypes, string> = {
   [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook',
   [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook',
   [LifecycleHooks.CREATED]: 'created hook',
@@ -44,9 +48,9 @@ export const ErrorTypeStrings: Record<LifecycleHooks | ErrorCodes, string> = {
   [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<LifecycleHooks | ErrorCodes, string> = {
   [ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function',
 }
 
-export type ErrorTypes = LifecycleHooks | ErrorCodes
+export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes
 
 export function callWithErrorHandling(
   fn: Function,
index f20baf2410b1d1ece4ae64c3f9fbf6b90c26631a..68a6aac9027a539213ba5bd1293d682fa6b6043a 100644 (file)
@@ -28,6 +28,8 @@ export {
   // effect
   effect,
   stop,
+  getCurrentWatcher,
+  onWatcherCleanup,
   ReactiveEffect,
   // effect scope
   effectScope,