fix(watch): flush:pre watchers should not fire if state change causes

owner component to unmount

fix #2291
This commit is contained in:
Evan You 2022-08-15 19:00:55 +08:00
parent a95554d35c
commit 78c199d6db
5 changed files with 68 additions and 142 deletions

View File

@ -509,7 +509,8 @@ describe('api: watch', () => {
expect(cb).not.toHaveBeenCalled() expect(cb).not.toHaveBeenCalled()
}) })
it('should fire on component unmount w/ flush: pre', async () => { // #2291
it('should not fire on component unmount w/ flush: pre', async () => {
const toggle = ref(true) const toggle = ref(true)
const cb = jest.fn() const cb = jest.fn()
const Comp = { const Comp = {
@ -527,7 +528,7 @@ describe('api: watch', () => {
expect(cb).not.toHaveBeenCalled() expect(cb).not.toHaveBeenCalled()
toggle.value = false toggle.value = false
await nextTick() await nextTick()
expect(cb).toHaveBeenCalledTimes(1) expect(cb).not.toHaveBeenCalled()
}) })
// #1763 // #1763

View File

@ -3,9 +3,8 @@ import {
nextTick, nextTick,
queuePostFlushCb, queuePostFlushCb,
invalidateJob, invalidateJob,
queuePreFlushCb, flushPostFlushCbs,
flushPreFlushCbs, flushPreFlushCbs
flushPostFlushCbs
} from '../src/scheduler' } from '../src/scheduler'
describe('scheduler', () => { describe('scheduler', () => {
@ -114,65 +113,7 @@ describe('scheduler', () => {
}) })
}) })
describe('queuePreFlushCb', () => { describe('pre flush jobs', () => {
it('basic usage', async () => {
const calls: string[] = []
const cb1 = () => {
calls.push('cb1')
}
const cb2 = () => {
calls.push('cb2')
}
queuePreFlushCb(cb1)
queuePreFlushCb(cb2)
expect(calls).toEqual([])
await nextTick()
expect(calls).toEqual(['cb1', 'cb2'])
})
it('should dedupe queued preFlushCb', async () => {
const calls: string[] = []
const cb1 = () => {
calls.push('cb1')
}
const cb2 = () => {
calls.push('cb2')
}
const cb3 = () => {
calls.push('cb3')
}
queuePreFlushCb(cb1)
queuePreFlushCb(cb2)
queuePreFlushCb(cb1)
queuePreFlushCb(cb2)
queuePreFlushCb(cb3)
expect(calls).toEqual([])
await nextTick()
expect(calls).toEqual(['cb1', 'cb2', 'cb3'])
})
it('chained queuePreFlushCb', async () => {
const calls: string[] = []
const cb1 = () => {
calls.push('cb1')
// cb2 will be executed after cb1 at the same tick
queuePreFlushCb(cb2)
}
const cb2 = () => {
calls.push('cb2')
}
queuePreFlushCb(cb1)
await nextTick()
expect(calls).toEqual(['cb1', 'cb2'])
})
})
describe('queueJob w/ queuePreFlushCb', () => {
it('queueJob inside preFlushCb', async () => { it('queueJob inside preFlushCb', async () => {
const calls: string[] = [] const calls: string[] = []
const job1 = () => { const job1 = () => {
@ -183,8 +124,9 @@ describe('scheduler', () => {
calls.push('cb1') calls.push('cb1')
queueJob(job1) queueJob(job1)
} }
cb1.pre = true
queuePreFlushCb(cb1) queueJob(cb1)
await nextTick() await nextTick()
expect(calls).toEqual(['cb1', 'job1']) expect(calls).toEqual(['cb1', 'job1'])
}) })
@ -194,17 +136,23 @@ describe('scheduler', () => {
const job1 = () => { const job1 = () => {
calls.push('job1') calls.push('job1')
} }
job1.id = 1
const cb1 = () => { const cb1 = () => {
calls.push('cb1') calls.push('cb1')
queueJob(job1) queueJob(job1)
// cb2 should execute before the job // cb2 should execute before the job
queuePreFlushCb(cb2) queueJob(cb2)
} }
cb1.pre = true
const cb2 = () => { const cb2 = () => {
calls.push('cb2') calls.push('cb2')
} }
cb2.pre = true
cb2.id = 1
queuePreFlushCb(cb1) queueJob(cb1)
await nextTick() await nextTick()
expect(calls).toEqual(['cb1', 'cb2', 'job1']) expect(calls).toEqual(['cb1', 'cb2', 'job1'])
}) })
@ -216,9 +164,9 @@ describe('scheduler', () => {
// when updating the props of a child component. This is handled // when updating the props of a child component. This is handled
// directly inside `updateComponentPreRender` to avoid non atomic // directly inside `updateComponentPreRender` to avoid non atomic
// cb triggers (#1763) // cb triggers (#1763)
queuePreFlushCb(cb1) queueJob(cb1)
queuePreFlushCb(cb2) queueJob(cb2)
flushPreFlushCbs(undefined, job1) flushPreFlushCbs()
calls.push('job1') calls.push('job1')
} }
const cb1 = () => { const cb1 = () => {
@ -226,9 +174,11 @@ describe('scheduler', () => {
// a cb triggers its parent job, which should be skipped // a cb triggers its parent job, which should be skipped
queueJob(job1) queueJob(job1)
} }
cb1.pre = true
const cb2 = () => { const cb2 = () => {
calls.push('cb2') calls.push('cb2')
} }
cb2.pre = true
queueJob(job1) queueJob(job1)
await nextTick() await nextTick()
@ -237,12 +187,14 @@ describe('scheduler', () => {
// #3806 // #3806
it('queue preFlushCb inside postFlushCb', async () => { it('queue preFlushCb inside postFlushCb', async () => {
const cb = jest.fn() const spy = jest.fn()
const cb = () => spy()
cb.pre = true
queuePostFlushCb(() => { queuePostFlushCb(() => {
queuePreFlushCb(cb) queueJob(cb)
}) })
await nextTick() await nextTick()
expect(cb).toHaveBeenCalled() expect(spy).toHaveBeenCalled()
}) })
}) })

View File

@ -9,7 +9,7 @@ import {
EffectScheduler, EffectScheduler,
DebuggerOptions DebuggerOptions
} from '@vue/reactivity' } from '@vue/reactivity'
import { SchedulerJob, queuePreFlushCb } from './scheduler' import { SchedulerJob, queueJob } from './scheduler'
import { import {
EMPTY_OBJ, EMPTY_OBJ,
isObject, isObject,
@ -345,7 +345,9 @@ function doWatch(
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense) scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else { } else {
// default: 'pre' // default: 'pre'
scheduler = () => queuePreFlushCb(job) job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
} }
const effect = new ReactiveEffect(getter, scheduler) const effect = new ReactiveEffect(getter, scheduler)

View File

@ -1590,7 +1590,7 @@ function baseCreateRenderer(
pauseTracking() pauseTracking()
// props update may have triggered pre-flush watchers. // props update may have triggered pre-flush watchers.
// flush them before the render update. // flush them before the render update.
flushPreFlushCbs(undefined, instance.update) flushPreFlushCbs()
resetTracking() resetTracking()
} }
@ -2331,6 +2331,7 @@ function baseCreateRenderer(
} else { } else {
patch(container._vnode || null, vnode, container, null, null, null, isSVG) patch(container._vnode || null, vnode, container, null, null, null, isSVG)
} }
flushPreFlushCbs()
flushPostFlushCbs() flushPostFlushCbs()
container._vnode = vnode container._vnode = vnode
} }

View File

@ -5,6 +5,7 @@ import { warn } from './warning'
export interface SchedulerJob extends Function { export interface SchedulerJob extends Function {
id?: number id?: number
pre?: boolean
active?: boolean active?: boolean
computed?: boolean computed?: boolean
/** /**
@ -39,10 +40,6 @@ let isFlushPending = false
const queue: SchedulerJob[] = [] const queue: SchedulerJob[] = []
let flushIndex = 0 let flushIndex = 0
const pendingPreFlushCbs: SchedulerJob[] = []
let activePreFlushCbs: SchedulerJob[] | null = null
let preFlushIndex = 0
const pendingPostFlushCbs: SchedulerJob[] = [] const pendingPostFlushCbs: SchedulerJob[] = []
let activePostFlushCbs: SchedulerJob[] | null = null let activePostFlushCbs: SchedulerJob[] | null = null
let postFlushIndex = 0 let postFlushIndex = 0
@ -50,8 +47,6 @@ let postFlushIndex = 0
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any> const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null let currentFlushPromise: Promise<void> | null = null
let currentPreFlushParentJob: SchedulerJob | null = null
const RECURSION_LIMIT = 100 const RECURSION_LIMIT = 100
type CountMap = Map<SchedulerJob, number> type CountMap = Map<SchedulerJob, number>
@ -89,12 +84,11 @@ export function queueJob(job: SchedulerJob) {
// allow it recursively trigger itself - it is the user's responsibility to // allow it recursively trigger itself - it is the user's responsibility to
// ensure it doesn't end up in an infinite loop. // ensure it doesn't end up in an infinite loop.
if ( if (
(!queue.length || !queue.length ||
!queue.includes( !queue.includes(
job, job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
)) && )
job !== currentPreFlushParentJob
) { ) {
if (job.id == null) { if (job.id == null) {
queue.push(job) queue.push(job)
@ -119,71 +113,44 @@ export function invalidateJob(job: SchedulerJob) {
} }
} }
function queueCb( export function queuePostFlushCb(cb: SchedulerJobs) {
cb: SchedulerJobs,
activeQueue: SchedulerJob[] | null,
pendingQueue: SchedulerJob[],
index: number
) {
if (!isArray(cb)) { if (!isArray(cb)) {
if ( if (
!activeQueue || !activePostFlushCbs ||
!activeQueue.includes(cb, cb.allowRecurse ? index + 1 : index) !activePostFlushCbs.includes(
cb,
cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
)
) { ) {
pendingQueue.push(cb) pendingPostFlushCbs.push(cb)
} }
} else { } else {
// if cb is an array, it is a component lifecycle hook which can only be // if cb is an array, it is a component lifecycle hook which can only be
// triggered by a job, which is already deduped in the main queue, so // triggered by a job, which is already deduped in the main queue, so
// we can skip duplicate check here to improve perf // we can skip duplicate check here to improve perf
pendingQueue.push(...cb) pendingPostFlushCbs.push(...cb)
} }
queueFlush() queueFlush()
} }
export function queuePreFlushCb(cb: SchedulerJob) { export function flushPreFlushCbs(seen?: CountMap, i = flushIndex) {
queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}
export function queuePostFlushCb(cb: SchedulerJobs) {
queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}
export function flushPreFlushCbs(
seen?: CountMap,
parentJob: SchedulerJob | null = null
) {
if (pendingPreFlushCbs.length) {
currentPreFlushParentJob = parentJob
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0
if (__DEV__) { if (__DEV__) {
seen = seen || new Map() seen = seen || new Map()
} }
for ( for (; i < queue.length; i++) {
preFlushIndex = 0; const cb = queue[i]
preFlushIndex < activePreFlushCbs.length; if (cb && cb.pre) {
preFlushIndex++ if (__DEV__ && checkRecursiveUpdates(seen!, cb)) {
) {
if (
__DEV__ &&
checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])
) {
continue continue
} }
activePreFlushCbs[preFlushIndex]() queue.splice(i, 1)
i--
cb()
} }
activePreFlushCbs = null
preFlushIndex = 0
currentPreFlushParentJob = null
// recursively flush until it drains
flushPreFlushCbs(seen, parentJob)
} }
} }
export function flushPostFlushCbs(seen?: CountMap) { export function flushPostFlushCbs(seen?: CountMap) {
// flush any pre cbs queued during the flush (e.g. pre watchers)
flushPreFlushCbs()
if (pendingPostFlushCbs.length) { if (pendingPostFlushCbs.length) {
const deduped = [...new Set(pendingPostFlushCbs)] const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0 pendingPostFlushCbs.length = 0
@ -222,6 +189,15 @@ export function flushPostFlushCbs(seen?: CountMap) {
const getId = (job: SchedulerJob): number => const getId = (job: SchedulerJob): number =>
job.id == null ? Infinity : job.id job.id == null ? Infinity : job.id
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}
function flushJobs(seen?: CountMap) { function flushJobs(seen?: CountMap) {
isFlushPending = false isFlushPending = false
isFlushing = true isFlushing = true
@ -229,8 +205,6 @@ function flushJobs(seen?: CountMap) {
seen = seen || new Map() seen = seen || new Map()
} }
flushPreFlushCbs(seen)
// Sort queue before flush. // Sort queue before flush.
// This ensures that: // This ensures that:
// 1. Components are updated from parent to child. (because parent is always // 1. Components are updated from parent to child. (because parent is always
@ -238,7 +212,7 @@ function flushJobs(seen?: CountMap) {
// priority number) // priority number)
// 2. If a component is unmounted during a parent component's update, // 2. If a component is unmounted during a parent component's update,
// its update can be skipped. // its update can be skipped.
queue.sort((a, b) => getId(a) - getId(b)) queue.sort(comparator)
// conditional usage of checkRecursiveUpdate must be determined out of // conditional usage of checkRecursiveUpdate must be determined out of
// try ... catch block since Rollup by default de-optimizes treeshaking // try ... catch block since Rollup by default de-optimizes treeshaking
@ -270,11 +244,7 @@ function flushJobs(seen?: CountMap) {
currentFlushPromise = null currentFlushPromise = null
// some postFlushCb queued jobs! // some postFlushCb queued jobs!
// keep flushing until it drains. // keep flushing until it drains.
if ( if (queue.length || pendingPostFlushCbs.length) {
queue.length ||
pendingPreFlushCbs.length ||
pendingPostFlushCbs.length
) {
flushJobs(seen) flushJobs(seen)
} }
} }