Skip to content

Commit 08fc0e9

Browse files
committed
fix(runtime-vapor): preserve hydrated VDOM slot fragment anchors
1 parent f30017e commit 08fc0e9

2 files changed

Lines changed: 134 additions & 4 deletions

File tree

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9241,6 +9241,104 @@ describe('VDOM interop', () => {
92419241
`)
92429242
})
92439243

9244+
test('hydrate VDOM Teleport slot content and switch vapor branch', async () => {
9245+
const targetId = 'interop-vdom-teleport-slot-hydration-target'
9246+
const data = ref({
9247+
show: true,
9248+
target: targetId,
9249+
tail: 'tail',
9250+
})
9251+
const portalCode = `<script setup>
9252+
defineOptions({ name: 'VDomPortal' })
9253+
defineProps({ to: String })
9254+
</script>
9255+
<template>
9256+
<Teleport :to="to">
9257+
<slot />
9258+
</Teleport>
9259+
</template>`
9260+
const rootCode = `<script setup vapor>
9261+
const data = _data
9262+
const components = _components
9263+
</script>
9264+
<template>
9265+
<components.Portal v-if="data.show" :to="'#' + data.target">
9266+
<span>teleported</span>
9267+
</components.Portal>
9268+
<p v-else>next</p>
9269+
<span>{{ data.tail }}</span>
9270+
</template>`
9271+
9272+
const ssrComponents = {
9273+
Portal: compile(portalCode, data, {}, { vapor: false, ssr: true }),
9274+
}
9275+
const clientComponents = {
9276+
Portal: compile(portalCode, data, {}, { vapor: false, ssr: false }),
9277+
}
9278+
const serverComp = compile(rootCode, data, ssrComponents, {
9279+
vapor: true,
9280+
ssr: true,
9281+
})
9282+
const ssrCtx: Record<string, any> = {}
9283+
const html = await VueServerRenderer.renderToString(
9284+
runtimeDom.createSSRApp(serverComp),
9285+
ssrCtx,
9286+
)
9287+
9288+
const target = document.createElement('div')
9289+
target.id = targetId
9290+
target.innerHTML = ssrCtx.teleports[`#${targetId}`]
9291+
const nextTarget = document.createElement('div')
9292+
nextTarget.id = `${targetId}-next`
9293+
document.body.appendChild(target)
9294+
document.body.appendChild(nextTarget)
9295+
9296+
const container = document.createElement('div')
9297+
container.innerHTML = html
9298+
document.body.appendChild(container)
9299+
9300+
const clientComp = compile(rootCode, data, clientComponents, {
9301+
vapor: true,
9302+
ssr: false,
9303+
})
9304+
const app = createVaporSSRApp(clientComp)
9305+
app.use(runtimeVapor.vaporInteropPlugin)
9306+
try {
9307+
app.mount(container)
9308+
9309+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
9310+
expect(formatHtml(target.innerHTML)).toBe(
9311+
'<!--teleport start anchor-->\n' +
9312+
'<!--[--><span>teleported</span><!--]-->\n' +
9313+
'<!--teleport anchor-->',
9314+
)
9315+
9316+
data.value.target = `${targetId}-next`
9317+
await nextTick()
9318+
9319+
expect(target.innerHTML).not.toContain('<!--[-->')
9320+
expect(nextTarget.innerHTML).toContain(
9321+
'<!--[--><span>teleported</span><!--]-->',
9322+
)
9323+
9324+
data.value.show = false
9325+
await nextTick()
9326+
9327+
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
9328+
"
9329+
<!--[--><p>next</p><!--if--><span>tail</span><!--]-->
9330+
"
9331+
`)
9332+
expect(target.innerHTML).toBe('')
9333+
expect(nextTarget.innerHTML).toBe('')
9334+
} finally {
9335+
app.unmount()
9336+
container.remove()
9337+
target.remove()
9338+
nextTarget.remove()
9339+
}
9340+
})
9341+
92449342
test('hydrate Suspense VNode via createDynamicComponent and switch branch', async () => {
92459343
const data = ref({
92469344
showSuspense: true,

packages/runtime-vapor/src/vdomInterop.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ const vaporInteropImpl: Omit<
286286
unmount(vnode, doRemove) {
287287
const container = doRemove ? vnode.anchor!.parentNode : undefined
288288
const instance = vnode.component as any as VaporComponentInstance
289+
let slotStartAnchor: Node | null = null
289290
if (instance) {
290291
// the async component may not be resolved yet, block is null
291292
if (instance.block) {
@@ -304,6 +305,11 @@ const vaporInteropImpl: Omit<
304305
}
305306
} else if (vnode.vb) {
306307
const anchor = vnode.anchor as Node | null
308+
// `hydrateSlot()` records the opening marker for VDOM SSR slot fragments
309+
// on vnode.el while vnode.anchor points at the closing marker.
310+
if (vnode.el && vnode.el !== anchor && isComment(vnode.el as Node, '[')) {
311+
slotStartAnchor = vnode.el as Node
312+
}
307313
// Fragment child unmounts invoke VaporSlot with doRemove = false, so the
308314
// renderer does not pass us a container. Most slot blocks can still
309315
// clean themselves up without it, but KeepAlive needs the host container
@@ -317,6 +323,12 @@ const vaporInteropImpl: Omit<
317323
stopVaporSlotScope(vnode)
318324
}
319325
if (doRemove) {
326+
if (slotStartAnchor) {
327+
const parent = slotStartAnchor.parentNode
328+
if (parent) {
329+
remove(slotStartAnchor, parent)
330+
}
331+
}
320332
const anchor = vnode.anchor as Node
321333
// `container` is captured before unmount starts, but the unmount above
322334
// may already remove or move this anchor. Only remove it if it is still
@@ -362,6 +374,10 @@ const vaporInteropImpl: Omit<
362374
const selfAnchor = n1.anchor as Node
363375
const parent = selfAnchor.parentNode as ParentNode
364376
const nextSibling = selfAnchor.nextSibling
377+
const rangeStartAnchor =
378+
n1.el && n1.el !== selfAnchor && isComment(n1.el as Node, '[')
379+
? (n1.el as Node)
380+
: undefined
365381
const oldBlockOwnsAnchor =
366382
isFragment(n1.vb!) && n1.vb!.anchor === selfAnchor
367383
// remove old vapor block
@@ -380,12 +396,14 @@ const vaporInteropImpl: Omit<
380396
newAnchor = selfAnchor
381397
insertAnchor = selfAnchor
382398
}
383-
insert((n2.el = n2.anchor = newAnchor), parent, insertAnchor)
399+
insert((n2.anchor = newAnchor), parent, insertAnchor)
400+
n2.el = rangeStartAnchor || newAnchor
384401
insert((n2.vb = slotBlock), parent, newAnchor)
385402
} else {
386403
const vs1 = n1.vs!
387404
const vs2 = n2.vs!
388-
n2.el = n2.anchor = n1.anchor
405+
n2.el = n1.el
406+
n2.anchor = n1.anchor
389407
n2.vb = n1.vb
390408
;(vs2.ref = vs1.ref)!.value = n2.props
391409
vs2.scope = vs1.scope
@@ -395,6 +413,13 @@ const vaporInteropImpl: Omit<
395413
},
396414

397415
move(vnode, container, anchor, moveType) {
416+
if (
417+
vnode.el &&
418+
vnode.el !== vnode.anchor &&
419+
isComment(vnode.el as Node, '[')
420+
) {
421+
move(vnode.el as any, container, anchor, moveType)
422+
}
398423
move(vnode.vb || (vnode.component as any), container, anchor, moveType)
399424
move(vnode.anchor as any, container, anchor, moveType)
400425
},
@@ -432,11 +457,18 @@ const vaporInteropImpl: Omit<
432457
if (!isHydrating && !isVdomHydrating) return node
433458
vaporHydrateNode(node, () => {
434459
vnode.vb = renderVaporSlot(vnode, parentComponent, parentSuspense)
435-
vnode.anchor = vnode.el =
460+
const anchor =
436461
isFragment(vnode.vb) && vnode.vb.anchor
437462
? vnode.vb.anchor
438463
: currentHydrationNode!
439-
464+
// VDOM SSR wraps slot output in fragment anchors. Keep that range on the
465+
// VaporSlot vnode so enabled Teleport removal can dispose both anchors.
466+
if (isComment(node, '[') && isComment(anchor, ']')) {
467+
vnode.el = node
468+
vnode.anchor = anchor
469+
} else {
470+
vnode.anchor = vnode.el = anchor
471+
}
440472
if (__DEV__ && !vnode.anchor) {
441473
throw new Error(
442474
`Failed to locate slot anchor. this is likely a Vue internal bug.`,

0 commit comments

Comments
 (0)