Skip to content

Commit

Permalink
fix(watch): post flush watchers should not fire when component is unm…
Browse files Browse the repository at this point in the history
…ounted

fix #1603
  • Loading branch information
yyx990803 committed Jul 17, 2020
1 parent 024a8f1 commit 341b30c
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 39 deletions.
69 changes: 63 additions & 6 deletions packages/runtime-core/__tests__/apiWatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,13 @@ describe('api: watch', () => {
it('flush timing: post (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}`
expect(serializeInner(root)).toBe(expectedDOM)
result = serializeInner(root) === expectedDOM
})

const Comp = {
Expand All @@ -279,27 +280,31 @@ describe('api: watch', () => {
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 count2 = ref(0)

let callCount = 0
let result1
let result2
const assertion = jest.fn((count, count2Value) => {
callCount++
// on mount, the watcher callback should be called before DOM render
// on update, should be called before the count is updated
const expectedDOM = callCount === 1 ? `` : `${count - 1}`
expect(serializeInner(root)).toBe(expectedDOM)
result1 = serializeInner(root) === expectedDOM

// in a pre-flush callback, all state should have been updated
const expectedState = callCount === 1 ? 0 : 1
expect(count2Value).toBe(expectedState)
const expectedState = callCount - 1
result2 = count === expectedState && count2Value === expectedState
})

const Comp = {
Expand All @@ -318,30 +323,36 @@ describe('api: watch', () => {
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(assertion).toHaveBeenCalledTimes(1)
expect(result1).toBe(true)
expect(result2).toBe(true)

count.value++
count2.value++
await nextTick()
// two mutations should result in 1 callback execution
expect(assertion).toHaveBeenCalledTimes(2)
expect(result1).toBe(true)
expect(result2).toBe(true)
})

it('flush timing: sync', async () => {
const count = ref(0)
const count2 = ref(0)

let callCount = 0
let result1
let result2
const assertion = jest.fn(count => {
callCount++
// on mount, the watcher callback should be called before DOM render
// on update, should be called before the count is updated
const expectedDOM = callCount === 1 ? `` : `${count - 1}`
expect(serializeInner(root)).toBe(expectedDOM)
result1 = serializeInner(root) === expectedDOM

// in a sync callback, state mutation on the next line should not have
// executed yet on the 2nd call, but will be on the 3rd call.
const expectedState = callCount < 3 ? 0 : 1
expect(count2.value).toBe(expectedState)
result2 = count2.value === expectedState
})

const Comp = {
Expand All @@ -360,11 +371,57 @@ describe('api: watch', () => {
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(assertion).toHaveBeenCalledTimes(1)
expect(result1).toBe(true)
expect(result2).toBe(true)

count.value++
count2.value++
await nextTick()
expect(assertion).toHaveBeenCalledTimes(3)
expect(result1).toBe(true)
expect(result2).toBe(true)
})

it('should not fire on component unmount w/ flush: post', async () => {
const toggle = ref(true)
const cb = jest.fn()
const Comp = {
setup() {
watch(toggle, cb)
},
render() {}
}
const App = {
render() {
return toggle.value ? h(Comp) : null
}
}
render(h(App), nodeOps.createElement('div'))
expect(cb).not.toHaveBeenCalled()
toggle.value = false
await nextTick()
expect(cb).not.toHaveBeenCalled()
})

it('should fire on component unmount w/ flush: pre', async () => {
const toggle = ref(true)
const cb = jest.fn()
const Comp = {
setup() {
watch(toggle, cb, { flush: 'pre' })
},
render() {}
}
const App = {
render() {
return toggle.value ? h(Comp) : null
}
}
render(h(App), nodeOps.createElement('div'))
expect(cb).not.toHaveBeenCalled()
toggle.value = false
await nextTick()
expect(cb).toHaveBeenCalledTimes(1)
})

it('deep', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-core/__tests__/components/Suspense.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('Suspense', () => {
})

const count = ref(0)
watch(count, v => {
watch(count, () => {
calls.push('watch callback')
})
count.value++ // trigger the watcher now
Expand Down Expand Up @@ -367,7 +367,7 @@ describe('Suspense', () => {
await nextTick()
expect(serializeInner(root)).toBe(`<!---->`)
// should discard effects (except for immediate ones)
expect(calls).toEqual(['immediate effect', 'watch callback', 'unmounted'])
expect(calls).toEqual(['immediate effect', 'unmounted'])
})

test('unmount suspense after resolve', async () => {
Expand Down
54 changes: 30 additions & 24 deletions packages/runtime-core/src/apiWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,33 +234,39 @@ function doWatch(
}

let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
const applyCb = cb
? () => {
if (instance && instance.isUnmounted) {
return
}
const newValue = runner()
if (deep || hasChanged(newValue, oldValue)) {
// cleanup before running cb again
if (cleanup) {
cleanup()
}
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onInvalidate
])
oldValue = newValue
const job = () => {
if (!runner.active) {
return
}
if (cb) {
// watch(source, cb)
const newValue = runner()
if (deep || hasChanged(newValue, oldValue)) {
// cleanup before running cb again
if (cleanup) {
cleanup()
}
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onInvalidate
])
oldValue = newValue
}
: void 0
} else {
// watchEffect
runner()
}
}

let scheduler: (job: () => any) => void
if (flush === 'sync') {
scheduler = invoke
} else if (flush === 'pre') {
scheduler = job => {
// ensure it's queued before component updates (which have positive ids)
job.id = -1
scheduler = () => {
if (!instance || instance.isMounted) {
queueJob(job)
} else {
Expand All @@ -270,22 +276,22 @@ function doWatch(
}
}
} else {
scheduler = job => queuePostRenderEffect(job, instance && instance.suspense)
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
}

const runner = effect(getter, {
lazy: true,
onTrack,
onTrigger,
scheduler: applyCb ? () => scheduler(applyCb) : scheduler
scheduler
})

recordInstanceBoundEffect(runner)

// initial run
if (applyCb) {
if (cb) {
if (immediate) {
applyCb()
job()
} else {
oldValue = runner()
}
Expand Down
12 changes: 5 additions & 7 deletions packages/runtime-core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2025,19 +2025,17 @@ function baseCreateRenderer(
if (bum) {
invokeArrayFns(bum)
}
if (effects) {
for (let i = 0; i < effects.length; i++) {
stop(effects[i])
}
}
// update may be null if a component is unmounted before its async
// setup has resolved.
if (update) {
stop(update)
unmount(subTree, instance, parentSuspense, doRemove)
}
if (effects) {
queuePostRenderEffect(() => {
for (let i = 0; i < effects.length; i++) {
stop(effects[i])
}
}, parentSuspense)
}
// unmounted hook
if (um) {
queuePostRenderEffect(um, parentSuspense)
Expand Down

0 comments on commit 341b30c

Please sign in to comment.