Skip to content

Commit 267093c

Browse files
authoredAug 2, 2024··
feat(reactivity/watch): add pause/resume for ReactiveEffect, EffectScope, and WatchHandle (#9651)
1 parent 55acabe commit 267093c

File tree

7 files changed

+193
-14
lines changed

7 files changed

+193
-14
lines changed
 

‎packages/reactivity/__tests__/effect.spec.ts

+44
Original file line numberDiff line numberDiff line change
@@ -1282,4 +1282,48 @@ describe('reactivity/effect', () => {
12821282
).not.toHaveBeenWarned()
12831283
})
12841284
})
1285+
1286+
test('should pause/resume effect', () => {
1287+
const obj = reactive({ foo: 1 })
1288+
const fnSpy = vi.fn(() => obj.foo)
1289+
const runner = effect(fnSpy)
1290+
1291+
expect(fnSpy).toHaveBeenCalledTimes(1)
1292+
expect(obj.foo).toBe(1)
1293+
1294+
runner.effect.pause()
1295+
obj.foo++
1296+
expect(fnSpy).toHaveBeenCalledTimes(1)
1297+
expect(obj.foo).toBe(2)
1298+
1299+
runner.effect.resume()
1300+
expect(fnSpy).toHaveBeenCalledTimes(2)
1301+
expect(obj.foo).toBe(2)
1302+
1303+
obj.foo++
1304+
expect(fnSpy).toHaveBeenCalledTimes(3)
1305+
expect(obj.foo).toBe(3)
1306+
})
1307+
1308+
test('should be executed once immediately when resume is called', () => {
1309+
const obj = reactive({ foo: 1 })
1310+
const fnSpy = vi.fn(() => obj.foo)
1311+
const runner = effect(fnSpy)
1312+
1313+
expect(fnSpy).toHaveBeenCalledTimes(1)
1314+
expect(obj.foo).toBe(1)
1315+
1316+
runner.effect.pause()
1317+
obj.foo++
1318+
expect(fnSpy).toHaveBeenCalledTimes(1)
1319+
expect(obj.foo).toBe(2)
1320+
1321+
obj.foo++
1322+
expect(fnSpy).toHaveBeenCalledTimes(1)
1323+
expect(obj.foo).toBe(3)
1324+
1325+
runner.effect.resume()
1326+
expect(fnSpy).toHaveBeenCalledTimes(2)
1327+
expect(obj.foo).toBe(3)
1328+
})
12851329
})

‎packages/reactivity/__tests__/effectScope.spec.ts

+27
Original file line numberDiff line numberDiff line change
@@ -295,4 +295,31 @@ describe('reactivity/effect/scope', () => {
295295
expect(getCurrentScope()).toBe(parentScope)
296296
})
297297
})
298+
299+
it('should pause/resume EffectScope', async () => {
300+
const counter = reactive({ num: 0 })
301+
const fnSpy = vi.fn(() => counter.num)
302+
const scope = new EffectScope()
303+
scope.run(() => {
304+
effect(fnSpy)
305+
})
306+
307+
expect(fnSpy).toHaveBeenCalledTimes(1)
308+
309+
counter.num++
310+
await nextTick()
311+
expect(fnSpy).toHaveBeenCalledTimes(2)
312+
313+
scope.pause()
314+
counter.num++
315+
await nextTick()
316+
expect(fnSpy).toHaveBeenCalledTimes(2)
317+
318+
counter.num++
319+
await nextTick()
320+
expect(fnSpy).toHaveBeenCalledTimes(2)
321+
322+
scope.resume()
323+
expect(fnSpy).toHaveBeenCalledTimes(3)
324+
})
298325
})

‎packages/reactivity/src/effect.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export enum EffectFlags {
4646
DIRTY = 1 << 4,
4747
ALLOW_RECURSE = 1 << 5,
4848
NO_BATCH = 1 << 6,
49+
PAUSED = 1 << 7,
4950
}
5051

