Skip to content

Commit 341b30c

Browse files
committed
fix(watch): post flush watchers should not fire when component is unmounted
fix #1603
1 parent 024a8f1 commit 341b30c

File tree

4 files changed

+100
-39
lines changed

4 files changed

+100
-39
lines changed

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

+63-6
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,13 @@ describe('api: watch', () => {
260260
it('flush timing: post (default)', async () => {
261261
const count = ref(0)
262262
let callCount = 0
263+
let result
263264
const assertion = jest.fn(count => {
264265
callCount++
265266
// on mount, the watcher callback should be called before DOM render
266267
// on update, should be called after the count is updated
267268
const expectedDOM = callCount === 1 ? `` : `${count}`
268-
expect(serializeInner(root)).toBe(expectedDOM)
269+
result = serializeInner(root) === expectedDOM
269270
})
270271

271272
const Comp = {
@@ -279,27 +280,31 @@ describe('api: watch', () => {
279280
const root = nodeOps.createElement('div')
280281
render(h(Comp), root)
281282
expect(assertion).toHaveBeenCalledTimes(1)
283+
expect(result).toBe(true)
282284

283285
count.value++
284286
await nextTick()
285287
expect(assertion).toHaveBeenCalledTimes(2)
288+
expect(result).toBe(true)
286289
})
287290

288291
it('flush timing: pre', async () => {
289292
const count = ref(0)
290293
const count2 = ref(0)
291294

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

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

305310
const Comp = {
@@ -318,30 +323,36 @@ describe('api: watch', () => {
318323
const root = nodeOps.createElement('div')
319324
render(h(Comp), root)
320325
expect(assertion).toHaveBeenCalledTimes(1)
326+
expect(result1).toBe(true)
327+
expect(result2).toBe(true)
321328

322329
count.value++
323330
count2.value++
324331
await nextTick()
325332
// two mutations should result in 1 callback execution
326333
expect(assertion).toHaveBeenCalledTimes(2)
334+
expect(result1).toBe(true)
335+
expect(result2).toBe(true)
327336
})
328337

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

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

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

347358
const Comp = {
@@ -360,11 +371,57 @@ describe('api: watch', () => {
360371
const root = nodeOps.createElement('div')
361372
render(h(Comp), root)
362373
expect(assertion).toHaveBeenCalledTimes(1)
374+
expect(result1).toBe(true)
375+
expect(result2).toBe(true)
363376

364377
count.value++
365378
count2.value++
366379
await nextTick()
367380
expect(assertion).toHaveBeenCalledTimes(3)
381+
expect(result1).toBe(true)
382+
expect(result2).toBe(true)
383+
})
384+
385+
it('should not fire on component unmount w/ flush: post', async () => {
386+
const toggle = ref(true)
387+
const cb = jest.fn()
388+
const Comp = {
389+
setup() {
390+
watch(toggle, cb)
391+
},
392+
render() {}
393+
}
394+
const App = {
395+
render() {
396+
return toggle.value ? h(Comp) : null
397+
}
398+
}
399+
render(h(App), nodeOps.createElement('div'))
400+
expect(cb).not.toHaveBeenCalled()
401+
toggle.value = false
402+
await nextTick()
403+
expect(cb).not.toHaveBeenCalled()
404+
})
405+
406+
it('should fire on component unmount w/ flush: pre', async () => {
407+
const toggle = ref(true)
408+
const cb = jest.fn()
409+
const Comp = {
410+
setup() {
411+
watch(toggle, cb, { flush: 'pre' })
412+
},
413+
render() {}
414+
}
415+
const App = {
416+
render() {
417+
return toggle.value ? h(Comp) : null
418+
}
419+
}
420+
render(h(App), nodeOps.createElement('div'))
421+
expect(cb).not.toHaveBeenCalled()
422+
toggle.value = false
423+
await nextTick()
424+
expect(cb).toHaveBeenCalledTimes(1)
368425
})
369426

370427
it('deep', async () => {

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describe('Suspense', () => {
170170
})
171171

172172
const count = ref(0)
173-
watch(count, v => {
173+
watch(count, () => {
174174
calls.push('watch callback')
175175
})
176176
count.value++ // trigger the watcher now
@@ -367,7 +367,7 @@ describe('Suspense', () => {
367367
await nextTick()
368368
expect(serializeInner(root)).toBe(`<!---->`)
369369
// should discard effects (except for immediate ones)
370-
expect(calls).toEqual(['immediate effect', 'watch callback', 'unmounted'])
370+
expect(calls).toEqual(['immediate effect', 'unmounted'])
371371
})
372372

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

packages/runtime-core/src/apiWatch.ts

+30-24
Original file line numberDiff line numberDiff line change
@@ -234,33 +234,39 @@ function doWatch(
234234
}
235235

236236
let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
237-
const applyCb = cb
238-
? () => {
239-
if (instance && instance.isUnmounted) {
240-
return
241-
}
242-
const newValue = runner()
243-
if (deep || hasChanged(newValue, oldValue)) {
244-
// cleanup before running cb again
245-
if (cleanup) {
246-
cleanup()
247-
}
248-
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
249-
newValue,
250-
// pass undefined as the old value when it's changed for the first time
251-
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
252-
onInvalidate
253-
])
254-
oldValue = newValue
237+
const job = () => {
238+
if (!runner.active) {
239+
return
240+
}
241+
if (cb) {
242+
// watch(source, cb)
243+
const newValue = runner()
244+
if (deep || hasChanged(newValue, oldValue)) {
245+
// cleanup before running cb again
246+
if (cleanup) {
247+
cleanup()
255248
}
249+
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
250+
newValue,
251+
// pass undefined as the old value when it's changed for the first time
252+
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
253+
onInvalidate
254+
])
255+
oldValue = newValue
256256
}
257-
: void 0
257+
} else {
258+
// watchEffect
259+
runner()
260+
}
261+
}
258262

259263
let scheduler: (job: () => any) => void
260264
if (flush === 'sync') {
261265
scheduler = invoke
262266
} else if (flush === 'pre') {
263-
scheduler = job => {
267+
// ensure it's queued before component updates (which have positive ids)
268+
job.id = -1
269+
scheduler = () => {
264270
if (!instance || instance.isMounted) {
265271
queueJob(job)
266272
} else {
@@ -270,22 +276,22 @@ function doWatch(
270276
}
271277
}
272278
} else {
273-
scheduler = job => queuePostRenderEffect(job, instance && instance.suspense)
279+
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
274280
}
275281

276282
const runner = effect(getter, {
277283
lazy: true,
278284
onTrack,
279285
onTrigger,
280-
scheduler: applyCb ? () => scheduler(applyCb) : scheduler
286+
scheduler
281287
})
282288

283289
recordInstanceBoundEffect(runner)
284290

285291
// initial run
286-
if (applyCb) {
292+
if (cb) {
287293
if (immediate) {
288-
applyCb()
294+
job()
289295
} else {
290296
oldValue = runner()
291297
}

packages/runtime-core/src/renderer.ts

+5-7
Original file line numberDiff line numberDiff line change
@@ -2025,19 +2025,17 @@ function baseCreateRenderer(
20252025
if (bum) {
20262026
invokeArrayFns(bum)
20272027
}
2028+
if (effects) {
2029+
for (let i = 0; i < effects.length; i++) {
2030+
stop(effects[i])
2031+
}
2032+
}
20282033
// update may be null if a component is unmounted before its async
20292034
// setup has resolved.
20302035
if (update) {
20312036
stop(update)
20322037
unmount(subTree, instance, parentSuspense, doRemove)
20332038
}
2034-
if (effects) {
2035-
queuePostRenderEffect(() => {
2036-
for (let i = 0; i < effects.length; i++) {
2037-
stop(effects[i])
2038-
}
2039-
}, parentSuspense)
2040-
}
20412039
// unmounted hook
20422040
if (um) {
20432041
queuePostRenderEffect(um, parentSuspense)

0 commit comments

Comments
 (0)