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

View File

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

View File

@ -145,6 +145,9 @@ export class DynamicFragment extends VaporFragment {
} }
hydrate = (label: string, isEmpty: boolean = false): void => { hydrate = (label: string, isEmpty: boolean = false): void => {
// avoid repeated hydration during rendering fallback
if (this.anchor) return
const createAnchor = () => { const createAnchor = () => {
const { parentNode, nextSibling } = findLastChild(this.nodes)! const { parentNode, nextSibling } = findLastChild(this.nodes)!
parentNode!.insertBefore( parentNode!.insertBefore(
@ -156,14 +159,8 @@ export class DynamicFragment extends VaporFragment {
// manually create anchors for: // manually create anchors for:
// 1. else-if branch // 1. else-if branch
// 2. empty forwarded slot
// (not present in SSR output) // (not present in SSR output)
if ( if (label === ELSE_IF_ANCHOR_LABEL) {
label === ELSE_IF_ANCHOR_LABEL ||
(this.nodes instanceof DynamicFragment &&
this.nodes.forwarded &&
!isValidBlock(this.nodes))
) {
createAnchor() createAnchor()
} else { } else {
// for `v-if="false"`, the node will be an empty comment, use it as the anchor. // 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, type SSRBufferItem,
renderVNodeChildren, renderVNodeChildren,
} from '../render' } from '../render'
import { isArray } from '@vue/shared' import { isArray, isString } from '@vue/shared'
const { ensureValidVNode } = ssrUtils const { ensureValidVNode } = ssrUtils
@ -83,7 +83,20 @@ export function ssrRenderSlotInner(
isEmptySlot = false isEmptySlot = false
} else { } else {
for (let i = 0; i < slotBuffer.length; i++) { 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 isEmptySlot = false
break break
} }