Переглянути джерело

fix(runtime-dom): defer teleport mount/update until suspense resolves (#8619)

close #8603
白雾三语 4 тижнів тому
батько
коміт
88ed045015

+ 122 - 0
packages/runtime-core/__tests__/components/Suspense.spec.ts

@@ -8,6 +8,7 @@ import {
   KeepAlive,
   Suspense,
   type SuspenseProps,
+  Teleport,
   createCommentVNode,
   h,
   nextTick,
@@ -2165,6 +2166,127 @@ describe('Suspense', () => {
     await Promise.all(deps)
   })
 
+  test('should mount after suspense is resolved', async () => {
+    const target = nodeOps.createElement('div')
+
+    const Async = defineAsyncComponent({
+      render() {
+        return h('div', 'async')
+      },
+    })
+
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: h('div', null, [
+              h(Async),
+              h(Teleport, { to: target }, h('div', 'teleported')),
+            ]),
+            fallback: h('div', 'fallback'),
+          })
+      },
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(
+      `<div><div>async</div><!--teleport start--><!--teleport end--></div>`,
+    )
+    expect(serializeInner(target)).toBe(`<div>teleported</div>`)
+  })
+
+  test('should patch teleport before suspense is resolved', async () => {
+    const target = nodeOps.createElement('div')
+    const text = ref('one')
+
+    const Async = defineAsyncComponent({
+      render() {
+        return h('div', 'async')
+      },
+    })
+
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: h('div', null, [
+              h(Async),
+              h(Teleport, { to: target }, h('div', text.value)),
+            ]),
+            fallback: h('div', 'fallback'),
+          })
+      },
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    text.value = 'two'
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(
+      `<div><div>async</div><!--teleport start--><!--teleport end--></div>`,
+    )
+    expect(serializeInner(target)).toBe(`<div>two</div>`)
+  })
+
+  test('should handle disabled teleport updates before suspense is resolved', async () => {
+    const target = nodeOps.createElement('div')
+    const disabled = ref(false)
+
+    const Async = defineAsyncComponent({
+      render() {
+        return h('div', 'async')
+      },
+    })
+
+    const Comp = {
+      setup() {
+        return () =>
+          h(Suspense, null, {
+            default: h('div', null, [
+              h(Async),
+              h(
+                Teleport,
+                { to: target, disabled: disabled.value },
+                h('div', 'teleported'),
+              ),
+            ]),
+            fallback: h('div', 'fallback'),
+          })
+      },
+    }
+
+    const root = nodeOps.createElement('div')
+    render(h(Comp), root)
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    disabled.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe(`<div>fallback</div>`)
+    expect(serializeInner(target)).toBe(``)
+
+    await Promise.all(deps)
+    await nextTick()
+    expect(serializeInner(root)).toBe(
+      `<div><div>async</div><!--teleport start--><div>teleported</div><!--teleport end--></div>`,
+    )
+    expect(serializeInner(target)).toBe(``)
+  })
+
   //#11617
   test('update async component before resolve then update again', async () => {
     const arr: boolean[] = []

+ 16 - 8
packages/runtime-core/src/components/Teleport.ts

@@ -169,9 +169,13 @@ export const TeleportImpl = {
         updateCssVars(n2, true)
       }
 
-      if (isTeleportDeferred(n2.props)) {
+      if (
+        isTeleportDeferred(n2.props) ||
+        (parentSuspense && parentSuspense.pendingBranch)
+      ) {
         n2.el!.__isMounted = false
         queuePostRenderEffect(() => {
+          if (n2.el!.__isMounted !== false) return
           mountToTarget()
           delete n2.el!.__isMounted
         }, parentSuspense)
@@ -179,7 +183,17 @@ export const TeleportImpl = {
         mountToTarget()
       }
     } else {
-      if (isTeleportDeferred(n2.props) && n1.el!.__isMounted === false) {
+      // update content
+      n2.el = n1.el
+      n2.targetStart = n1.targetStart
+      const mainAnchor = (n2.anchor = n1.anchor)!
+      const target = (n2.target = n1.target)!
+      const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
+
+      // Target mounting may still be pending because of deferred teleport or a
+      // parent suspense buffering post-render effects. In that case, defer the
+      // teleport patch itself until the pending mount effect has run.
+      if (n1.el!.__isMounted === false) {
         queuePostRenderEffect(() => {
           TeleportImpl.process(
             n1,
@@ -196,12 +210,6 @@ export const TeleportImpl = {
         }, parentSuspense)
         return
       }
-      // update content
-      n2.el = n1.el
-      n2.targetStart = n1.targetStart
-      const mainAnchor = (n2.anchor = n1.anchor)!
-      const target = (n2.target = n1.target)!
-      const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
       const wasDisabled = isTeleportDisabled(n1.props)
       const currentContainer = wasDisabled ? container : target
       const currentAnchor = wasDisabled ? mainAnchor : targetAnchor