5152
/**
@@ -107,6 +108,8 @@ export interface Link {
107108
prevActiveLink?: Link
108109
}
109110

111+
const pausedQueueEffects = new WeakSet<ReactiveEffect>()
112+
110113
export class ReactiveEffect<T = any>
111114
implements Subscriber, ReactiveEffectOptions
112115
{
@@ -142,6 +145,20 @@ export class ReactiveEffect<T = any>
142145
}
143146
}
144147

148+
pause() {
149+
this.flags |= EffectFlags.PAUSED
150+
}
151+
152+
resume() {
153+
if (this.flags & EffectFlags.PAUSED) {
154+
this.flags &= ~EffectFlags.PAUSED
155+
if (pausedQueueEffects.has(this)) {
156+
pausedQueueEffects.delete(this)
157+
this.trigger()
158+
}
159+
}
160+
}
161+
145162
/**
146163
* @internal
147164
*/
@@ -207,7 +224,9 @@ export class ReactiveEffect<T = any>
207224
}
208225

209226
trigger() {
210-
if (this.scheduler) {
227+
if (this.flags & EffectFlags.PAUSED) {
228+
pausedQueueEffects.add(this)
229+
} else if (this.scheduler) {
211230
this.scheduler()
212231
} else {
213232
this.runIfDirty()

‎packages/reactivity/src/effectScope.ts

+35
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export class EffectScope {
1717
*/
1818
cleanups: (() => void)[] = []
1919

20+
private _isPaused = false
21+
2022
/**
2123
* only assigned by undetached scope
2224
* @internal
@@ -48,6 +50,39 @@ export class EffectScope {
4850
return this._active
4951
}
5052

53+
pause() {
54+
if (this._active) {
55+
this._isPaused = true
56+
if (this.scopes) {
57+
for (let i = 0, l = this.scopes.length; i < l; i++) {
58+
this.scopes[i].pause()
59+
}
60+
}
61+
for (let i = 0, l = this.effects.length; i < l; i++) {
62+
this.effects[i].pause()
63+
}
64+
}
65+
}
66+
67+
/**
68+
* Resumes the effect scope, including all child scopes and effects.
69+
*/
70+
resume() {
71+
if (this._active) {
72+
if (this._isPaused) {
73+
this._isPaused = false
74+
if (this.scopes) {
75+
for (let i = 0, l = this.scopes.length; i < l; i++) {
76+
this.scopes[i].resume()
77+
}
78+
}
79+
for (let i = 0, l = this.effects.length; i < l; i++) {
80+
this.effects[i].resume()
81+
}
82+
}
83+
}
84+
}
85+
5186
run<T>(fn: () => T): T | undefined {
5287
if (this._active) {
5388
const currentEffectScope = activeEffectScope

‎packages/runtime-core/__tests__/apiWatch.spec.ts

+39
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,45 @@ describe('api: watch', () => {
16211621
expect(cb).toHaveBeenCalledTimes(4)
16221622
})
16231623

1624+
test('pause / resume', async () => {
1625+
const count = ref(0)
1626+
const cb = vi.fn()
1627+
const { pause, resume } = watch(count, cb)
1628+
1629+
count.value++
1630+
await nextTick()
1631+
expect(cb).toHaveBeenCalledTimes(1)
1632+
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function))
1633+
1634+
pause()
1635+
count.value++
1636+
await nextTick()
1637+
expect(cb).toHaveBeenCalledTimes(1)
1638+
expect(cb).toHaveBeenLastCalledWith(1, 0, expect.any(Function))
1639+
1640+
resume()
1641+
count.value++
1642+
await nextTick()
1643+
expect(cb).toHaveBeenCalledTimes(2)
1644+
expect(cb).toHaveBeenLastCalledWith(3, 1, expect.any(Function))
1645+
1646+
count.value++
1647+
await nextTick()
1648+
expect(cb).toHaveBeenCalledTimes(3)
1649+
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function))
1650+
1651+
pause()
1652+
count.value++
1653+
await nextTick()
1654+
expect(cb).toHaveBeenCalledTimes(3)
1655+
expect(cb).toHaveBeenLastCalledWith(4, 3, expect.any(Function))
1656+
1657+
resume()
1658+
await nextTick()
1659+
expect(cb).toHaveBeenCalledTimes(4)
1660+
expect(cb).toHaveBeenLastCalledWith(5, 4, expect.any(Function))
1661+
})
1662+
16241663
it('shallowReactive', async () => {
16251664
const state = shallowReactive({
16261665
msg: ref('hello'),

‎packages/runtime-core/src/apiWatch.ts

+27-13
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,17 @@ export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
7979

8080
export type WatchStopHandle = () => void
8181

82+
export interface WatchHandle extends WatchStopHandle {
83+
pause: () => void
84+
resume: () => void
85+
stop: () => void
86+
}
87+
8288
// Simple effect.
8389
export function watchEffect(
8490
effect: WatchEffect,
8591
options?: WatchOptionsBase,
86-
): WatchStopHandle {
92+
): WatchHandle {
8793
return doWatch(effect, null, options)
8894
}
8995

@@ -119,7 +125,7 @@ export function watch<T, Immediate extends Readonly<boolean> = false>(
119125
source: WatchSource<T>,
120126
cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
121127
options?: WatchOptions<Immediate>,
122-
): WatchStopHandle
128+
): WatchHandle
123129

124130
// overload: reactive array or tuple of multiple sources + cb
125131
export function watch<
@@ -131,7 +137,7 @@ export function watch<
131137
? WatchCallback<T, MaybeUndefined<T, Immediate>>
132138
: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
133139
options?: WatchOptions<Immediate>,
134-
): WatchStopHandle
140+
): WatchHandle
135141

136142
// overload: array of multiple sources + cb
137143
export function watch<
@@ -141,7 +147,7 @@ export function watch<
141147
sources: [...T],
142148
cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
143149
options?: WatchOptions<Immediate>,
144-
): WatchStopHandle
150+
): WatchHandle
145151

146152
// overload: watching reactive object w/ cb
147153
export function watch<
@@ -151,14 +157,14 @@ export function watch<
151157
source: T,
152158
cb: WatchCallback<T, MaybeUndefined<T, Immediate>>,
153159
options?: WatchOptions<Immediate>,
154-
): WatchStopHandle
160+
): WatchHandle
155161

