Skip to content

Commit

Permalink
feat(reactivity/watch): add pause/resume for ReactiveEffect, EffectSc…
Browse files Browse the repository at this point in the history
…ope, and WatchHandle (#9651)
  • Loading branch information
Alfred-Skyblue authored Aug 2, 2024
1 parent 55acabe commit 267093c
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 14 deletions.
44 changes: 44 additions & 0 deletions packages/reactivity/__tests__/effect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,4 +1282,48 @@ describe('reactivity/effect', () => {
).not.toHaveBeenWarned()
})
})

test('should pause/resume effect', () => {
const obj = reactive({ foo: 1 })
const fnSpy = vi.fn(() => obj.foo)
const runner = effect(fnSpy)

expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(1)

runner.effect.pause()
obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(2)

runner.effect.resume()
expect(fnSpy).toHaveBeenCalledTimes(2)
expect(obj.foo).toBe(2)

obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(3)
expect(obj.foo).toBe(3)
})

test('should be executed once immediately when resume is called', () => {
const obj = reactive({ foo: 1 })
const fnSpy = vi.fn(() => obj.foo)
const runner = effect(fnSpy)

expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(1)

runner.effect.pause()
obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(2)

obj.foo++
expect(fnSpy).toHaveBeenCalledTimes(1)
expect(obj.foo).toBe(3)

runner.effect.resume()
expect(fnSpy).toHaveBeenCalledTimes(2)
expect(obj.foo).toBe(3)
})
})
27 changes: 27 additions & 0 deletions packages/reactivity/__tests__/effectScope.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,4 +295,31 @@ describe('reactivity/effect/scope', () => {
expect(getCurrentScope()).toBe(parentScope)
})
})

it('should pause/resume EffectScope', async () => {
const counter = reactive({ num: 0 })
const fnSpy = vi.fn(() => counter.num)
const scope = new EffectScope()
scope.run(() => {
effect(fnSpy)
})

expect(fnSpy).toHaveBeenCalledTimes(1)

counter.num++
await nextTick()
expect(fnSpy).toHaveBeenCalledTimes(2)

scope.pause()
counter.num++
await nextTick()
expect(fnSpy).toHaveBeenCalledTimes(2)

counter.num++
await nextTick()
expect(fnSpy).toHaveBeenCalledTimes(2)

scope.resume()
expect(fnSpy).toHaveBeenCalledTimes(3)
})
})
21 changes: 20 additions & 1 deletion packages/reactivity/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export enum EffectFlags {
DIRTY = 1 << 4,
ALLOW_RECURSE = 1 << 5,
NO_BATCH = 1 << 6,
PAUSED = 1 << 7,
}

/**
Expand Down Expand Up @@ -107,6 +108,8 @@ export interface Link {
prevActiveLink?: Link
}

const pausedQueueEffects = new WeakSet<ReactiveEffect>()

export class ReactiveEffect<T = any>
implements Subscriber, ReactiveEffectOptions
{
Expand Down Expand Up @@ -142,6 +145,20 @@ export class ReactiveEffect<T = any>
}
}

pause() {
this.flags |= EffectFlags.PAUSED
}

resume() {
if (this.flags & EffectFlags.PAUSED) {
this.flags &= ~EffectFlags.PAUSED
if (pausedQueueEffects.has(this)) {
pausedQueueEffects.delete(this)
this.trigger()
}
}
}

/**
* @internal
*/
Expand Down Expand Up @@ -207,7 +224,9 @@ export class ReactiveEffect<T = any>
}

