]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix: preserve empty slot anchor in vapor components in ssr
authordaiwei <daiwei521@126.com>
Tue, 12 Aug 2025 08:23:55 +0000 (16:23 +0800)
committerdaiwei <daiwei521@126.com>
Tue, 12 Aug 2025 08:23:55 +0000 (16:23 +0800)
packages/compiler-ssr/__tests__/ssrVaporAnchors.spec.ts
packages/compiler-ssr/src/transforms/ssrTransformComponent.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/fragment.ts
packages/server-renderer/src/helpers/ssrRenderSlot.ts

index d29be57cfe42c1b8adc6fd1f401a9b9d6d179855..232b12e25da3b14b1360d5c255cabfda2721d45a 100644 (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 },
         ),
@@ -137,13 +143,13 @@ describe('insertion anchors', () => {
       expect(
         getCompiledString(
           `<component :is="'div'">
-              <div>
-                <span v-if="foo"/>
-                <span v-else-if="bar"/>
-                <span v-else/>
-                <span/>
-              </div>
-            </component>`,
+            <div>
+              <span v-if="foo"/>
+              <span v-else-if="bar"/>
+              <span v-else/>
+              <span/>
+            </div>
+          </component>`,
           { vapor: true },
         ),
       ).toMatchInlineSnapshot(`
@@ -247,7 +253,7 @@ describe('insertion anchors', () => {
       expect(
         getCompiledString(
           `<component :is="'div'">
-              <div>
+            <div>
               <span v-if="foo">
                 <span v-if="foo1" />
                 <span />
@@ -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,24 +468,28 @@ 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 [
-                _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]")
+                ])
               ]
             }
           }),
index db63235caf5834aa00b35caf7ca7268c7327a37e..5137da0e28667a5ee163f7122f9ef548aedde7dc 100644 (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[] {
-  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
index ecfd1a8977abd148edb880b11a6ad03de7ee70c1..2173894b289cffa5b741f8901e4672569ca468a6 100644 (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>"
       `,
       )
index 9b37419e6e327d0429bff737516f3328ab13972b..53fc4340de29785e52b6eedbada42d63a0202b73 100644 (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.
index 19aa4ce63b76c1a1118fd13a4dc32c6bae35533f..0e2ee5d6c213ea0addbe757f5b4a1513370f7ebe 100644 (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
           }