From 1730ab4261b1e3dd6b8460453d59470726f76ce3 Mon Sep 17 00:00:00 2001 From: Rizumu Ayaka Date: Fri, 9 Aug 2024 22:09:37 +0800 Subject: [PATCH 01/12] feat(runtime-core, reactivity): baseWatch + onWatcherCleanup Squashed commit of the following: commit dad9d0f58e06bb7b333bcdfd7ffafdb0b79b3118 Author: Rizumu Ayaka Date: Thu Mar 14 20:35:19 2024 +0800 feat: scheduler in reactivity commit 406c750258b450bff50160b05bbcf15017bd8a52 Author: Rizumu Ayaka Date: Thu Mar 14 14:08:12 2024 +0800 fix: revert export alias commit 74996b68a621ae89acb660a58a452058bff85d43 Author: Rizumu Ayaka Date: Wed Mar 13 22:21:27 2024 +0800 test: onWatcherCleanup in apiWatch commit a5769e17af1e9ffdc19beb8c8241a1e2f0c68154 Author: Rizumu Ayaka Date: Wed Mar 13 22:09:43 2024 +0800 fix: remove elusive code for once commit 589cd114b9e78e384473656bf4dc04ca3358a0be Author: Rizumu Ayaka Date: Wed Mar 13 21:14:34 2024 +0800 fix: errors related to immediateFirstRun commit 3694745229269ff77a6be2f34de5a2ceab50d05b Author: Rizumu Ayaka Date: Tue Mar 12 18:16:52 2024 +0800 refactor: rename to onWatcherCleanup, getCurrentWatcher, remove middleware commit b3f45d27e75f35b5fc465fb61f46daeb60a77332 Merge: 60a1b9740 9a936aaec Author: Rizumu Ayaka Date: Thu Mar 7 22:23:13 2024 +0800 chore: merge branch 'minor' into feat/onEffectCleanup-and-baseWatch commit 60a1b974044a7b3cf326808104a6a05bf6122f69 Author: Rizumu Ayaka Date: Tue Jan 9 20:45:31 2024 +0800 feat: middleware in baseWatch commit 2fdda650602be6599d0c973b3fae340fae23258b Merge: 39f07cd9e 2701355e8 Author: Rizumu Ayaka Date: Mon Jan 8 17:40:54 2024 +0800 Merge branch 'main' into feat/onEffectCleanup-and-baseWatch commit 39f07cd9ef93e6f0ccd0670fd963507846ed6c25 Author: Rizumu Ayaka Date: Mon Jan 8 17:40:18 2024 +0800 fix: should export getCurrentEffect function commit 770c21d431aed9b5156e32411a8cc5825da41cac Author: Rizumu Ayaka Date: Sat Jan 6 00:07:41 2024 +0800 fix: sync code changes according to the review in PR vuejs/core-vapor#82 commit a6eb0436f91a31fa62a8e822272636339c9625eb Merge: 8dd0c1fb2 0275dd329 Author: Rizumu Ayaka Date: Fri Jan 5 23:43:02 2024 +0800 chore: merge branch 'main' into feat/onEffectCleanup-and-baseWatch commit 8dd0c1fb20fa2fdecd1768050f9b9ece64b76530 Merge: 221363426 274f6f71f Author: Rizumu Ayaka Date: Sun Dec 31 20:30:29 2023 +0800 chore: merge remote-tracking branch 'origin/minor' into feat/onEffectCleanup-and-baseWatch commit 2213634269ddc4574d639f732d2611caf0b8c043 Author: Rizumu Ayaka Date: Sun Dec 31 19:21:12 2023 +0800 refactor: simplify unwatch implementation commit f44ef0b74b325b19cac6ec0fd8c17b03c6ae0337 Author: Rizumu Ayaka Date: Sun Dec 31 18:45:04 2023 +0800 feat: implement getCurrentEffect commit a078ad11c4cd865318c70493d261922fdba974c4 Author: Rizumu Ayaka Date: Thu Dec 28 21:28:28 2023 +0800 chore: rename handleWarn to onWarn commit 90fd005a5aacec27d6253d09cd11eaed58a892cb Author: Rizumu Ayaka Date: Thu Dec 28 21:05:03 2023 +0800 chore: organize exports commit e9555ce1f4bdffd8aef62cf7a1cde1fc8a365a52 Author: Rizumu Ayaka Date: Thu Dec 28 20:36:56 2023 +0800 test: baseWatch commit d99e9a63839754bf5adc25aef805685753c8d947 Author: Rizumu Ayaka Date: Thu Dec 28 20:04:42 2023 +0800 test: onEffectCleanup in runtime-core commit 56c87ec1eea8ae55cd5f0ae1cee2566ff638ef08 Author: Rizumu Ayaka Date: Thu Dec 28 19:44:43 2023 +0800 test: baseWatch with onEffectCleanup commit 7c5f05accb83963ffc0fec78b7bbe8189a7b1ab5 Merge: a8dc8e63a 75dbbb80a Author: Rizumu Ayaka Date: Thu Dec 28 17:32:00 2023 +0800 Merge branch 'minor' of https://github.com/vuejs/core into feat/onEffectCleanup-and-baseWatch commit a8dc8e63a992d5ea93750ca20ad1092516d3d4ca Author: Rizumu Ayaka Date: Wed Dec 27 22:43:17 2023 +0800 fix: tracked in cleanup commit b57405c0004fdc1f95ab2945d5639eb96eea0bf4 Author: Rizumu Ayaka Date: Wed Dec 27 20:28:49 2023 +0800 fix: treeshaking error commit 4d04f5ec11fc43f2bb2c23192bd901ba9c0f88e5 Author: Rizumu Ayaka Date: Wed Dec 27 20:19:53 2023 +0800 fix: treeshaking error commit d1f001b96c7472ee0825abe2d2bdbc2e4450d607 Author: Rizumu Ayaka Date: Wed Dec 27 20:10:05 2023 +0800 fix: lint commit 97179ed4669d7973947afede0bcd9be3d063d443 Merge: 2aef6099a 918306988 Author: Rizumu Ayaka Date: Tue Dec 26 23:24:47 2023 +0800 chore: merge branch 'minor' of https://github.com/vuejs/core into feat/onEffectCleanup-and-baseWatch commit 2aef6099a35f61eaf35ac62b62b4255188f93442 Author: Rizumu Ayaka Date: Tue Dec 26 22:19:26 2023 +0800 fix: some cases for server-renderer commit db4463cd3273d0b13c3ef11448435a84a2e8bcec Author: Rizumu Ayaka Date: Tue Dec 26 21:40:12 2023 +0800 fix: export onEffectCleanup commit 409b52a6bf87c9fdb89b062bffe2cbce776e7218 Author: Rizumu Ayaka Date: Tue Dec 26 21:31:27 2023 +0800 refactor: the watch API with baseWatch commit d8682e8f75376aca044b999cfc82aa51a0065a60 Author: Rizumu Ayaka Date: Mon Dec 25 22:09:38 2023 +0800 feat: initial code of baseWatch commit f1fe01e7f0083c2eda65edde915143768fa5f839 Author: Rizumu Ayaka Date: Mon Dec 25 20:50:35 2023 +0800 refactor: externalized COMPAT case --- .../reactivity/__tests__/baseWatch.spec.ts | 183 ++++++++ packages/reactivity/src/baseWatch.ts | 405 ++++++++++++++++++ packages/reactivity/src/index.ts | 10 + packages/reactivity/src/scheduler.ts | 30 ++ .../runtime-core/__tests__/apiWatch.spec.ts | 30 ++ .../runtime-core/__tests__/scheduler.spec.ts | 2 +- packages/runtime-core/src/apiWatch.ts | 305 ++----------- packages/runtime-core/src/componentOptions.ts | 58 ++- .../src/components/BaseTransition.ts | 3 +- packages/runtime-core/src/directives.ts | 3 +- packages/runtime-core/src/errorHandling.ts | 24 +- packages/runtime-core/src/index.ts | 2 + packages/runtime-core/src/renderer.ts | 15 +- packages/runtime-core/src/scheduler.ts | 61 +-- 14 files changed, 800 insertions(+), 331 deletions(-) create mode 100644 packages/reactivity/__tests__/baseWatch.spec.ts create mode 100644 packages/reactivity/src/baseWatch.ts create mode 100644 packages/reactivity/src/scheduler.ts 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() + } + } From eb56f13f5666c10443ef8d8b1eefe95c6db79e1f Mon Sep 17 00:00:00 2001 From: Rizumu Ayaka Date: Sat, 10 Aug 2024 00:15:12 +0800 Subject: [PATCH 02/12] chore: move code for improved history tracking --- packages/runtime-core/src/errorHandling.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index b7f955bbe4b..572fdc2e28d 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -31,8 +31,6 @@ export enum ErrorCodes { APP_UNMOUNT_CLEANUP, } -export type ErrorTypes = LifecycleHooks | ErrorCodes | BaseWatchErrorCodes - export const ErrorTypeStrings: Record = { [LifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook', [LifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook', @@ -67,6 +65,8 @@ export const ErrorTypeStrings: Record = { [ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function', } +export type ErrorTypes = LifecycleHooks | ErrorCodes | BaseWatchErrorCodes + export function callWithErrorHandling( fn: Function, instance: ComponentInternalInstance | null | undefined, From c81751b3f48ce4295891ef158440f157602d4c21 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 19 Aug 2024 22:04:55 +0800 Subject: [PATCH 03/12] refactor: simplify scheduler --- .../reactivity/__tests__/baseWatch.spec.ts | 12 +++--- packages/reactivity/src/baseWatch.ts | 33 +++++---------- packages/runtime-core/src/apiWatch.ts | 42 +++++++++++-------- packages/runtime-core/src/renderer.ts | 13 ------ packages/runtime-core/src/scheduler.ts | 28 ------------- 5 files changed, 42 insertions(+), 86 deletions(-) diff --git a/packages/reactivity/__tests__/baseWatch.spec.ts b/packages/reactivity/__tests__/baseWatch.spec.ts index 3112a69981d..c53bb129690 100644 --- a/packages/reactivity/__tests__/baseWatch.spec.ts +++ b/packages/reactivity/__tests__/baseWatch.spec.ts @@ -11,19 +11,21 @@ import { const queue: SchedulerJob[] = [] -// these codes are a simple scheduler +// 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, effect, immediateFirstRun, hasCb) => { - if (immediateFirstRun) { - !hasCb && effect.run() + +const scheduler: WatchScheduler = (job, isFirstRun) => { + if (isFirstRun) { + job(true) } else { - queue.push(() => job(immediateFirstRun)) + queue.push(job) flushJobs() } } + const flushJobs = () => { if (isFlushPending) return isFlushPending = true diff --git a/packages/reactivity/src/baseWatch.ts b/packages/reactivity/src/baseWatch.ts index 145f894abed..9ea318acb0e 100644 --- a/packages/reactivity/src/baseWatch.ts +++ b/packages/reactivity/src/baseWatch.ts @@ -16,6 +16,7 @@ import { ReactiveFlags } from './constants' import { type DebuggerOptions, EffectFlags, + type EffectScheduler, ReactiveEffect, pauseTracking, resetTracking, @@ -54,27 +55,10 @@ export interface BaseWatchOptions extends DebuggerOptions { // 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 WatchScheduler = (job: SchedulerJob, isFirstRun: 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 } @@ -120,7 +104,7 @@ export function baseWatch( immediate, deep, once, - scheduler = DEFAULT_SCHEDULER, + scheduler, onWarn = __DEV__ ? warn : NOOP, onError = DEFAULT_HANDLE_ERROR, onTrack, @@ -292,7 +276,11 @@ export function baseWatch( if (cb) job.flags! |= SchedulerJobFlags.ALLOW_RECURSE effect = new ReactiveEffect(getter) - effect.scheduler = () => scheduler(job, effect, false, !!cb) + if (scheduler) { + effect.scheduler = () => scheduler(job, false) + } else { + effect.scheduler = job as EffectScheduler + } cleanup = effect.onStop = () => { const cleanups = cleanupMap.get(effect) @@ -315,14 +303,15 @@ export function baseWatch( // initial run if (cb) { - scheduler(job, effect, true, !!cb) if (immediate) { job(true) } else { oldValue = effect.run() } + } else if (scheduler) { + scheduler(job.bind(null, true), true) } else { - scheduler(job, effect, true, !!cb) + effect.run() } return effect diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 069a181ac04..24263c15fde 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -5,14 +5,11 @@ import { type DebuggerOptions, type ReactiveMarker, type Ref, + SchedulerJobFlags, baseWatch, getCurrentScope, } from '@vue/reactivity' -import { - type SchedulerFactory, - createPreScheduler, - createSyncScheduler, -} from './scheduler' +import { type SchedulerJob, queueJob } from './scheduler' import { EMPTY_OBJ, NOOP, @@ -28,7 +25,7 @@ import { setCurrentInstance, } from './component' import { handleError as handleErrorWithInstance } from './errorHandling' -import { createPostRenderScheduler } from './renderer' +import { queuePostRenderEffect } from './renderer' import { warn } from './warning' import type { ObjectWatchOptionItem } from './componentOptions' import { useSSRContext } from './helpers/useSsrContext' @@ -160,17 +157,6 @@ 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, @@ -223,7 +209,27 @@ function doWatch( const instance = currentInstance extendOptions.onError = (err: unknown, type: BaseWatchErrorCodes) => handleErrorWithInstance(err, instance, type) - extendOptions.scheduler = getScheduler(flush)(instance) + + // scheduler + if (flush === 'post') { + extendOptions.scheduler = job => { + queuePostRenderEffect(job, instance && instance.suspense) + } + } else if (flush !== 'sync') { + // default: 'pre' + extendOptions.scheduler = (job, isFirstRun) => { + if (isFirstRun) { + job() + } else { + job.flags! |= SchedulerJobFlags.PRE + if (instance) { + job.id = instance.uid + ;(job as SchedulerJob).i = instance + } + queueJob(job) + } + } + } const effect = baseWatch(source, cb, extend({}, options, extendOptions)) const scope = getCurrentScope() diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 4f6904567e3..fc1fbc8d429 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -40,7 +40,6 @@ import { isReservedProp, } from '@vue/shared' import { - type SchedulerFactory, type SchedulerJob, type SchedulerJobs, flushPostFlushCbs, @@ -295,18 +294,6 @@ 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 db99e0a078d..cbfcd30838f 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -4,7 +4,6 @@ import { type ComponentInternalInstance, getComponentName } from './component' import { type SchedulerJob as BaseSchedulerJob, SchedulerJobFlags, - type WatchScheduler, } from '@vue/reactivity' export interface SchedulerJob extends BaseSchedulerJob { @@ -254,30 +253,3 @@ function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob) { } } } - -export type SchedulerFactory = ( - instance: ComponentInternalInstance | null, -) => WatchScheduler - -export const createSyncScheduler: SchedulerFactory = - () => (job, effect, immediateFirstRun, hasCb) => { - if (immediateFirstRun) { - 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 - ;(job as SchedulerJob).i = instance - } - queueJob(job) - } else if (!hasCb) { - effect.run() - } - } From ceedce4e41878b961029d7aac7bcbb59e9543782 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 19 Aug 2024 22:17:57 +0800 Subject: [PATCH 04/12] refactor: rename baseWatch to just watch --- .../{baseWatch.spec.ts => watch.spec.ts} | 30 ++++++++-------- packages/reactivity/src/index.ts | 8 ++--- .../reactivity/src/{baseWatch.ts => watch.ts} | 36 ++++++++----------- packages/runtime-core/src/apiWatch.ts | 20 +++++------ packages/runtime-core/src/errorHandling.ts | 10 +++--- 5 files changed, 48 insertions(+), 56 deletions(-) rename packages/reactivity/__tests__/{baseWatch.spec.ts => watch.spec.ts} (89%) rename packages/reactivity/src/{baseWatch.ts => watch.ts} (91%) diff --git a/packages/reactivity/__tests__/baseWatch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts similarity index 89% rename from packages/reactivity/__tests__/baseWatch.spec.ts rename to packages/reactivity/__tests__/watch.spec.ts index c53bb129690..9b344912900 100644 --- a/packages/reactivity/__tests__/baseWatch.spec.ts +++ b/packages/reactivity/__tests__/watch.spec.ts @@ -1,12 +1,12 @@ import { - BaseWatchErrorCodes, EffectScope, type Ref, type SchedulerJob, + WatchErrorCodes, type WatchScheduler, - baseWatch, onWatcherCleanup, ref, + watch, } from '../src' const queue: SchedulerJob[] = [] @@ -36,11 +36,11 @@ const flushJobs = () => { }) } -describe('baseWatch', () => { +describe('watch', () => { test('effect', () => { let dummy: any const source = ref(0) - baseWatch(() => { + watch(() => { dummy = source.value }) expect(dummy).toBe(0) @@ -48,10 +48,10 @@ describe('baseWatch', () => { expect(dummy).toBe(1) }) - test('watch', () => { + test('with callback', () => { let dummy: any const source = ref(0) - baseWatch(source, () => { + watch(source, () => { dummy = source.value }) expect(dummy).toBe(undefined) @@ -62,7 +62,7 @@ describe('baseWatch', () => { test('custom error handler', () => { const onError = vi.fn() - baseWatch( + watch( () => { throw 'oops in effect' }, @@ -71,7 +71,7 @@ describe('baseWatch', () => { ) const source = ref(0) - const effect = baseWatch( + const effect = watch( source, () => { onWatcherCleanup(() => { @@ -85,14 +85,14 @@ describe('baseWatch', () => { expect(onError.mock.calls.length).toBe(1) expect(onError.mock.calls[0]).toMatchObject([ 'oops in effect', - BaseWatchErrorCodes.WATCH_CALLBACK, + WatchErrorCodes.WATCH_CALLBACK, ]) source.value++ expect(onError.mock.calls.length).toBe(2) expect(onError.mock.calls[1]).toMatchObject([ 'oops in watch', - BaseWatchErrorCodes.WATCH_CALLBACK, + WatchErrorCodes.WATCH_CALLBACK, ]) effect!.stop() @@ -100,18 +100,18 @@ describe('baseWatch', () => { expect(onError.mock.calls.length).toBe(3) expect(onError.mock.calls[2]).toMatchObject([ 'oops in cleanup', - BaseWatchErrorCodes.WATCH_CLEANUP, + WatchErrorCodes.WATCH_CLEANUP, ]) }) - test('baseWatch with onEffectCleanup', async () => { + test('watch with onEffectCleanup', async () => { let dummy = 0 let source: Ref const scope = new EffectScope() scope.run(() => { source = ref(0) - baseWatch(onCleanup => { + watch(onCleanup => { source.value onCleanup(() => (dummy += 2)) @@ -145,7 +145,7 @@ describe('baseWatch', () => { source = ref(0) copyist = ref(0) // sync by default - baseWatch( + watch( () => { const current = (copyist.value = source.value) onWatcherCleanup(() => calls.push(`sync ${current}`)) @@ -154,7 +154,7 @@ describe('baseWatch', () => { {}, ) // with scheduler - baseWatch( + watch( () => { const current = copyist.value onWatcherCleanup(() => calls.push(`post ${current}`)) diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 06ba0b05dd9..335e97c8677 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -81,12 +81,12 @@ export { export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' export { TrackOpTypes, TriggerOpTypes, ReactiveFlags } from './constants' export { - baseWatch, + watch, getCurrentWatcher, traverse, onWatcherCleanup, - BaseWatchErrorCodes, - type BaseWatchOptions, + WatchErrorCodes, + type WatchOptions, type WatchScheduler, -} from './baseWatch' +} from './watch' export { type SchedulerJob, SchedulerJobFlags } from './scheduler' diff --git a/packages/reactivity/src/baseWatch.ts b/packages/reactivity/src/watch.ts similarity index 91% rename from packages/reactivity/src/baseWatch.ts rename to packages/reactivity/src/watch.ts index 9ea318acb0e..3a2e67d17c8 100644 --- a/packages/reactivity/src/baseWatch.ts +++ b/packages/reactivity/src/watch.ts @@ -26,9 +26,9 @@ 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, +// to @vue/reactivity to allow co-location with the moved base watch logic, hence // it is essential to keep these values unchanged. -export enum BaseWatchErrorCodes { +export enum WatchErrorCodes { WATCH_GETTER = 2, WATCH_CALLBACK, WATCH_CLEANUP, @@ -43,9 +43,9 @@ type WatchCallback = ( ) => any type OnCleanup = (cleanupFn: () => void) => void -export interface BaseWatchOptions extends DebuggerOptions { +export interface WatchOptions extends DebuggerOptions { immediate?: Immediate - deep?: boolean + deep?: boolean | number once?: boolean scheduler?: WatchScheduler onError?: HandleError @@ -56,7 +56,7 @@ export interface BaseWatchOptions extends DebuggerOptions { const INITIAL_WATCHER_VALUE = {} export type WatchScheduler = (job: SchedulerJob, isFirstRun: boolean) => void -export type HandleError = (err: unknown, type: BaseWatchErrorCodes) => void +export type HandleError = (err: unknown, type: WatchErrorCodes) => void export type HandleWarn = (msg: string, ...args: any[]) => void const DEFAULT_HANDLE_ERROR: HandleError = (err: unknown) => { @@ -97,7 +97,7 @@ export function onWatcherCleanup( } } -export function baseWatch( +export function watch( source: WatchSource | WatchSource[] | WatchEffect | object, cb?: WatchCallback | null, { @@ -109,7 +109,7 @@ export function baseWatch( onError = DEFAULT_HANDLE_ERROR, onTrack, onTrigger, - }: BaseWatchOptions = EMPTY_OBJ, + }: WatchOptions = EMPTY_OBJ, ): ReactiveEffect { const warnInvalidSource = (s: unknown) => { onWarn( @@ -152,11 +152,7 @@ export function baseWatch( } else if (isReactive(s)) { return reactiveGetter(s) } else if (isFunction(s)) { - return callWithErrorHandling( - s, - onError, - BaseWatchErrorCodes.WATCH_GETTER, - ) + return callWithErrorHandling(s, onError, WatchErrorCodes.WATCH_GETTER) } else { __DEV__ && warnInvalidSource(s) } @@ -165,7 +161,7 @@ export function baseWatch( if (cb) { // getter with cb getter = () => - callWithErrorHandling(source, onError, BaseWatchErrorCodes.WATCH_GETTER) + callWithErrorHandling(source, onError, WatchErrorCodes.WATCH_GETTER) } else { // no cb -> simple effect getter = () => { @@ -183,7 +179,7 @@ export function baseWatch( return callWithAsyncErrorHandling( source, onError, - BaseWatchErrorCodes.WATCH_CALLBACK, + WatchErrorCodes.WATCH_CALLBACK, [onWatcherCleanup], ) } finally { @@ -248,7 +244,7 @@ export function baseWatch( callWithAsyncErrorHandling( cb!, onError, - BaseWatchErrorCodes.WATCH_CALLBACK, + WatchErrorCodes.WATCH_CALLBACK, [ newValue, // pass undefined as the old value when it's changed for the first time @@ -286,11 +282,7 @@ export function baseWatch( const cleanups = cleanupMap.get(effect) if (cleanups) { cleanups.forEach(cleanup => - callWithErrorHandling( - cleanup, - onError, - BaseWatchErrorCodes.WATCH_CLEANUP, - ), + callWithErrorHandling(cleanup, onError, WatchErrorCodes.WATCH_CLEANUP), ) cleanupMap.delete(effect) } @@ -358,7 +350,7 @@ export function traverse( function callWithErrorHandling( fn: Function, handleError: HandleError, - type: BaseWatchErrorCodes, + type: WatchErrorCodes, args?: unknown[], ) { let res @@ -373,7 +365,7 @@ function callWithErrorHandling( function callWithAsyncErrorHandling( fn: Function | Function[], handleError: HandleError, - type: BaseWatchErrorCodes, + type: WatchErrorCodes, args?: unknown[], ): any[] { if (isFunction(fn)) { diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 24263c15fde..3cad946e981 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -1,12 +1,12 @@ import { - type BaseWatchErrorCodes, - type BaseWatchOptions, + type WatchOptions as BaseWatchOptions, type ComputedRef, type DebuggerOptions, type ReactiveMarker, type Ref, SchedulerJobFlags, - baseWatch, + type WatchErrorCodes, + watch as baseWatch, getCurrentScope, } from '@vue/reactivity' import { type SchedulerJob, queueJob } from './scheduler' @@ -185,9 +185,9 @@ function doWatch( } } - const extendOptions: BaseWatchOptions = {} + const baseWatchOptions: BaseWatchOptions = extend({}, options) - if (__DEV__) extendOptions.onWarn = warn + if (__DEV__) baseWatchOptions.onWarn = warn let ssrCleanup: (() => void)[] | undefined if (__SSR__ && isInSSRComponentSetup) { @@ -196,7 +196,7 @@ function doWatch( ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = []) } else if (!cb || immediate) { // immediately watch or watchEffect - extendOptions.once = true + baseWatchOptions.once = true } else { const watchHandle: WatchHandle = () => {} watchHandle.stop = NOOP @@ -207,17 +207,17 @@ function doWatch( } const instance = currentInstance - extendOptions.onError = (err: unknown, type: BaseWatchErrorCodes) => + baseWatchOptions.onError = (err: unknown, type: WatchErrorCodes) => handleErrorWithInstance(err, instance, type) // scheduler if (flush === 'post') { - extendOptions.scheduler = job => { + baseWatchOptions.scheduler = job => { queuePostRenderEffect(job, instance && instance.suspense) } } else if (flush !== 'sync') { // default: 'pre' - extendOptions.scheduler = (job, isFirstRun) => { + baseWatchOptions.scheduler = (job, isFirstRun) => { if (isFirstRun) { job() } else { @@ -231,7 +231,7 @@ function doWatch( } } - const effect = baseWatch(source, cb, extend({}, options, extendOptions)) + const effect = baseWatch(source, cb, baseWatchOptions) const scope = getCurrentScope() const watchHandle: WatchHandle = () => { effect.stop() diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts index 572fdc2e28d..c4bdf0baccd 100644 --- a/packages/runtime-core/src/errorHandling.ts +++ b/packages/runtime-core/src/errorHandling.ts @@ -4,7 +4,7 @@ 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' +import { WatchErrorCodes } from '@vue/reactivity' // contexts where user provided function may be executed, in addition to // lifecycle hooks. @@ -48,9 +48,9 @@ export const ErrorTypeStrings: Record = { [LifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook', [ErrorCodes.SETUP_FUNCTION]: 'setup function', [ErrorCodes.RENDER_FUNCTION]: 'render function', - [BaseWatchErrorCodes.WATCH_GETTER]: 'watcher getter', - [BaseWatchErrorCodes.WATCH_CALLBACK]: 'watcher callback', - [BaseWatchErrorCodes.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', @@ -65,7 +65,7 @@ export const ErrorTypeStrings: Record = { [ErrorCodes.APP_UNMOUNT_CLEANUP]: 'app unmount cleanup function', } -export type ErrorTypes = LifecycleHooks | ErrorCodes | BaseWatchErrorCodes +export type ErrorTypes = LifecycleHooks | ErrorCodes | WatchErrorCodes export function callWithErrorHandling( fn: Function, From 6f6e541f9568ce620c6dca0afaa4e93a34995b64 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 19 Aug 2024 22:39:38 +0800 Subject: [PATCH 05/12] refactor: move scheduler types back to core --- packages/reactivity/__tests__/watch.spec.ts | 5 ++- packages/reactivity/src/index.ts | 1 - packages/reactivity/src/scheduler.ts | 30 ---------------- packages/reactivity/src/watch.ts | 14 ++++---- .../runtime-core/__tests__/scheduler.spec.ts | 2 +- packages/runtime-core/src/apiWatch.ts | 25 ++++++++++---- .../src/components/BaseTransition.ts | 3 +- packages/runtime-core/src/renderer.ts | 2 +- packages/runtime-core/src/scheduler.ts | 34 ++++++++++++++++--- 9 files changed, 61 insertions(+), 55 deletions(-) delete mode 100644 packages/reactivity/src/scheduler.ts diff --git a/packages/reactivity/__tests__/watch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts index 9b344912900..d510dd56b8c 100644 --- a/packages/reactivity/__tests__/watch.spec.ts +++ b/packages/reactivity/__tests__/watch.spec.ts @@ -1,7 +1,6 @@ import { EffectScope, type Ref, - type SchedulerJob, WatchErrorCodes, type WatchScheduler, onWatcherCleanup, @@ -9,7 +8,7 @@ import { watch, } from '../src' -const queue: SchedulerJob[] = [] +const queue: (() => void)[] = [] // a simple scheduler for testing purposes let isFlushPending = false @@ -19,7 +18,7 @@ const nextTick = (fn?: () => any) => const scheduler: WatchScheduler = (job, isFirstRun) => { if (isFirstRun) { - job(true) + job() } else { queue.push(job) flushJobs() diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 335e97c8677..a95e2455b94 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -89,4 +89,3 @@ export { type WatchOptions, type WatchScheduler, } from './watch' -export { type SchedulerJob, SchedulerJobFlags } from './scheduler' diff --git a/packages/reactivity/src/scheduler.ts b/packages/reactivity/src/scheduler.ts deleted file mode 100644 index 709b12cbf52..00000000000 --- a/packages/reactivity/src/scheduler.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 3a2e67d17c8..8bddc298f86 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -23,7 +23,6 @@ import { } 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` // to @vue/reactivity to allow co-location with the moved base watch logic, hence @@ -48,6 +47,7 @@ export interface WatchOptions extends DebuggerOptions { deep?: boolean | number once?: boolean scheduler?: WatchScheduler + augmentJob?: (job: (...args: any[]) => void) => void onError?: HandleError onWarn?: HandleWarn } @@ -55,7 +55,7 @@ export interface WatchOptions extends DebuggerOptions { // initial value for watchers to trigger on undefined initial values const INITIAL_WATCHER_VALUE = {} -export type WatchScheduler = (job: SchedulerJob, isFirstRun: boolean) => void +export type WatchScheduler = (job: () => void, isFirstRun: boolean) => void export type HandleError = (err: unknown, type: WatchErrorCodes) => void export type HandleWarn = (msg: string, ...args: any[]) => void @@ -105,6 +105,7 @@ export function watch( deep, once, scheduler, + augmentJob, onWarn = __DEV__ ? warn : NOOP, onError = DEFAULT_HANDLE_ERROR, onTrack, @@ -217,7 +218,8 @@ export function watch( let oldValue: any = isMultiSource ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE - const job: SchedulerJob = (immediateFirstRun?: boolean) => { + + const job = (immediateFirstRun?: boolean) => { if ( !(effect.flags & EffectFlags.ACTIVE) || (!effect.dirty && !immediateFirstRun) @@ -267,9 +269,9 @@ export function watch( } } - // 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 + if (augmentJob) { + augmentJob(job) + } effect = new ReactiveEffect(getter) if (scheduler) { diff --git a/packages/runtime-core/__tests__/scheduler.spec.ts b/packages/runtime-core/__tests__/scheduler.spec.ts index ef752061ec2..5c5b04673ab 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, nextTick, diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 3cad946e981..4b807e741a7 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -4,12 +4,11 @@ import { type DebuggerOptions, type ReactiveMarker, type Ref, - SchedulerJobFlags, type WatchErrorCodes, watch as baseWatch, getCurrentScope, } from '@vue/reactivity' -import { type SchedulerJob, queueJob } from './scheduler' +import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' import { EMPTY_OBJ, NOOP, @@ -211,26 +210,38 @@ function doWatch( handleErrorWithInstance(err, instance, type) // scheduler + let isPre = false if (flush === 'post') { baseWatchOptions.scheduler = job => { queuePostRenderEffect(job, instance && instance.suspense) } } else if (flush !== 'sync') { // default: 'pre' + isPre = true baseWatchOptions.scheduler = (job, isFirstRun) => { if (isFirstRun) { job() } else { - job.flags! |= SchedulerJobFlags.PRE - if (instance) { - job.id = instance.uid - ;(job as SchedulerJob).i = instance - } queueJob(job) } } } + 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 + } + if (isPre) { + job.flags! |= SchedulerJobFlags.PRE + if (instance) { + job.id = instance.uid + ;(job as SchedulerJob).i = instance + } + } + } + const effect = baseWatch(source, cb, baseWatchOptions) const scope = getCurrentScope() const watchHandle: WatchHandle = () => { diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index f40f3365ac0..a31f28b2388 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -14,12 +14,13 @@ import { } from '../vnode' import { warn } from '../warning' import { isKeepAlive } from './KeepAlive' -import { SchedulerJobFlags, toRaw } from '@vue/reactivity' +import { 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/renderer.ts b/packages/runtime-core/src/renderer.ts index fc1fbc8d429..11736e9dff2 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -41,6 +41,7 @@ import { } from '@vue/shared' import { type SchedulerJob, + SchedulerJobFlags, type SchedulerJobs, flushPostFlushCbs, flushPreFlushCbs, @@ -50,7 +51,6 @@ import { import { EffectFlags, ReactiveEffect, - SchedulerJobFlags, pauseTracking, resetTracking, } from '@vue/reactivity' diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index cbfcd30838f..aa12b6896a7 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -1,12 +1,36 @@ 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, - SchedulerJobFlags, -} from '@vue/reactivity' -export interface SchedulerJob extends BaseSchedulerJob { +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 /** * Attached by renderer.ts when setting up a component's render effect * Used to obtain component information when reporting max recursive updates. From 19d88f0eff253181b04b813bd843a6d1f935f887 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 19 Aug 2024 22:50:58 +0800 Subject: [PATCH 06/12] refactor: reduce compat impact on code paths --- packages/runtime-core/src/componentOptions.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index 3278398b964..2a39f45b685 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -884,12 +884,20 @@ export function createWatcher( if (isString(raw)) { const handler = ctx[raw] if (isFunction(handler)) { - watch(getter, handler as WatchCallback, options) + 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), options) + 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)) @@ -898,7 +906,7 @@ export function createWatcher( ? raw.handler.bind(publicThis) : (ctx[raw.handler] as WatchCallback) if (isFunction(handler)) { - watch(getter, handler, extend(raw, options)) + watch(getter, handler, __COMPAT__ ? extend(raw, options) : raw) } else if (__DEV__) { warn(`Invalid watch handler specified by key "${raw.handler}"`, handler) } From 0bf3ad8d49c8c519204197dcd05899f56e595f24 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 19 Aug 2024 23:16:41 +0800 Subject: [PATCH 07/12] refactor: mark augmentJob as internal --- packages/reactivity/src/watch.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 8bddc298f86..c9b8963fd52 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -47,9 +47,12 @@ export interface WatchOptions extends DebuggerOptions { deep?: boolean | number once?: boolean scheduler?: WatchScheduler - augmentJob?: (job: (...args: any[]) => void) => void onError?: HandleError onWarn?: HandleWarn + /** + * @internal + */ + augmentJob?: (job: (...args: any[]) => void) => void } // initial value for watchers to trigger on undefined initial values @@ -105,11 +108,11 @@ export function watch( deep, once, scheduler, - augmentJob, onWarn = __DEV__ ? warn : NOOP, onError = DEFAULT_HANDLE_ERROR, onTrack, onTrigger, + augmentJob, }: WatchOptions = EMPTY_OBJ, ): ReactiveEffect { const warnInvalidSource = (s: unknown) => { From 342567f241bac38b802baa29f39613ba2629ad14 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 20 Aug 2024 00:07:38 +0800 Subject: [PATCH 08/12] refactor: remove duplicated error handling wrapper --- packages/reactivity/__tests__/watch.spec.ts | 18 +++- packages/reactivity/src/watch.ts | 111 +++++++------------- packages/runtime-core/src/apiWatch.ts | 7 +- 3 files changed, 54 insertions(+), 82 deletions(-) diff --git a/packages/reactivity/__tests__/watch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts index d510dd56b8c..d852d76ec8c 100644 --- a/packages/reactivity/__tests__/watch.spec.ts +++ b/packages/reactivity/__tests__/watch.spec.ts @@ -2,6 +2,7 @@ import { EffectScope, type Ref, WatchErrorCodes, + type WatchOptions, type WatchScheduler, onWatcherCleanup, ref, @@ -58,15 +59,26 @@ describe('watch', () => { expect(dummy).toBe(1) }) - test('custom error handler', () => { + 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, - { onError }, + { call }, ) const source = ref(0) @@ -78,7 +90,7 @@ describe('watch', () => { }) throw 'oops in watch' }, - { onError }, + { call }, ) expect(onError.mock.calls.length).toBe(1) diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index c9b8963fd52..0668f83a79a 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -7,7 +7,6 @@ import { isMap, isObject, isPlainObject, - isPromise, isSet, } from '@vue/shared' import { warn } from './warning' @@ -47,24 +46,25 @@ export interface WatchOptions extends DebuggerOptions { deep?: boolean | number once?: boolean scheduler?: WatchScheduler - onError?: HandleError - onWarn?: HandleWarn + onWarn?: (msg: string, ...args: any[]) => void /** * @internal */ augmentJob?: (job: (...args: any[]) => void) => void + /** + * @internal + */ + call?: ( + fn: Function | Function[], + type: WatchErrorCodes, + args?: unknown[], + ) => void } // initial value for watchers to trigger on undefined initial values const INITIAL_WATCHER_VALUE = {} export type WatchScheduler = (job: () => void, isFirstRun: boolean) => void -export type HandleError = (err: unknown, type: WatchErrorCodes) => void -export type HandleWarn = (msg: string, ...args: any[]) => void - -const DEFAULT_HANDLE_ERROR: HandleError = (err: unknown) => { - throw err -} const cleanupMap: WeakMap void)[]> = new WeakMap() let activeWatcher: ReactiveEffect | undefined = undefined @@ -109,10 +109,10 @@ export function watch( once, scheduler, onWarn = __DEV__ ? warn : NOOP, - onError = DEFAULT_HANDLE_ERROR, onTrack, onTrigger, augmentJob, + call, }: WatchOptions = EMPTY_OBJ, ): ReactiveEffect { const warnInvalidSource = (s: unknown) => { @@ -156,7 +156,7 @@ export function watch( } else if (isReactive(s)) { return reactiveGetter(s) } else if (isFunction(s)) { - return callWithErrorHandling(s, onError, WatchErrorCodes.WATCH_GETTER) + return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s() } else { __DEV__ && warnInvalidSource(s) } @@ -164,8 +164,9 @@ export function watch( } else if (isFunction(source)) { if (cb) { // getter with cb - getter = () => - callWithErrorHandling(source, onError, WatchErrorCodes.WATCH_GETTER) + getter = call + ? () => call(source, WatchErrorCodes.WATCH_GETTER) + : (source as () => any) } else { // no cb -> simple effect getter = () => { @@ -180,12 +181,9 @@ export function watch( const currentEffect = activeWatcher activeWatcher = effect try { - return callWithAsyncErrorHandling( - source, - onError, - WatchErrorCodes.WATCH_CALLBACK, - [onWatcherCleanup], - ) + return call + ? call(source, WatchErrorCodes.WATCH_CALLBACK, [onWatcherCleanup]) + : source(onWatcherCleanup) } finally { activeWatcher = currentEffect } @@ -246,21 +244,20 @@ export function watch( const currentWatcher = activeWatcher activeWatcher = effect try { - callWithAsyncErrorHandling( - cb!, - onError, - WatchErrorCodes.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, - ], - ) + 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, + onWatcherCleanup, + ] + call + ? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args) + : // @ts-expect-error + cb!(...args) oldValue = newValue } finally { activeWatcher = currentWatcher @@ -286,9 +283,11 @@ export function watch( cleanup = effect.onStop = () => { const cleanups = cleanupMap.get(effect) if (cleanups) { - cleanups.forEach(cleanup => - callWithErrorHandling(cleanup, onError, WatchErrorCodes.WATCH_CLEANUP), - ) + if (call) { + call(cleanups, WatchErrorCodes.WATCH_CLEANUP) + } else { + for (const cleanup of cleanups) cleanup() + } cleanupMap.delete(effect) } } @@ -351,41 +350,3 @@ export function traverse( } return value } - -function callWithErrorHandling( - fn: Function, - handleError: HandleError, - type: WatchErrorCodes, - 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: WatchErrorCodes, - 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/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 4b807e741a7..b156b71ae60 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -4,7 +4,6 @@ import { type DebuggerOptions, type ReactiveMarker, type Ref, - type WatchErrorCodes, watch as baseWatch, getCurrentScope, } from '@vue/reactivity' @@ -23,7 +22,7 @@ import { isInSSRComponentSetup, setCurrentInstance, } from './component' -import { handleError as handleErrorWithInstance } from './errorHandling' +import { callWithAsyncErrorHandling } from './errorHandling' import { queuePostRenderEffect } from './renderer' import { warn } from './warning' import type { ObjectWatchOptionItem } from './componentOptions' @@ -206,8 +205,8 @@ function doWatch( } const instance = currentInstance - baseWatchOptions.onError = (err: unknown, type: WatchErrorCodes) => - handleErrorWithInstance(err, instance, type) + baseWatchOptions.call = (fn, type, args) => + callWithAsyncErrorHandling(fn, instance, type, args) // scheduler let isPre = false From e24a421f68eeb22297564a51f456ac24918f12a0 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 20 Aug 2024 00:17:51 +0800 Subject: [PATCH 09/12] refactor: move WatchHandle into reactivity --- packages/reactivity/src/index.ts | 2 ++ packages/reactivity/src/watch.ts | 26 +++++++++++++-- packages/runtime-core/src/apiWatch.ts | 48 +++++++-------------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index a95e2455b94..47302b224d7 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -88,4 +88,6 @@ export { 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 index 0668f83a79a..ef62af11dd9 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -8,6 +8,7 @@ import { isObject, isPlainObject, isSet, + remove, } from '@vue/shared' import { warn } from './warning' import type { ComputedRef } from './computed' @@ -22,6 +23,7 @@ import { } 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 @@ -61,6 +63,14 @@ export interface WatchOptions extends DebuggerOptions { ) => 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 = {} @@ -114,7 +124,7 @@ export function watch( augmentJob, call, }: WatchOptions = EMPTY_OBJ, -): ReactiveEffect { +): WatchHandle { const warnInvalidSource = (s: unknown) => { onWarn( `Invalid watch source: `, @@ -310,7 +320,19 @@ export function watch( effect.run() } - return effect + 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( diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index b156b71ae60..3304f2c75b6 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -4,18 +4,11 @@ import { type DebuggerOptions, type ReactiveMarker, type Ref, + type WatchHandle, watch as baseWatch, - getCurrentScope, } from '@vue/reactivity' import { type SchedulerJob, SchedulerJobFlags, queueJob } from './scheduler' -import { - EMPTY_OBJ, - NOOP, - extend, - isFunction, - isString, - remove, -} from '@vue/shared' +import { EMPTY_OBJ, NOOP, extend, isFunction, isString } from '@vue/shared' import { type ComponentInternalInstance, currentInstance, @@ -28,6 +21,8 @@ import { warn } from './warning' 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) @@ -60,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, @@ -79,7 +66,7 @@ export function watchEffect( export function watchPostEffect( effect: WatchEffect, options?: DebuggerOptions, -): WatchStopHandle { +): WatchHandle { return doWatch( effect, null, @@ -90,7 +77,7 @@ export function watchPostEffect( export function watchSyncEffect( effect: WatchEffect, options?: DebuggerOptions, -): WatchStopHandle { +): WatchHandle { return doWatch( effect, null, @@ -196,11 +183,11 @@ function doWatch( // 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 } } @@ -241,18 +228,7 @@ function doWatch( } } - const effect = baseWatch(source, cb, baseWatchOptions) - 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 + const watchHandle = baseWatch(source, cb, baseWatchOptions) if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle) return watchHandle From 4b92fca0a51c9841dd944d21197c5f3dc3488db5 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 20 Aug 2024 00:22:38 +0800 Subject: [PATCH 10/12] chore: fix dts --- packages/reactivity/src/watch.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index ef62af11dd9..65d4af0ef71 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -113,20 +113,12 @@ export function onWatcherCleanup( export function watch( source: WatchSource | WatchSource[] | WatchEffect | object, cb?: WatchCallback | null, - { - immediate, - deep, - once, - scheduler, - onWarn = __DEV__ ? warn : NOOP, - onTrack, - onTrigger, - augmentJob, - call, - }: WatchOptions = EMPTY_OBJ, + options: WatchOptions = EMPTY_OBJ, ): WatchHandle { + const { immediate, deep, once, scheduler, augmentJob, call } = options + const warnInvalidSource = (s: unknown) => { - onWarn( + ;(options.onWarn || warn)( `Invalid watch source: `, s, `A watch source can only be a getter/effect function, a ref, ` + @@ -303,8 +295,8 @@ export function watch( } if (__DEV__) { - effect.onTrack = onTrack - effect.onTrigger = onTrigger + effect.onTrack = options.onTrack + effect.onTrigger = options.onTrigger } // initial run From 84033f0a11ae69edba53e8338c43b1f2d09a121c Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 20 Aug 2024 01:06:22 +0800 Subject: [PATCH 11/12] fix: make cleanup passed into watch callback bound --- packages/reactivity/__tests__/watch.spec.ts | 4 ++-- packages/reactivity/src/watch.ts | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/reactivity/__tests__/watch.spec.ts b/packages/reactivity/__tests__/watch.spec.ts index d852d76ec8c..7a4078166b7 100644 --- a/packages/reactivity/__tests__/watch.spec.ts +++ b/packages/reactivity/__tests__/watch.spec.ts @@ -115,7 +115,7 @@ describe('watch', () => { ]) }) - test('watch with onEffectCleanup', async () => { + test('watch with onWatcherCleanup', async () => { let dummy = 0 let source: Ref const scope = new EffectScope() @@ -146,7 +146,7 @@ describe('watch', () => { expect(dummy).toBe(30) }) - test('nested calls to baseWatch and onEffectCleanup', async () => { + test('nested calls to baseWatch and onWatcherCleanup', async () => { let calls: string[] = [] let source: Ref let copyist: Ref diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 65d4af0ef71..3d5fc2cb658 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -96,11 +96,11 @@ export function getCurrentWatcher(): ReactiveEffect | undefined { export function onWatcherCleanup( cleanupFn: () => void, failSilently = false, + owner: ReactiveEffect | undefined = activeWatcher, ): void { - if (activeWatcher) { - const cleanups = - cleanupMap.get(activeWatcher) || - cleanupMap.set(activeWatcher, []).get(activeWatcher)! + if (owner) { + let cleanups = cleanupMap.get(owner) + if (!cleanups) cleanupMap.set(owner, (cleanups = [])) cleanups.push(cleanupFn) } else if (__DEV__ && !failSilently) { warn( @@ -137,6 +137,7 @@ export function watch( } let effect: ReactiveEffect + let boundCleanup: typeof onWatcherCleanup let getter: () => any let cleanup: (() => void) | undefined let forceTrigger = false @@ -184,8 +185,8 @@ export function watch( activeWatcher = effect try { return call - ? call(source, WatchErrorCodes.WATCH_CALLBACK, [onWatcherCleanup]) - : source(onWatcherCleanup) + ? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup]) + : source(boundCleanup) } finally { activeWatcher = currentEffect } @@ -254,7 +255,7 @@ export function watch( : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue, - onWatcherCleanup, + boundCleanup, ] call ? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args) @@ -276,6 +277,7 @@ export function watch( } effect = new ReactiveEffect(getter) + boundCleanup = fn => onWatcherCleanup(fn, false, effect) if (scheduler) { effect.scheduler = () => scheduler(job, false) } else { From f0f26475454a63255f3b6e18b21928492af02125 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 20 Aug 2024 08:18:59 +0800 Subject: [PATCH 12/12] chore: minor tweaks --- packages/reactivity/src/watch.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 3d5fc2cb658..2104896b7ae 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -137,9 +137,9 @@ export function watch( } let effect: ReactiveEffect - let boundCleanup: typeof onWatcherCleanup let getter: () => any let cleanup: (() => void) | undefined + let boundCleanup: typeof onWatcherCleanup let forceTrigger = false let isMultiSource = false @@ -277,12 +277,12 @@ export function watch( } effect = new ReactiveEffect(getter) + + effect.scheduler = scheduler + ? () => scheduler(job, false) + : (job as EffectScheduler) + boundCleanup = fn => onWatcherCleanup(fn, false, effect) - if (scheduler) { - effect.scheduler = () => scheduler(job, false) - } else { - effect.scheduler = job as EffectScheduler - } cleanup = effect.onStop = () => { const cleanups = cleanupMap.get(effect)