Просмотр исходного кода

fix(runtime-core): skip async component callbacks after unmount (#14911)

baozj 2 дней назад
Родитель
Сommit
5300ead57b

+ 84 - 0
packages/runtime-core/__tests__/apiAsyncComponent.spec.ts

@@ -442,6 +442,90 @@ describe('api: defineAsyncComponent', () => {
     expect(serializeInner(root)).toBe('resolved')
   })
 
+  test('should not call errorHandler after unmount (timeout)', async () => {
+    const Foo = defineAsyncComponent({
+      loader: () => new Promise(() => {}),
+      timeout: 50,
+    })
+
+    const show = ref(true)
+    const root = nodeOps.createElement('div')
+    const handler = vi.fn()
+    const app = createApp({
+      render: () => (show.value ? h(Foo) : null),
+    })
+    app.config.errorHandler = handler
+    app.mount(root)
+
+    show.value = false
+    await nextTick()
+
+    await timeout(60)
+    expect(handler).not.toHaveBeenCalled()
+  })
+
+  test('should not call errorHandler after unmount (loader error)', async () => {
+    const Foo = defineAsyncComponent({
+      loader: () => Promise.reject(new Error('load failed')),
+    })
+
+    const show = ref(true)
+    const root = nodeOps.createElement('div')
+    const handler = vi.fn()
+    const app = createApp({
+      render: () => (show.value ? h(Foo) : null),
+    })
+    app.config.errorHandler = handler
+    app.mount(root)
+
+    show.value = false
+    await nextTick()
+
+    await timeout()
+    expect(handler).not.toHaveBeenCalled()
+  })
+
+  test('should retry loader after rejected loader is ignored after unmount', async () => {
+    let reject!: (err: Error) => void
+    let resolve!: (comp: Component) => void
+
+    const loader = vi.fn(
+      () =>
+        new Promise<Component>((_resolve, _reject) => {
+          resolve = _resolve
+          reject = _reject
+        }),
+    )
+
+    const Foo = defineAsyncComponent({ loader })
+    const show = ref(true)
+    const root = nodeOps.createElement('div')
+    const handler = vi.fn()
+
+    const app = createApp({
+      render: () => (show.value ? h(Foo) : null),
+    })
+    app.config.errorHandler = handler
+    app.mount(root)
+
+    show.value = false
+    await nextTick()
+
+    reject(new Error('load failed'))
+    await timeout()
+
+    expect(handler).not.toHaveBeenCalled()
+
+    show.value = true
+    await nextTick()
+
+    expect(loader).toHaveBeenCalledTimes(2)
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
   test('with suspense', async () => {
     let resolve: (comp: Component) => void
     const Foo = defineAsyncComponent(

+ 18 - 2
packages/runtime-core/src/apiAsyncComponent.ts

@@ -11,6 +11,7 @@ import { isFunction, isObject } from '@vue/shared'
 import type { ComponentPublicInstance } from './componentPublicInstance'
 import { type VNode, createVNode } from './vnode'
 import { defineComponent } from './apiDefineComponent'
+import { onUnmounted } from './apiLifecycle'
 import { warn } from './warning'
 import { ref } from '@vue/reactivity'
 import { ErrorCodes, handleError } from './errorHandling'
@@ -201,14 +202,24 @@ export function defineAsyncComponent<
       const error = ref()
       const delayed = ref(!!delay)
 
+      let timeoutTimer: ReturnType<typeof setTimeout> | undefined
+      let delayTimer: ReturnType<typeof setTimeout> | undefined
+
+      onUnmounted(() => {
+        if (timeoutTimer != null) clearTimeout(timeoutTimer)
+        if (delayTimer != null) clearTimeout(delayTimer)
+      })
+
       if (delay) {
-        setTimeout(() => {
+        delayTimer = setTimeout(() => {
+          if (instance.isUnmounted) return
           delayed.value = false
         }, delay)
       }
 
       if (timeout != null) {
-        setTimeout(() => {
+        timeoutTimer = setTimeout(() => {
+          if (instance.isUnmounted) return
           if (!loaded.value && !error.value) {
             const err = new Error(
               `Async component timed out after ${timeout}ms.`,
@@ -221,6 +232,7 @@ export function defineAsyncComponent<
 
       load()
         .then(() => {
+          if (instance.isUnmounted) return
           loaded.value = true
           if (instance.parent && isKeepAlive(instance.parent.vnode)) {
             // parent is keep-alive, force update so the loaded component's
@@ -229,6 +241,10 @@ export function defineAsyncComponent<
           }
         })
         .catch(err => {
+          if (instance.isUnmounted) {
+            pendingRequest = null
+            return
+          }
           onError(err)
           error.value = err
         })