From 44867b14eb7cb856e86283aa53e682a90d25a3a3 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 12 Aug 2025 16:23:55 +0800 Subject: [PATCH] fix: preserve empty slot anchor in vapor components in ssr --- .../__tests__/ssrVaporAnchors.spec.ts | 68 ++++++++++++------- .../src/transforms/ssrTransformComponent.ts | 22 +++--- .../runtime-vapor/__tests__/hydration.spec.ts | 45 ++++++++++-- packages/runtime-vapor/src/fragment.ts | 11 ++- .../src/helpers/ssrRenderSlot.ts | 17 ++++- 5 files changed, 115 insertions(+), 48 deletions(-) diff --git a/packages/compiler-ssr/__tests__/ssrVaporAnchors.spec.ts b/packages/compiler-ssr/__tests__/ssrVaporAnchors.spec.ts index d29be57cf..232b12e25 100644 --- a/packages/compiler-ssr/__tests__/ssrVaporAnchors.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrVaporAnchors.spec.ts @@ -18,7 +18,9 @@ describe('insertion anchors', () => { expect( getCompiledString( ` -
+
+ +
`, { vapor: true }, ), @@ -70,7 +72,11 @@ describe('insertion anchors', () => { expect( getCompiledString( ` -
+
+ + + +
`, { vapor: true }, ), @@ -137,13 +143,13 @@ describe('insertion anchors', () => { expect( getCompiledString( ` -
- - - - -
-
`, +
+ + + + +
+ `, { vapor: true }, ), ).toMatchInlineSnapshot(` @@ -247,7 +253,7 @@ describe('insertion anchors', () => { expect( getCompiledString( ` -
+
@@ -363,7 +369,9 @@ describe('insertion anchors', () => { expect( getCompiledString( ` -
+
+ +
`, { vapor: true }, ), @@ -444,7 +452,13 @@ describe('insertion anchors', () => { test('mixed anchors in ssr slot vnode fallback', () => { expect( getCompiledString( - ``, + ` +
+ + + +
+
`, { vapor: true, }, @@ -454,24 +468,28 @@ describe('insertion anchors', () => { _ssrRenderVNode(_push, _createVNode(_resolveDynamicComponent('div'), null, { default: _withCtx((_, _push, _parent, _scopeId) => { if (_push) { + _push(\`\`) _push(_ssrRenderComponent(_component_Comp, null, null, _parent, _scopeId)) - _push(\`
\`) + _push(\`
\`) _push(_ssrRenderComponent(_component_Comp, null, null, _parent, _scopeId)) - _push(\`
\`) + _push(\`\`) _push(_ssrRenderComponent(_component_Comp, null, null, _parent, _scopeId)) + _push(\`
\`) } else { return [ - _createCommentVNode("[p"), - _createVNode(_component_Comp), - _createCommentVNode("p]"), - _createVNode("span"), - _createCommentVNode("[i"), - _createVNode(_component_Comp), - _createCommentVNode("i]"), - _createVNode("span"), - _createCommentVNode("[a"), - _createVNode(_component_Comp), - _createCommentVNode("a]") + _createVNode("div", null, [ + _createCommentVNode("[p"), + _createVNode(_component_Comp), + _createCommentVNode("p]"), + _createVNode("span"), + _createCommentVNode("[i"), + _createVNode(_component_Comp), + _createCommentVNode("i]"), + _createVNode("span"), + _createCommentVNode("[a"), + _createVNode(_component_Comp), + _createCommentVNode("a]") + ]) ] } }), diff --git a/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts b/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts index db63235ca..5137da0e2 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts @@ -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[] { - processBlockNodeAnchor(children) + 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 diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index ecfd1a897..2173894b2 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -3027,6 +3027,43 @@ describe('Vapor Mode hydration', () => { ) }) + test('forwarded slot with fallback', async () => { + const data = reactive({ + foo: 'foo', + }) + const { container } = await testHydration( + ``, + { + Parent: ``, + Child: ``, + }, + data, + ) + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + "
+ + foo + +
" + `, + ) + + data.foo = 'foo1' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + "
+ + foo1 + +
" + `, + ) + }) + test('forwarded slot with empty content', async () => { const data = reactive({ foo: 'foo', @@ -3071,8 +3108,8 @@ describe('Vapor Mode hydration', () => { ` "
- - + +
foo
" `, ) @@ -3083,8 +3120,8 @@ describe('Vapor Mode hydration', () => { ` "
- - + +
bar
" `, ) diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 9b37419e6..53fc4340d 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -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. diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts index 19aa4ce63..0e2ee5d6c 100644 --- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts +++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts @@ -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 === '' + ) { + push(buffer) + continue + } + + if (!isComment(buffer)) { isEmptySlot = false break }