Skip to content

Commit 0600dd3

Browse files
committed
fix(runtime-vapor): avoid duplicate VDOM unmount in interop teardown
1 parent 08fc0e9 commit 0600dd3

2 files changed

Lines changed: 158 additions & 18 deletions

File tree

packages/runtime-vapor/__tests__/hydration.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9339,6 +9339,121 @@ describe('VDOM interop', () => {
93399339
}
93409340
})
93419341

9342+
test('hydrate client-mounted VDOM Teleport slot content and switch vapor branch', async () => {
9343+
const targetId = 'interop-vdom-mounted-teleport-slot-hydration-target'
9344+
const data = ref({
9345+
show: true,
9346+
tail: 'tail',
9347+
})
9348+
const mountedPortalCode = `<script setup>
9349+
import { onMounted, ref } from 'vue'
9350+
defineOptions({ name: 'VDomMountedPortal' })
9351+
defineProps({ to: String })
9352+
const mounted = ref(false)
9353+
onMounted(() => {
9354+
mounted.value = true
9355+
})
9356+
</script>
9357+
<template>
9358+
<Teleport v-if="mounted" :to="to">
9359+
<slot />
9360+
</Teleport>
9361+
</template>`
9362+
const portalCode = `<script setup>
9363+
defineOptions({ name: 'VDomPortalForwarder' })
9364+
defineProps({ to: String })
9365+
const components = _components
9366+
</script>
9367+
<template>
9368+
<components.MountedPortal :to="to">
9369+
<slot />
9370+
</components.MountedPortal>
9371+
</template>`
9372+
const rootCode = `<script setup vapor>
9373+
const data = _data
9374+
const components = _components
9375+
</script>
9376+
<template>
9377+
<components.Portal v-if="data.show" :to="'#${targetId}'">
9378+
<span>teleported</span>
9379+
</components.Portal>
9380+
<p v-else>next</p>
9381+
<span>{{ data.tail }}</span>
9382+
</template>`
9383+
9384+
const ssrComponents: any = {}
9385+
ssrComponents.MountedPortal = compile(
9386+
mountedPortalCode,
9387+
data,
9388+
ssrComponents,
9389+
{
9390+
vapor: false,
9391+
ssr: true,
9392+
},
9393+
)
9394+
ssrComponents.Portal = compile(portalCode, data, ssrComponents, {
9395+
vapor: false,
9396+
ssr: true,
9397+
})
9398+
const clientComponents: any = {}
9399+
clientComponents.MountedPortal = compile(
9400+
mountedPortalCode,
9401+
data,
9402+
clientComponents,
9403+
{
9404+
vapor: false,
9405+
ssr: false,
9406+
},
9407+
)
9408+
clientComponents.Portal = compile(portalCode, data, clientComponents, {
9409+
vapor: false,
9410+
ssr: false,
9411+
})
9412+
const serverComp = compile(rootCode, data, ssrComponents, {
9413+
vapor: true,
9414+
ssr: true,
9415+
})
9416+
const html = await VueServerRenderer.renderToString(
9417+
runtimeDom.createSSRApp(serverComp),
9418+
)
9419+
9420+
const target = document.createElement('div')
9421+
target.id = targetId
9422+
document.body.appendChild(target)
9423+
9424+
const container = document.createElement('div')
9425+
container.innerHTML = html
9426+
document.body.appendChild(container)
9427+
9428+
const clientComp = compile(rootCode, data, clientComponents, {
9429+
vapor: true,
9430+
ssr: false,
9431+
})
9432+
const app = createVaporSSRApp(clientComp)
9433+
app.use(runtimeVapor.vaporInteropPlugin)
9434+
try {
9435+
app.mount(container)
9436+
await nextTick()
9437+
9438+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
9439+
expect(formatHtml(target.innerHTML)).toBe('<span>teleported</span>')
9440+
9441+
data.value.show = false
9442+
await nextTick()
9443+
9444+
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
9445+
"
9446+
<!--[--><p>next</p><!--if--><span>tail</span><!--]-->
9447+
"
9448+
`)
9449+
expect(target.innerHTML).toBe('')
9450+
} finally {
9451+
app.unmount()
9452+
container.remove()
9453+
target.remove()
9454+
}
9455+
})
9456+
93429457
test('hydrate Suspense VNode via createDynamicComponent and switch branch', async () => {
93439458
const data = ref({
93449459
showSuspense: true,

packages/runtime-vapor/src/vdomInterop.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -636,9 +636,8 @@ const vaporSlotsProxyHandler: ProxyHandler<any> = {
636636

637637
let vdomHydrateNode: HydrationRenderer['hydrateNode'] | undefined
638638

639-
// Static/Fragment vnodes always represent a contiguous range [el..anchor].
640-
// For component vnodes, only treat them as a range when their hydrated subTree
641-
// is Static/Fragment (multi-root component case).
639+
// Static/Fragment/Teleport vnodes represent a root range [el..anchor].
640+
// Component roots can update internally, so resolve through the current subtree.
642641
function resolveVNodeRange(vnode: VNode): [Node, Node] | undefined {
643642
const { type, shapeFlag, el, anchor } = vnode
644643
if (shapeFlag & ShapeFlags.TELEPORT && el && anchor && anchor !== el) {
@@ -648,21 +647,11 @@ function resolveVNodeRange(vnode: VNode): [Node, Node] | undefined {
648647
if ((type === Static || type === Fragment) && el && anchor && anchor !== el) {
649648
return [el as Node, anchor as Node]
650649
}
651-
if (!(shapeFlag & ShapeFlags.COMPONENT)) {
652-
return
653-
}
654-
655-
const subTree = vnode.component && vnode.component.subTree
656-
const subEl = subTree && subTree.el
657-
const subAnchor = subTree && subTree.anchor
658-
if (
659-
subTree &&
660-
(subTree.type === Static || subTree.type === Fragment) &&
661-
subEl &&
662-
subAnchor &&
663-
subAnchor !== subEl
664-
) {
665-
return [subEl as Node, subAnchor as Node]
650+
if (shapeFlag & ShapeFlags.COMPONENT) {
651+
const subTree = vnode.component && vnode.component.subTree
652+
if (subTree) {
653+
return resolveVNodeRange(subTree)
654+
}
666655
}
667656
}
668657

@@ -687,9 +676,27 @@ function resolveVNodeNodes(vnode: VNode): Block {
687676
}
688677
return nodeRange
689678
}
679+
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
680+
const subTree = vnode.component && vnode.component.subTree
681+
if (subTree) {
682+
return resolveVNodeNodes(subTree)
683+
}
684+
}
690685
return vnode.el as Block
691686
}
692687

688+
function removeAttachedNodes(block: Block, parent: ParentNode): void {
689+
if (block instanceof Node) {
690+
if (block.parentNode === parent) {
691+
remove(block, parent)
692+
}
693+
} else if (isArray(block)) {
694+
for (let i = 0; i < block.length; i++) {
695+
removeAttachedNodes(block[i], parent)
696+
}
697+
}
698+
}
699+
693700
function appendVnodeUpdatedHook(vnode: VNode, hook: () => void): void {
694701
const props = (vnode.props ||= {})
695702
const existing = props.onVnodeUpdated
@@ -901,7 +908,20 @@ function createVDOMComponent(
901908

902909
let rawRef: VNodeNormalizedRef | null = null
903910
let isMounted = false
911+
let isUnmounted = false
912+
let isDomRemoved = false
913+
const removeDom = (parentNode?: ParentNode): void => {
914+
if (!parentNode || isDomRemoved) {
915+
return
916+
}
917+
removeAttachedNodes(resolveVNodeNodes(vnode), parentNode)
918+
isDomRemoved = true
919+
}
904920
const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
921+
if (isUnmounted) {
922+
removeDom(parentNode)
923+
return
924+
}
905925
// unset ref
906926
if (rawRef) vdomSetRef(rawRef, null, null, vnode, true)
907927
if (transition) setVNodeTransitionHooks(vnode, transition)
@@ -915,7 +935,12 @@ function createVDOMComponent(
915935
)
916936
return
917937
}
938+
// Vapor block removal and scope disposal can both reach this path.
939+
// VDOM fragment ranges must only be removed once.
940+
isUnmounted = true
941+
isMounted = false
918942
internals.umt(vnode.component!, null, !!parentNode)
943+
removeDom(parentNode)
919944
}
920945

921946
frag.hydrate = () => {

0 commit comments

Comments
 (0)