fix: preserve empty slot anchor in vapor components in ssr

This commit is contained in:
daiwei 2025-08-12 16:23:55 +08:00
parent e6d037d8fe
commit 44867b14eb
5 changed files with 115 additions and 48 deletions

View File

@ -18,7 +18,9 @@ describe('insertion anchors', () => {
expect(
getCompiledString(
`<component :is="'div'">
<div><Comp/><Comp/><span/></div>
<div>
<Comp/><Comp/><span/>
</div>
</component>`,
{ vapor: true },
),
@ -70,7 +72,11 @@ describe('insertion anchors', () => {
expect(
getCompiledString(
`<component :is="'div'">
<div><slot name="foo"/><slot/><span/></div>
<div>
<slot name="foo"/>
<slot/>
<span/>
</div>
</component>`,
{ vapor: true },
),
@ -363,7 +369,9 @@ describe('insertion anchors', () => {
expect(
getCompiledString(
`<component :is="'div'">
<div><span v-for="item in items"/><span/></div>
<div>
<span v-for="item in items"/><span/>
</div>
</component>`,
{ vapor: true },
),
@ -444,7 +452,13 @@ describe('insertion anchors', () => {
test('mixed anchors in ssr slot vnode fallback', () => {
expect(
getCompiledString(
`<component :is="'div'"><Comp/><span/><Comp/><span/><Comp/></component>`,
`<component :is="'div'">
<div>
<Comp/><span/>
<Comp/><span/>
<Comp/>
</div>
</component>`,
{
vapor: true,
},
@ -454,13 +468,16 @@ describe('insertion anchors', () => {
_ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent('div'), null, {
default: _withCtx((_, _push, _parent, _scopeId) => {
if (_push) {
_push(\`<div\${_scopeId}><!--[p-->\`)
_push(_ssrRenderComponent(_component_Comp, null, null, _parent, _scopeId))
_push(\`<span\${_scopeId}></span>\`)
_push(\`<!--p]--><span\${_scopeId}></span><!--[i-->\`)
_push(_ssrRenderComponent(_component_Comp, null, null, _parent, _scopeId))
_push(\`<span\${_scopeId}></span>\`)
_push(\`<!--i]--><span\${_scopeId}></span><!--[a-->\`)
_push(_ssrRenderComponent(_component_Comp, null, null, _parent, _scopeId))
_push(\`<!--a]--></div>\`)
} else {
return [
_createVNode("div", null, [
_createCommentVNode("[p"),
_createVNode(_component_Comp),
_createCommentVNode("p]"),
@ -472,6 +489,7 @@ describe('insertion anchors', () => {
_createCommentVNode("[a"),
_createVNode(_component_Comp),
_createCommentVNode("a]")
])
]
}
}),

View File

@ -15,6 +15,7 @@ import {
Namespaces,
type NodeTransform,
NodeTypes,
type PlainElementNode,
RESOLVE_DYNAMIC_COMPONENT,
type ReturnStatement,
type RootNode,
@ -139,7 +140,7 @@ export const ssrTransformComponent: NodeTransform = (node, context) => {
if (clonedNode.children.length) {
buildSlots(clonedNode, context, (props, vFor, children) => {
vnodeBranches.push(
createVNodeSlotBranch(props, vFor, children, context),
createVNodeSlotBranch(props, vFor, children, context, clonedNode),
)
return createFunctionExpression(undefined)
})
@ -302,6 +303,7 @@ function createVNodeSlotBranch(
vFor: DirectiveNode | undefined,
children: TemplateChildNode[],
parentContext: TransformContext,
parent: TemplateChildNode,
): ReturnStatement {
// apply a sub-transform using vnode-based transforms.
const rawOptions = rawOptionsMap.get(parentContext.root)!
@ -338,7 +340,7 @@ function createVNodeSlotBranch(
}
if (parentContext.vapor) {
children = injectVaporInsertionAnchors(children)
children = injectVaporInsertionAnchors(children, parent)
}
const wrapperNode: TemplateNode = {
@ -395,8 +397,12 @@ function subTransform(
function injectVaporInsertionAnchors(
children: TemplateChildNode[],
parent: TemplateChildNode,
): TemplateChildNode[] {
if (isElementWithChildren(parent)) {
processBlockNodeAnchor(children)
}
const newChildren: TemplateChildNode[] = new Array(children.length * 3)
let newIndex = 0
@ -439,12 +445,10 @@ function injectVaporInsertionAnchors(
// copy branch nodes
for (let j = i; j <= lastBranchIndex; j++) {
const node = children[j]
const node = children[j] as PlainElementNode
newChildren[newIndex++] = node
if (isElementWithChildren(node)) {
node.children = injectVaporInsertionAnchors(node.children)
}
node.children = injectVaporInsertionAnchors(node.children, node)
}
// inject anchor after branch nodes
@ -464,9 +468,7 @@ function injectVaporInsertionAnchors(
newChildren[newIndex++] = child
if (anchor) newChildren[newIndex++] = createAnchorComment(`${anchor}]`)
if (isElementWithChildren(child)) {
child.children = injectVaporInsertionAnchors(child.children)
}
child.children = injectVaporInsertionAnchors(child.children, child)
}
newChildren.length = newIndex

View File

@ -3027,6 +3027,43 @@ describe('Vapor Mode hydration', () => {
)
})
test('forwarded slot with fallback', async () => {
const data = reactive({
foo: 'foo',
})
const { container } = await testHydration(
`<template>
<components.Parent/>
</template>`,
{
Parent: `<template><components.Child><slot/></components.Child></template>`,
Child: `<template><div><slot>{{data.foo}}</slot></div></template>`,
},
data,
)
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
`
"<div>
<!--[a-->
<!--[--><!--slot-->foo<!--]-->
<!--slot--><!--a]-->
</div>"
`,
)
data.foo = 'foo1'
await nextTick()
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
`
"<div>
<!--[a-->
<!--[--><!--slot-->foo1<!--]-->
<!--slot--><!--a]-->
</div>"
`,
)
})
test('forwarded slot with empty content', async () => {
const data = reactive({
foo: 'foo',
@ -3071,8 +3108,8 @@ describe('Vapor Mode hydration', () => {
`
"<div>
<!--[p-->
<!--[--><!--]-->
<!--slot--><!--slot--><!--slot--><!--slot--><!--p]-->
<!--[--><!--slot--><!--slot--><!--slot--><!--]-->
<!--slot--><!--p]-->
<div>foo</div></div>"
`,
)
@ -3083,8 +3120,8 @@ describe('Vapor Mode hydration', () => {
`
"<div>
<!--[p-->
<!--[--><!--]-->
<!--slot--><!--slot--><!--slot--><!--slot--><!--p]-->
<!--[--><!--slot--><!--slot--><!--slot--><!--]-->
<!--slot--><!--p]-->
<div>bar</div></div>"
`,
)

View File

@ -145,6 +145,9 @@ export class DynamicFragment extends VaporFragment {
}
hydrate = (label: string, isEmpty: boolean = false): void => {
// avoid repeated hydration during rendering fallback
if (this.anchor) return
const createAnchor = () => {
const { parentNode, nextSibling } = findLastChild(this.nodes)!
parentNode!.insertBefore(
@ -156,14 +159,8 @@ export class DynamicFragment extends VaporFragment {
// manually create anchors for:
// 1. else-if branch
// 2. empty forwarded slot
// (not present in SSR output)
if (
label === ELSE_IF_ANCHOR_LABEL ||
(this.nodes instanceof DynamicFragment &&
this.nodes.forwarded &&
!isValidBlock(this.nodes))
) {
if (label === ELSE_IF_ANCHOR_LABEL) {
createAnchor()
} else {
// for `v-if="false"`, the node will be an empty comment, use it as the anchor.

View File

@ -5,7 +5,7 @@ import {
type SSRBufferItem,
renderVNodeChildren,
} from '../render'
import { isArray } from '@vue/shared'
import { isArray, isString } from '@vue/shared'
const { ensureValidVNode } = ssrUtils
@ -83,7 +83,20 @@ export function ssrRenderSlotInner(
isEmptySlot = false
} else {
for (let i = 0; i < slotBuffer.length; i++) {
if (!isComment(slotBuffer[i])) {
const buffer = slotBuffer[i]
// preserve empty slot anchor in vapor components
// DynamicFragment requires this anchor
if (
parentComponent.type.__vapor &&
isString(buffer) &&
buffer === '<!--slot-->'
) {
push(buffer)
continue
}
if (!isComment(buffer)) {
isEmptySlot = false
break
}