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](https://github.com/vuejs/vue-next/issues/1706#issuecomment-666258948),
    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.
This commit is contained in:
Evan You 2020-09-17 23:17:21 -04:00
parent 58c31e3699
commit 49bb44756f
4 changed files with 78 additions and 67 deletions

View File

@ -280,38 +280,7 @@ describe('api: watch', () => {
expect(cleanup).toHaveBeenCalledTimes(2) expect(cleanup).toHaveBeenCalledTimes(2)
}) })
it('flush timing: post (default)', async () => { it('flush timing: pre (default)', async () => {
const count = ref(0)
let callCount = 0
let result
const assertion = jest.fn(count => {
callCount++
// on mount, the watcher callback should be called before DOM render
// on update, should be called after the count is updated
const expectedDOM = callCount === 1 ? `` : `${count}`
result = serializeInner(root) === expectedDOM
})
const Comp = {
setup() {
watchEffect(() => {
assertion(count.value)
})
return () => count.value
}
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(assertion).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
count.value++
await nextTick()
expect(assertion).toHaveBeenCalledTimes(2)
expect(result).toBe(true)
})
it('flush timing: pre', async () => {
const count = ref(0) const count = ref(0)
const count2 = ref(0) const count2 = ref(0)
@ -332,14 +301,9 @@ describe('api: watch', () => {
const Comp = { const Comp = {
setup() { setup() {
watchEffect( watchEffect(() => {
() => { assertion(count.value, count2.value)
assertion(count.value, count2.value) })
},
{
flush: 'pre'
}
)
return () => count.value return () => count.value
} }
} }
@ -358,6 +322,35 @@ describe('api: watch', () => {
expect(result2).toBe(true) expect(result2).toBe(true)
}) })
it('flush timing: post', async () => {
const count = ref(0)
let result
const assertion = jest.fn(count => {
result = serializeInner(root) === `${count}`
})
const Comp = {
setup() {
watchEffect(
() => {
assertion(count.value)
},
{ flush: 'post' }
)
return () => count.value
}
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(assertion).toHaveBeenCalledTimes(1)
expect(result).toBe(true)
count.value++
await nextTick()
expect(assertion).toHaveBeenCalledTimes(2)
expect(result).toBe(true)
})
it('flush timing: sync', async () => { it('flush timing: sync', async () => {
const count = ref(0) const count = ref(0)
const count2 = ref(0) const count2 = ref(0)
@ -410,7 +403,7 @@ describe('api: watch', () => {
const cb = jest.fn() const cb = jest.fn()
const Comp = { const Comp = {
setup() { setup() {
watch(toggle, cb) watch(toggle, cb, { flush: 'post' })
}, },
render() {} render() {}
} }

View File

@ -154,7 +154,7 @@ describe('Suspense', () => {
expect(onResolve).toHaveBeenCalled() expect(onResolve).toHaveBeenCalled()
}) })
test('buffer mounted/updated hooks & watch callbacks', async () => { test('buffer mounted/updated hooks & post flush watch callbacks', async () => {
const deps: Promise<any>[] = [] const deps: Promise<any>[] = []
const calls: string[] = [] const calls: string[] = []
const toggle = ref(true) const toggle = ref(true)
@ -165,14 +165,21 @@ describe('Suspense', () => {
// extra tick needed for Node 12+ // extra tick needed for Node 12+
deps.push(p.then(() => Promise.resolve())) deps.push(p.then(() => Promise.resolve()))
watchEffect(() => { watchEffect(
calls.push('immediate effect') () => {
}) calls.push('watch effect')
},
{ flush: 'post' }
)
const count = ref(0) const count = ref(0)
watch(count, () => { watch(
calls.push('watch callback') count,
}) () => {
calls.push('watch callback')
},
{ flush: 'post' }
)
count.value++ // trigger the watcher now count.value++ // trigger the watcher now
onMounted(() => { onMounted(() => {
@ -201,12 +208,12 @@ describe('Suspense', () => {
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
render(h(Comp), root) render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`) expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual([`immediate effect`]) expect(calls).toEqual([])
await Promise.all(deps) await Promise.all(deps)
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<div>async</div>`) expect(serializeInner(root)).toBe(`<div>async</div>`)
expect(calls).toEqual([`immediate effect`, `watch callback`, `mounted`]) expect(calls).toEqual([`watch effect`, `watch callback`, `mounted`])
// effects inside an already resolved suspense should happen at normal timing // effects inside an already resolved suspense should happen at normal timing
toggle.value = false toggle.value = false
@ -214,7 +221,7 @@ describe('Suspense', () => {
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<!---->`) expect(serializeInner(root)).toBe(`<!---->`)
expect(calls).toEqual([ expect(calls).toEqual([
`immediate effect`, `watch effect`,
`watch callback`, `watch callback`,
`mounted`, `mounted`,
'unmounted' 'unmounted'
@ -319,14 +326,21 @@ describe('Suspense', () => {
const p = new Promise(r => setTimeout(r, 1)) const p = new Promise(r => setTimeout(r, 1))
deps.push(p) deps.push(p)
watchEffect(() => { watchEffect(
calls.push('immediate effect') () => {
}) calls.push('watch effect')
},
{ flush: 'post' }
)
const count = ref(0) const count = ref(0)
watch(count, () => { watch(
calls.push('watch callback') count,
}) () => {
calls.push('watch callback')
},
{ flush: 'post' }
)
count.value++ // trigger the watcher now count.value++ // trigger the watcher now
onMounted(() => { onMounted(() => {
@ -355,7 +369,7 @@ describe('Suspense', () => {
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
render(h(Comp), root) render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`) expect(serializeInner(root)).toBe(`<div>fallback</div>`)
expect(calls).toEqual(['immediate effect']) expect(calls).toEqual([])
// remove the async dep before it's resolved // remove the async dep before it's resolved
toggle.value = false toggle.value = false
@ -366,8 +380,8 @@ describe('Suspense', () => {
await Promise.all(deps) await Promise.all(deps)
await nextTick() await nextTick()
expect(serializeInner(root)).toBe(`<!---->`) expect(serializeInner(root)).toBe(`<!---->`)
// should discard effects (except for immediate ones) // should discard effects (except for unmount)
expect(calls).toEqual(['immediate effect', 'unmounted']) expect(calls).toEqual(['unmounted'])
}) })
test('unmount suspense after resolve', async () => { test('unmount suspense after resolve', async () => {

View File

@ -268,9 +268,10 @@ function doWatch(
let scheduler: (job: () => any) => void let scheduler: (job: () => any) => void
if (flush === 'sync') { if (flush === 'sync') {
scheduler = job scheduler = job
} else if (flush === 'pre') { } else if (flush === 'post') {
// ensure it's queued before component updates (which have positive ids) scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
job.id = -1 } else {
// default: 'pre'
scheduler = () => { scheduler = () => {
if (!instance || instance.isMounted) { if (!instance || instance.isMounted) {
queuePreFlushCb(job) queuePreFlushCb(job)
@ -280,8 +281,6 @@ function doWatch(
job() job()
} }
} }
} else {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} }
const runner = effect(getter, { const runner = effect(getter, {
@ -300,6 +299,8 @@ function doWatch(
} else { } else {
oldValue = runner() oldValue = runner()
} }
} else if (flush === 'post') {
queuePostRenderEffect(runner, instance && instance.suspense)
} else { } else {
runner() runner()
} }

View File

@ -171,15 +171,18 @@ const KeepAliveImpl = {
keys.delete(key) keys.delete(key)
} }
// prune cache on include/exclude prop change
watch( watch(
() => [props.include, props.exclude], () => [props.include, props.exclude],
([include, exclude]) => { ([include, exclude]) => {
include && pruneCache(name => matches(include, name)) include && pruneCache(name => matches(include, name))
exclude && pruneCache(name => !matches(exclude, name)) exclude && pruneCache(name => !matches(exclude, name))
} },
// prune post-render after `current` has been updated
{ flush: 'post' }
) )
// cache sub tree in beforeMount/Update (i.e. right after the render) // cache sub tree after render
let pendingCacheKey: CacheKey | null = null let pendingCacheKey: CacheKey | null = null
const cacheSubtree = () => { const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0 // fix #1621, the pendingCacheKey could be 0