Skip to content

Commit 09702e9

Browse files
committed
fix(runtime-core/scheduler): only allow watch callbacks to be self-triggering
fix #1740 Previous fix for #1727 caused `watchEffect` to also recursively trigger itself on reactive array mutations which implicitly registers array `.length` as dependencies and mutates it at the same time. This fix limits recursive trigger behavior to only `watch()` callbacks since code inside the callback do not register dependencies and mutations are always explicitly intended.
1 parent ccf3362 commit 09702e9

File tree

3 files changed

+109
-32
lines changed

3 files changed

+109
-32
lines changed

packages/runtime-core/__tests__/scheduler.spec.ts

+44
Original file line numberDiff line numberDiff line change
@@ -308,5 +308,49 @@ describe('scheduler', () => {
308308
expect(
309309
`Unhandled error during execution of scheduler flush`
310310
).toHaveBeenWarned()
311+
312+
// this one should no longer error
313+
await nextTick()
314+
})
315+
316+
test('should prevent self-triggering jobs by default', async () => {
317+
let count = 0
318+
const job = () => {
319+
if (count < 3) {
320+
count++
321+
queueJob(job)
322+
}
323+
}
324+
queueJob(job)
325+
await nextTick()
326+
// only runs once - a job cannot queue itself
327+
expect(count).toBe(1)
328+
})
329+
330+
test('should allow watcher callbacks to trigger itself', async () => {
331+
// normal job
332+
let count = 0
333+
const job = () => {
334+
if (count < 3) {
335+
count++
336+
queueJob(job)
337+
}
338+
}
339+
job.cb = true
340+
queueJob(job)
341+
await nextTick()
342+
expect(count).toBe(3)
343+
344+
// post cb
345+
const cb = () => {
346+
if (count < 5) {
347+
count++
348+
queuePostFlushCb(cb)
349+
}
350+
}
351+
cb.cb = true
352+
queuePostFlushCb(cb)
353+
await nextTick()
354+
expect(count).toBe(5)
311355
})
312356
})