trigger() {
if (this.scheduler) {
if (this.flags & EffectFlags.PAUSED) {
pausedQueueEffects.add(this)
} else if (this.scheduler) {
this.scheduler()
} else {
this.runIfDirty()
Expand Down
35 changes: 35 additions & 0 deletions packages/reactivity/src/effectScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export class EffectScope {
*/
cleanups: (() => void)[] = []

private _isPaused = false

/**
* only assigned by undetached scope
* @internal
Expand Down Expand Up @@ -48,6 +50,39 @@ export class EffectScope {
return this._active
}

pause() {
if (this._active) {
this._isPaused = true
if (this.scopes) {
for (let i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].pause()
}
}
for (let i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].pause()
}
}
}

/**
* Resumes the effect scope, including all child scopes and effects.
*/
resume() {
if (this._active) {
if (this._isPaused) {
this._isPaused = false
if (this.scopes) {
for (let i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].resume()
}
}
for (let i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].resume()
}
}
}
}

run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope
Expand Down
39 changes: 39 additions & 0 deletions packages/runtime-core/__tests__/apiWatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1621,6 +1621,45 @@ describe('api: watch', () => {
expect(cb).toHaveBeenCalledTimes(4)
})

test('pause / resume', async () => {
const count = ref(0)
const cb = vi.fn()
const { pause, resume } = watch(count, cb)

count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function))

pause()
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function))

resume()
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(2)
expect(cb).toHaveBeenLastCalledWith(3, 1, expect.any(Function))

count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(3)
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function))

pause()
count.value++
await nextTick()
expect(cb).toHaveBeenCalledTimes(3)
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function))

resume()
await nextTick()
expect(cb).toHaveBeenCalledTimes(4)
expect(cb).toHaveBeenLastCalledWith(5, 4, expect.any(Function))
})

it('shallowReactive', async () => {
const state = shallowReactive({
msg: ref('hello'),
Expand Down
40 changes: 27 additions & 13 deletions packages/runtime-core/src/apiWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,17 @@ export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {

export type WatchStopHandle = () => void

export interface WatchHandle extends WatchStopHandle {
pause: () => void
resume: () => void
stop: () => void
}

// Simple effect.
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
): WatchHandle {
return doWatch(effect, null, options)
}

Expand Down Expand Up @@ -119,7 +125,7 @@ export function watch<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle

// overload: reactive array or tuple of multiple sources + cb
export function watch<
Expand All @@ -131,7 +137,7 @@ export function watch<
? WatchCallback<T, MaybeUndefined<T, Immediate>>
: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle

// overload: array of multiple sources + cb
export function watch<
Expand All @@ -141,7 +147,7 @@ export function watch<
sources: [...T],
cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle

// overload: watching reactive object w/ cb
export function watch<
Expand All @@ -151,14 +157,14 @@ export function watch<
source: T,
cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
options?: WatchOptions<Immediate>,
): WatchStopHandle
): WatchHandle

// implementation
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
source: T | WatchSource<T>,
cb: any,
options?: WatchOptions<Immediate>,
): WatchStopHandle {
): WatchHandle {
if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
Expand All @@ -180,12 +186,12 @@ function doWatch(
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle {
): WatchHandle {
if (cb && once) {
const _cb = cb
cb = (...args) => {
_cb(...args)
unwatch()
watchHandle()
}
}

Expand Down Expand Up @@ -327,7 +333,11 @@ function doWatch(
const ctx = useSSRContext()!
ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = [])
} else {
return NOOP
const watchHandle: WatchHandle = () => {}
watchHandle.stop = NOOP
watchHandle.resume = NOOP
watchHandle.pause = NOOP
return watchHandle
}
}

Expand Down Expand Up @@ -397,13 +407,17 @@ function doWatch(
effect.scheduler = scheduler

const scope = getCurrentScope()
const unwatch = () => {
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

if (__DEV__) {
effect.onTrack = onTrack
effect.onTrigger = onTrigger
Expand All @@ -425,8 +439,8 @@ function doWatch(
effect.run()
}

if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch)
return unwatch
if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle)
return watchHandle
}

// this.$watch
Expand All @@ -435,7 +449,7 @@ export function instanceWatch(
source: string | Function,
value: WatchCallback | ObjectWatchOptionItem,
options?: WatchOptions,
): WatchStopHandle {
): WatchHandle {
const publicThis = this.proxy as any
const getter = isString(source)
? source.includes('.')
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export type {
WatchOptionsBase,
WatchCallback,
WatchSource,
WatchHandle,
WatchStopHandle,
} from './apiWatch'
export type { InjectionKey } from './apiInject'
Expand Down

0 comments on commit 267093c

Please sign in to comment.