156162
// implementation
157163
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
158164
source: T | WatchSource<T>,
159165
cb: any,
160166
options?: WatchOptions<Immediate>,
161-
): WatchStopHandle {
167+
): WatchHandle {
162168
if (__DEV__ && !isFunction(cb)) {
163169
warn(
164170
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
@@ -180,12 +186,12 @@ function doWatch(
180186
onTrack,
181187
onTrigger,
182188
}: WatchOptions = EMPTY_OBJ,
183-
): WatchStopHandle {
189+
): WatchHandle {
184190
if (cb && once) {
185191
const _cb = cb
186192
cb = (...args) => {
187193
_cb(...args)
188-
unwatch()
194+
watchHandle()
189195
}
190196
}
191197

@@ -327,7 +333,11 @@ function doWatch(
327333
const ctx = useSSRContext()!
328334
ssrCleanup = ctx.__watcherHandles || (ctx.__watcherHandles = [])
329335
} else {
330-
return NOOP
336+
const watchHandle: WatchHandle = () => {}
337+
watchHandle.stop = NOOP
338+
watchHandle.resume = NOOP
339+
watchHandle.pause = NOOP
340+
return watchHandle
331341
}
332342
}
333343

@@ -397,13 +407,17 @@ function doWatch(
397407
effect.scheduler = scheduler
398408

399409
const scope = getCurrentScope()
400-
const unwatch = () => {
410+
const watchHandle: WatchHandle = () => {
401411
effect.stop()
402412
if (scope) {
403413
remove(scope.effects, effect)
404414
}
405415
}
406416

417+
watchHandle.pause = effect.pause.bind(effect)
418+
watchHandle.resume = effect.resume.bind(effect)
419+
watchHandle.stop = watchHandle
420+
407421
if (__DEV__) {
408422
effect.onTrack = onTrack
409423
effect.onTrigger = onTrigger
@@ -425,8 +439,8 @@ function doWatch(
425439
effect.run()
426440
}
427441

428-
if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch)
429-
return unwatch
442+
if (__SSR__ && ssrCleanup) ssrCleanup.push(watchHandle)
443+
return watchHandle
430444
}
431445

432446
// this.$watch
@@ -435,7 +449,7 @@ export function instanceWatch(
435449
source: string | Function,
436450
value: WatchCallback | ObjectWatchOptionItem,
437451
options?: WatchOptions,
438-
): WatchStopHandle {
452+
): WatchHandle {
439453
const publicThis = this.proxy as any
440454
const getter = isString(source)
441455
? source.includes('.')

‎packages/runtime-core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ export type {
230230
WatchOptionsBase,
231231
WatchCallback,
232232
WatchSource,
233+
WatchHandle,
233234
WatchStopHandle,
234235
} from './apiWatch'
235236
export type { InjectionKey } from './apiInject'

0 commit comments

Comments
 (0)
Please sign in to comment.