Selaa lähdekoodia

fix(runtime-vapor): avoid duplicate VDOM unmount in interop teardown

daiwei 1 kuukausi sitten
vanhempi
commit
cb877ffd8c

+ 115 - 0
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -9339,6 +9339,121 @@ describe('VDOM interop', () => {
     }
   })
 
+  test('hydrate client-mounted VDOM Teleport slot content and switch vapor branch', async () => {
+    const targetId = 'interop-vdom-mounted-teleport-slot-hydration-target'
+    const data = ref({
+      show: true,
+      tail: 'tail',
+    })
+    const mountedPortalCode = `<script setup>
+        import { onMounted, ref } from 'vue'
+        defineOptions({ name: 'VDomMountedPortal' })
+        defineProps({ to: String })
+        const mounted = ref(false)
+        onMounted(() => {
+          mounted.value = true
+        })
+      </script>
+      <template>
+        <Teleport v-if="mounted" :to="to">
+          <slot />
+        </Teleport>
+      </template>`
+    const portalCode = `<script setup>
+        defineOptions({ name: 'VDomPortalForwarder' })
+        defineProps({ to: String })
+        const components = _components
+      </script>
+      <template>
+        <components.MountedPortal :to="to">
+          <slot />
+        </components.MountedPortal>
+      </template>`
+    const rootCode = `<script setup vapor>
+        const data = _data
+        const components = _components
+      </script>
+      <template>
+        <components.Portal v-if="data.show" :to="'#${targetId}'">
+          <span>teleported</span>
+        </components.Portal>
+        <p v-else>next</p>
+        <span>{{ data.tail }}</span>
+      </template>`
+
+    const ssrComponents: any = {}
+    ssrComponents.MountedPortal = compile(
+      mountedPortalCode,
+      data,
+      ssrComponents,
+      {
+        vapor: false,
+        ssr: true,
+      },
+    )
+    ssrComponents.Portal = compile(portalCode, data, ssrComponents, {
+      vapor: false,
+      ssr: true,
+    })
+    const clientComponents: any = {}
+    clientComponents.MountedPortal = compile(
+      mountedPortalCode,
+      data,
+      clientComponents,
+      {
+        vapor: false,
+        ssr: false,
+      },
+    )
+    clientComponents.Portal = compile(portalCode, data, clientComponents, {
+      vapor: false,
+      ssr: false,
+    })
+    const serverComp = compile(rootCode, data, ssrComponents, {
+      vapor: true,
+      ssr: true,
+    })
+    const html = await VueServerRenderer.renderToString(
+      runtimeDom.createSSRApp(serverComp),
+    )
+
+    const target = document.createElement('div')
+    target.id = targetId
+    document.body.appendChild(target)
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    document.body.appendChild(container)
+
+    const clientComp = compile(rootCode, data, clientComponents, {
+      vapor: true,
+      ssr: false,
+    })
+    const app = createVaporSSRApp(clientComp)
+    app.use(runtimeVapor.vaporInteropPlugin)
+    try {
+      app.mount(container)
+      await nextTick()
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+      expect(formatHtml(target.innerHTML)).toBe('<span>teleported</span>')
+
+      data.value.show = false
+      await nextTick()
+
+      expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+        "
+        <!--[--><p>next</p><!--if--><span>tail</span><!--]-->
+        "
+      `)
+      expect(target.innerHTML).toBe('')
+    } finally {
+      app.unmount()
+      container.remove()
+      target.remove()
+    }
+  })
+
   test('hydrate Suspense VNode via createDynamicComponent and switch branch', async () => {
     const data = ref({
       showSuspense: true,

+ 43 - 18
packages/runtime-vapor/src/vdomInterop.ts

@@ -636,9 +636,8 @@ const vaporSlotsProxyHandler: ProxyHandler<any> = {
 
 let vdomHydrateNode: HydrationRenderer['hydrateNode'] | undefined
 
-// Static/Fragment vnodes always represent a contiguous range [el..anchor].
-// For component vnodes, only treat them as a range when their hydrated subTree
-// is Static/Fragment (multi-root component case).
+// Static/Fragment/Teleport vnodes represent a root range [el..anchor].
+// Component roots can update internally, so resolve through the current subtree.
 function resolveVNodeRange(vnode: VNode): [Node, Node] | undefined {
   const { type, shapeFlag, el, anchor } = vnode
   if (shapeFlag & ShapeFlags.TELEPORT && el && anchor && anchor !== el) {
@@ -648,21 +647,11 @@ function resolveVNodeRange(vnode: VNode): [Node, Node] | undefined {
   if ((type === Static || type === Fragment) && el && anchor && anchor !== el) {
     return [el as Node, anchor as Node]
   }
-  if (!(shapeFlag & ShapeFlags.COMPONENT)) {
-    return
-  }
-
-  const subTree = vnode.component && vnode.component.subTree
-  const subEl = subTree && subTree.el
-  const subAnchor = subTree && subTree.anchor
-  if (
-    subTree &&
-    (subTree.type === Static || subTree.type === Fragment) &&
-    subEl &&
-    subAnchor &&
-    subAnchor !== subEl
-  ) {
-    return [subEl as Node, subAnchor as Node]
+  if (shapeFlag & ShapeFlags.COMPONENT) {
+    const subTree = vnode.component && vnode.component.subTree
+    if (subTree) {
+      return resolveVNodeRange(subTree)
+    }
   }
 }
 
@@ -687,9 +676,27 @@ function resolveVNodeNodes(vnode: VNode): Block {
     }
     return nodeRange
   }
+  if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
+    const subTree = vnode.component && vnode.component.subTree
+    if (subTree) {
+      return resolveVNodeNodes(subTree)
+    }
+  }
   return vnode.el as Block
 }
 
+function removeAttachedNodes(block: Block, parent: ParentNode): void {
+  if (block instanceof Node) {
+    if (block.parentNode === parent) {
+      remove(block, parent)
+    }
+  } else if (isArray(block)) {
+    for (let i = 0; i < block.length; i++) {
+      removeAttachedNodes(block[i], parent)
+    }
+  }
+}
+
 function appendVnodeUpdatedHook(vnode: VNode, hook: () => void): void {
   const props = (vnode.props ||= {})
   const existing = props.onVnodeUpdated
@@ -901,7 +908,20 @@ function createVDOMComponent(
 
   let rawRef: VNodeNormalizedRef | null = null
   let isMounted = false
+  let isUnmounted = false
+  let isDomRemoved = false
+  const removeDom = (parentNode?: ParentNode): void => {
+    if (!parentNode || isDomRemoved) {
+      return
+    }
+    removeAttachedNodes(resolveVNodeNodes(vnode), parentNode)
+    isDomRemoved = true
+  }
   const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
+    if (isUnmounted) {
+      removeDom(parentNode)
+      return
+    }
     // unset ref
     if (rawRef) vdomSetRef(rawRef, null, null, vnode, true)
     if (transition) setVNodeTransitionHooks(vnode, transition)
@@ -915,7 +935,12 @@ function createVDOMComponent(
       )
       return
     }
+    // Vapor block removal and scope disposal can both reach this path.
+    // VDOM fragment ranges must only be removed once.
+    isUnmounted = true
+    isMounted = false
     internals.umt(vnode.component!, null, !!parentNode)
+    removeDom(parentNode)
   }
 
   frag.hydrate = () => {