Skip to content

Commit 49bb447

Browse files
committed
refactor: watch APIs default to trigger pre-flush
BREAKING CHANGE: watch APIs now default to use `flush: 'pre'` instead of `flush: 'post'`. - This change affects `watch`, `watchEffect`, the `watch` component option, and `this.$watch`. - As pointed out by @skirtles-code in [this comment](#1706 (comment)), Vue 2's watch behavior is pre-flush, and the ecosystem has many uses of watch that assumes the pre-flush behavior. Defaulting to post-flush can result in unnecessary re-renders without the users being aware of it. - With this change, watchers need to specify `{ flush: 'post' }` via options to trigger callback after Vue render updates. Note that specifying `{ flush: 'post' }` will also defer `watchEffect`'s initial run to wait for the component's initial render.
1 parent 58c31e3 commit 49bb447

File tree

4 files changed

+73
-62
lines changed

4 files changed

+73
-62
lines changed

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

+29-36
Original file line numberDiff line numberDiff line change
@@ -280,82 +280,75 @@ describe('api: watch', () => {
280280
expect(cleanup).toHaveBeenCalledTimes(2)
281281
})
282282

283-
it('flush timing: post (default)', async () => {
283+
it('flush timing: pre (default)', async () => {
284284
const count = ref(0)
285+
const count2 = ref(0)
286+
285287
let callCount = 0
286-
let result
287-
const assertion = jest.fn(count => {
288+
let result1
289+
let result2
290+
const assertion = jest.fn((count, count2Value) => {
288291
callCount++
289292
// on mount, the watcher callback should be called before DOM render
290-
// on update, should be called after the count is updated
291-
const expectedDOM = callCount === 1 ? `` : `${count}`
292-
result = serializeInner(root) === expectedDOM
293+
// on update, should be called before the count is updated
294+
const expectedDOM = callCount === 1 ? `` : `${count - 1}`
295+
result1 = serializeInner(root) === expectedDOM
296+
297+
// in a pre-flush callback, all state should have been updated
298+
const expectedState = callCount - 1
299+
result2 = count === expectedState && count2Value === expectedState
293300
})
294301

295302
const Comp = {
296303
setup() {
297304
watchEffect(() => {
298-
assertion(count.value)
305+
assertion(count.value, count2.value)
299306
})
300307
return () => count.value
301308
}
302309
}
303310
const root = nodeOps.createElement('div')
304311
render(h(Comp), root)
305312
expect(assertion).toHaveBeenCalledTimes(1)
306-
expect(result).toBe(true)
313+
expect(result1).toBe(true)
314+
expect(result2).toBe(true)
307315

308316
count.value++
317+
count2.value++
309318
await nextTick()
319+
// two mutations should result in 1 callback execution
310320
expect(assertion).toHaveBeenCalledTimes(2)
311-
expect(result).toBe(true)
321+
expect(result1).toBe(true)
322+
expect(result2).toBe(true)
312323
})
313324

314-
it('flush timing: pre', async () => {
325+
it('flush timing: post', async () => {
315326
const count = ref(0)
316-
const count2 = ref(0)
317-
318-
let callCount = 0
319-
let result1
320-
let result2
321-
const assertion = jest.fn((count, count2Value) => {
322-
callCount++
323-
// on mount, the watcher callback should be called before DOM render
324-
// on update, should be called before the count is updated
325-
const expectedDOM = callCount === 1 ? `` : `${count - 1}`
326-
result1 = serializeInner(root) === expectedDOM
327-
328-
// in a pre-flush callback, all state should have been updated
329-
const expectedState = callCount - 1
330-
result2 = count === expectedState && count2Value === expectedState
327+
let result
328+
const assertion = jest.fn(count => {
329+
result = serializeInner(root) === `${count}`
331330
})
332331

333332
const Comp = {
334333
setup() {
335334
watchEffect(
336335
() => {
337-
assertion(count.value, count2.value)
336+
assertion(count.value)
338337
},
339-
{
340-
flush: 'pre'
341-
}
338+
{ flush: 'post' }
342339
)
343340
return () => count.value
344341
}
345342
}
346343
const root = nodeOps.createElement('div')
347344
render(h(Comp), root)
348345
expect(assertion).toHaveBeenCalledTimes(1)
349-
expect(result1).toBe(true)
350-
expect(result2).toBe(true)
346+
expect(result).toBe(true)
351347

352348
count.value++
353-
count2.value++
354349
await nextTick()
355-
// two mutations should result in 1 callback execution
356350
expect(assertion).toHaveBeenCalledTimes(2)
357-
expect(result1).toBe(true)
358-
expect(result2).toBe(true)
351+
expect(result).toBe(true)
359352
})
360353

361354
it('flush timing: sync', async () => {
@@ -410,7 +403,7 @@ describe('api: watch', () => {
410403
const cb = jest.fn()
411404
const Comp = {
412405
setup() {
413-
watch(toggle, cb)
406+
watch(toggle, cb, { flush: 'post' })
414407
},
415408
render() {}
416409
}

packages/runtime-core/__tests__/components/Suspense.spec.ts

+33-19
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ describe('Suspense', () => {
154154
expect(onResolve).toHaveBeenCalled()
155155
})
156156

157-
test('buffer mounted/updated hooks & watch callbacks', async () => {
157+
test('buffer mounted/updated hooks & post flush watch callbacks', async () => {
158158
const deps: Promise<any>[] = []
159159
const calls: string[] = []
160160
const toggle = ref(true)
@@ -165,14 +165,21 @@ describe('Suspense', () => {
165165
// extra tick needed for Node 12+
166166
deps.push(p.then(() => Promise.resolve()))
167167

168-
watchEffect(() => {
169-
calls.push('immediate effect')
170-
})
168+
watchEffect(
169+
() => {
170+
calls.push('watch effect')
171+
},
172+
{ flush: 'post' }
173+
)
171174

172175
const count = ref(0)
173-
watch(count, () => {
174-
calls.push('watch callback')
175-
})
176+
watch(
177+
count,
178+
() => {
179+
calls.push('watch callback')
180+
},
181+
{ flush: 'post' }
182+
)
176183
count.value++ // trigger the watcher now
177184

178185
onMounted(() => {
@@ -201,20 +208,20 @@ describe('Suspense', () => {
201208
const root = nodeOps.createElement('div')
202209
render(h(Comp), root)
203210
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
204-
expect(calls).toEqual([`immediate effect`])
211+
expect(calls).toEqual([])
205212

206213
await Promise.all(deps)
207214
await nextTick()
208215
expect(serializeInner(root)).toBe(`<div>async</div>`)
209-
expect(calls).toEqual([`immediate effect`, `watch callback`, `mounted`])
216+
expect(calls).toEqual([`watch effect`, `watch callback`, `mounted`])
210217

211218
// effects inside an already resolved suspense should happen at normal timing
212219
toggle.value = false
213220
await nextTick()
214221
await nextTick()
215222
expect(serializeInner(root)).toBe(`<!---->`)
216223
expect(calls).toEqual([
217-
`immediate effect`,
224+
`watch effect`,
218225
`watch callback`,
219226
`mounted`,
220227
'unmounted'
@@ -319,14 +326,21 @@ describe('Suspense', () => {
319326
const p = new Promise(r => setTimeout(r, 1))
320327
deps.push(p)
321328

322-
watchEffect(() => {
323-
calls.push('immediate effect')
324-
})
329+
watchEffect(
330+
() => {
331+
calls.push('watch effect')
332+
},
333+
{ flush: 'post' }
334+
)
325335

326336
const count = ref(0)
327-
watch(count, () => {
328-
calls.push('watch callback')
329-
})
337+
watch(
338+
count,
339+
() => {
340+
calls.push('watch callback')
341+
},
342+
{ flush: 'post' }
343+
)
330344
count.value++ // trigger the watcher now
331345

332346
onMounted(() => {
@@ -355,7 +369,7 @@ describe('Suspense', () => {
355369
const root = nodeOps.createElement('div')
356370
render(h(Comp), root)
357371
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
358-
expect(calls).toEqual(['immediate effect'])
372+
expect(calls).toEqual([])
359373

360374
// remove the async dep before it's resolved
361375
toggle.value = false
@@ -366,8 +380,8 @@ describe('Suspense', () => {
366380
await Promise.all(deps)
367381
await nextTick()
368382
expect(serializeInner(root)).toBe(`<!---->`)
369-
// should discard effects (except for immediate ones)
370-
expect(calls).toEqual(['immediate effect', 'unmounted'])
383+
// should discard effects (except for unmount)
384+
expect(calls).toEqual(['unmounted'])
371385
})
372386

373387
test('unmount suspense after resolve', async () => {

packages/runtime-core/src/apiWatch.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,10 @@ function doWatch(
268268
let scheduler: (job: () => any) => void
269269
if (flush === 'sync') {
270270
scheduler = job
271-
} else if (flush === 'pre') {
272-
// ensure it's queued before component updates (which have positive ids)
273-
job.id = -1
271+
} else if (flush === 'post') {
272+
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
273+
} else {
274+
// default: 'pre'
274275
scheduler = () => {
275276
if (!instance || instance.isMounted) {
276277
queuePreFlushCb(job)
@@ -280,8 +281,6 @@ function doWatch(
280281
job()
281282
}
282283
}
283-
} else {
284-
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
285284
}
286285

287286
const runner = effect(getter, {
@@ -300,6 +299,8 @@ function doWatch(
300299
} else {
301300
oldValue = runner()
302301
}
302+
} else if (flush === 'post') {
303+
queuePostRenderEffect(runner, instance && instance.suspense)
303304
} else {
304305
runner()
305306
}

packages/runtime-core/src/components/KeepAlive.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,18 @@ const KeepAliveImpl = {
171171
keys.delete(key)
172172
}
173173

174+
// prune cache on include/exclude prop change
174175
watch(
175176
() => [props.include, props.exclude],
176177
([include, exclude]) => {
177178
include && pruneCache(name => matches(include, name))
178179
exclude && pruneCache(name => !matches(exclude, name))
179-
}
180+
},
181+
// prune post-render after `current` has been updated
182+
{ flush: 'post' }
180183
)
181184

182-
// cache sub tree in beforeMount/Update (i.e. right after the render)
185+
// cache sub tree after render
183186
let pendingCacheKey: CacheKey | null = null
184187
const cacheSubtree = () => {
185188
// fix #1621, the pendingCacheKey could be 0

0 commit comments

Comments
 (0)