packages/runtime-core/src/apiWatch.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ReactiveEffectOptions,
88
isReactive
99
} from '@vue/reactivity'
10-
import { queueJob } from './scheduler'
10+
import { queueJob, SchedulerJob } from './scheduler'
1111
import {
1212
EMPTY_OBJ,
1313
isObject,
@@ -232,7 +232,7 @@ function doWatch(
232232
}
233233

234234
let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
235-
const job = () => {
235+
const job: SchedulerJob = () => {
236236
if (!runner.active) {
237237
return
238238
}
@@ -258,6 +258,10 @@ function doWatch(
258258
}
259259
}
260260

261+
// important: mark the job as a watcher callback so that scheduler knows it
262+
// it is allowed to self-trigger (#1727)
263+
job.cb = !!cb
264+
261265
let scheduler: (job: () => any) => void
262266
if (flush === 'sync') {
263267
scheduler = job

packages/runtime-core/src/scheduler.ts

+59-30
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,57 @@
11
import { ErrorCodes, callWithErrorHandling } from './errorHandling'
22
import { isArray } from '@vue/shared'
33

4-
export interface Job {
4+
export interface SchedulerJob {
55
(): void
6+
/**
7+
* unique job id, only present on raw effects, e.g. component render effect
8+
*/
69
id?: number
10+
/**
11+
* Indicates this is a watch() callback and is allowed to trigger itself.
12+
* A watch callback doesn't track its dependencies so if it triggers itself
13+
* again, it's likely intentional and it is the user's responsibility to
14+
* perform recursive state mutation that eventually stabilizes.
15+
*/
16+
cb?: boolean
717
}
818

9-
const queue: (Job | null)[] = []
19+
const queue: (SchedulerJob | null)[] = []
1020
const postFlushCbs: Function[] = []
1121
const resolvedPromise: Promise<any> = Promise.resolve()
1222
let currentFlushPromise: Promise<void> | null = null
1323

1424
let isFlushing = false
1525
let isFlushPending = false
16-
let flushIndex = -1
26+
let flushIndex = 0
1727
let pendingPostFlushCbs: Function[] | null = null
1828
let pendingPostFlushIndex = 0
1929

2030
const RECURSION_LIMIT = 100
21-
type CountMap = Map<Job | Function, number>
31+
type CountMap = Map<SchedulerJob | Function, number>
2232

2333
export function nextTick(fn?: () => void): Promise<void> {
2434
const p = currentFlushPromise || resolvedPromise
2535
return fn ? p.then(fn) : p
2636
}
2737

28-
export function queueJob(job: Job) {
29-
if (!queue.includes(job, flushIndex + 1)) {
38+
export function queueJob(job: SchedulerJob) {
39+
// the dedupe search uses the startIndex argument of Array.includes()
40+
// by default the search index includes the current job that is being run
41+
// so it cannot recursively trigger itself again.
42+
// if the job is a watch() callback, the search will start with a +1 index to
43+
// allow it recursively trigger itself - it is the user's responsibility to
44+
// ensure it doesn't end up in an infinite loop.
45+
if (
46+
!queue.length ||
47+
!queue.includes(job, job.cb ? flushIndex + 1 : flushIndex)
48+
) {
3049
queue.push(job)
3150
queueFlush()
3251
}
3352
}
3453

35-
export function invalidateJob(job: Job) {
54+
export function invalidateJob(job: SchedulerJob) {
3655
const i = queue.indexOf(job)
3756
if (i > -1) {
3857
queue[i] = null
@@ -43,7 +62,12 @@ export function queuePostFlushCb(cb: Function | Function[]) {
4362
if (!isArray(cb)) {
4463
if (
4564
!pendingPostFlushCbs ||
46-
!pendingPostFlushCbs.includes(cb, pendingPostFlushIndex + 1)
65+
!pendingPostFlushCbs.includes(
66+
cb,
67+
(cb as SchedulerJob).cb
68+
? pendingPostFlushIndex + 1
69+
: pendingPostFlushIndex
70+
)
4771
) {
4872
postFlushCbs.push(cb)
4973
}
@@ -85,7 +109,7 @@ export function flushPostFlushCbs(seen?: CountMap) {
85109
}
86110
}
87111

88-
const getId = (job: Job) => (job.id == null ? Infinity : job.id)
112+
const getId = (job: SchedulerJob) => (job.id == null ? Infinity : job.id)
89113

90114
function flushJobs(seen?: CountMap) {
91115
isFlushPending = false
@@ -105,38 +129,43 @@ function flushJobs(seen?: CountMap) {
105129
// during execution of another flushed job.
106130
queue.sort((a, b) => getId(a!) - getId(b!))
107131

108-
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
109-
const job = queue[flushIndex]
110-
if (job) {
111-
if (__DEV__) {
112-
checkRecursiveUpdates(seen!, job)
132+
try {
133+
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
134+
const job = queue[flushIndex]
135+
if (job) {
136+
if (__DEV__) {
137+
checkRecursiveUpdates(seen!, job)
138+
}
139+
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
113140
}
114-
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
115141
}
116-
}
117-
flushIndex = -1
118-
queue.length = 0
119-
120-
flushPostFlushCbs(seen)
121-
isFlushing = false
122-
currentFlushPromise = null
123-
// some postFlushCb queued jobs!
124-
// keep flushing until it drains.
125-
if (queue.length || postFlushCbs.length) {
126-
flushJobs(seen)
142+
} finally {
143+
flushIndex = 0
144+
queue.length = 0
145+
146+
flushPostFlushCbs(seen)
147+
isFlushing = false
148+
currentFlushPromise = null
149+
// some postFlushCb queued jobs!
150+
// keep flushing until it drains.
151+
if (queue.length || postFlushCbs.length) {
152+
flushJobs(seen)
153+
}
127154
}
128155
}
129156

130-
function checkRecursiveUpdates(seen: CountMap, fn: Job | Function) {
157+
function checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob | Function) {
131158
if (!seen.has(fn)) {
132159
seen.set(fn, 1)
133160
} else {
134161
const count = seen.get(fn)!
135162
if (count > RECURSION_LIMIT) {
136163
throw new Error(
137-
'Maximum recursive updates exceeded. ' +
138-
"You may have code that is mutating state in your component's " +
139-
'render function or updated hook or watcher source function.'
164+
`Maximum recursive updates exceeded. ` +
165+
`This means you have a reactive effect that is mutating its own ` +
166+
`dependencies and thus recursively triggering itself. Possible sources ` +
167+
`include component template, render function, updated hook or ` +
168+
`watcher source function.`
140169
)
141170
} else {
142171
seen.set(fn, count + 1)

0 commit comments

Comments
 (0)