Просмотр исходного кода

chore: Merge branch 'minor' into rolldown

daiwei 3 месяцев назад
Родитель
Сommit
ffb13c2c14

+ 53 - 0
packages-private/vapor-e2e-test/__tests__/transition.spec.ts

@@ -1179,6 +1179,59 @@ describe('vapor transition', () => {
         '<div class="">vapor compA</div>',
       )
     })
+
+    test('apply transition to pre-resolved async component', async () => {
+      const btnSelector = '.async-resolved > button'
+      const containerSelector = '.async-resolved #container'
+      const hiddenCompSelector = '.async-resolved #hidden-async'
+
+      // Wait for the hidden AsyncCompResolved to resolve and render
+      await waitForInnerHTML(
+        hiddenCompSelector,
+        '<div style="display: none;">vapor compA</div>',
+      )
+
+      expect(await html(containerSelector)).toBe('')
+
+      await click(btnSelector)
+      expect(await html(containerSelector)).toBe(
+        '<div class="v-enter-from v-enter-active">vapor compA</div>',
+      )
+      await waitForInnerHTML(
+        containerSelector,
+        '<div class="v-enter-active v-enter-to">vapor compA</div>',
+      )
+      await waitForInnerHTML(
+        containerSelector,
+        '<div class="">vapor compA</div>',
+      )
+
+      // leave
+      await click(btnSelector)
+      await nextTick()
+      expect(await html(containerSelector)).toBe(
+        '<div class="v-leave-from v-leave-active">vapor compA</div>',
+      )
+      await waitForInnerHTML(
+        containerSelector,
+        '<div class="v-leave-active v-leave-to">vapor compA</div>',
+      )
+      await waitForInnerHTML(containerSelector, '')
+
+      // enter again
+      await click(btnSelector)
+      expect(await html(containerSelector)).toBe(
+        '<div class="v-enter-from v-enter-active">vapor compA</div>',
+      )
+      await waitForInnerHTML(
+        containerSelector,
+        '<div class="v-enter-active v-enter-to">vapor compA</div>',
+      )
+      await waitForInnerHTML(
+        containerSelector,
+        '<div class="">vapor compA</div>',
+      )
+    })
   })
 
   describe('transition with v-show', () => {

+ 16 - 0
packages-private/vapor-e2e-test/transition/App.vue

@@ -112,6 +112,10 @@ const AsyncComp = defineVaporAsyncComponent(() => {
   return new Promise(resolve => setTimeout(() => resolve(VaporCompA), 50))
 })
 
+const AsyncCompResolved = defineVaporAsyncComponent(() =>
+  Promise.resolve(VaporCompA),
+)
+
 const TrueBranch = defineVaporComponent({
   name: 'TrueBranch',
   setup() {
@@ -649,6 +653,18 @@ const Comp2 = defineVaporComponent({
       </div>
       <button @click="toggle = !toggle">button</button>
     </div>
+    <div class="async-resolved">
+      <!-- Pre-resolve the async component by rendering it hidden -->
+      <div id="hidden-async">
+        <AsyncCompResolved v-show="false" />
+      </div>
+      <div id="container">
+        <transition>
+          <AsyncCompResolved v-if="!toggle"></AsyncCompResolved>
+        </transition>
+      </div>
+      <button @click="toggle = !toggle">button</button>
+    </div>
     <!-- async component end -->
 
     <!-- with teleport -->

+ 15 - 0
packages/compiler-vapor/__tests__/transforms/TransformTransition.spec.ts

@@ -188,6 +188,21 @@ describe('compiler: transition', () => {
     )
   })
 
+  test('does not warn with multiple children in v-if branch', () => {
+    checkWarning(
+      `
+      <transition>
+        <h1 v-if="condition">
+          <span>True</span>
+          <span>True</span>
+        </h1>
+        <h1 v-else>False</h1>
+      </transition>
+      `,
+      false,
+    )
+  })
+
   test('inject persisted when child has v-show', () => {
     expect(
       compileWithElementTransform(`

+ 4 - 5
packages/compiler-vapor/src/generators/text.ts

@@ -10,8 +10,8 @@ export function genSetText(
   context: CodegenContext,
 ): CodeFragment[] {
   const { helper } = context
-  const { element, values, generated, jsx, isComponent } = oper
-  const texts = combineValues(values, context, jsx)
+  const { element, values, generated, isComponent } = oper
+  const texts = combineValues(values, context)
   return [
     NEWLINE,
     ...genCall(
@@ -26,16 +26,15 @@ export function genSetText(
 function combineValues(
   values: SimpleExpressionNode[],
   context: CodegenContext,
-  jsx?: boolean,
 ): CodeFragment[] {
   return values.flatMap((value, i) => {
     let exp = genExpression(value, context)
-    if (!jsx && getLiteralExpressionValue(value, true) == null) {
+    if (getLiteralExpressionValue(value, true) == null) {
       // dynamic, wrap with toDisplayString
       exp = genCall(context.helper('toDisplayString'), exp)
     }
     if (i > 0) {
-      exp.unshift(jsx ? ', ' : ' + ')
+      exp.unshift(' + ')
     }
     return exp
   })

+ 0 - 1
packages/compiler-vapor/src/ir/index.ts

@@ -128,7 +128,6 @@ export interface SetTextIRNode extends BaseIRNode {
   element: number
   values: SimpleExpressionNode[]
   generated?: boolean // whether this is a generated empty text node by `processTextLikeContainer`
-  jsx?: boolean
   isComponent?: boolean
 }
 

+ 1 - 2
packages/compiler-vapor/src/transforms/transformTransition.ts

@@ -54,8 +54,7 @@ function hasMultipleChildren(node: ElementNode): boolean {
         // not has v-for
         !findDir(c, 'for') &&
         // if the first child has v-if, the rest should also have v-else-if/v-else
-        (index === 0 ? findDir(c, 'if') : hasElse(c)) &&
-        !hasMultipleChildren(c),
+        (index === 0 ? findDir(c, 'if') : hasElse(c)),
     )
   ) {
     return false

+ 10 - 0
packages/runtime-core/src/hydration.ts

@@ -53,6 +53,14 @@ import { isAsyncWrapper } from './apiAsyncComponent'
 import { isReactive } from '@vue/reactivity'
 import { updateHOCHostEl } from './componentRenderUtils'
 
+/**
+ * VDOM hydration state.
+ * Also used by vapor interop plugin for tree-shaking:
+ * In non-hydration builds, this is never set to true, so the logic in
+ * vaporInteropImpl's hydrate/hydrateSlot can be tree-shaken.
+ */
+export let isHydrating = false
+
 export type RootHydrateFunction = (
   vnode: VNode<Node, Element>,
   container: (Element | ShadowRoot) & { _vnode?: VNode },
@@ -138,7 +146,9 @@ export function createHydrationFunctions(
       return
     }
 
+    isHydrating = true
     hydrateNode(container.firstChild!, vnode, null, null, null)
+    isHydrating = false
     flushPostFlushCbs()
     container._vnode = vnode
   }

+ 1 - 0
packages/runtime-core/src/index.ts

@@ -650,6 +650,7 @@ export {
   isMapEqual,
   isValidHtmlOrSvgAttribute,
   getAttributeMismatch,
+  isHydrating,
 } from './hydration'
 /**
  * @internal

+ 64 - 0
packages/runtime-vapor/__tests__/scopeId.spec.ts

@@ -310,6 +310,70 @@ describe('scopeId', () => {
         `</div>`,
     )
   })
+
+  test('nested components with slots', async () => {
+    const Child = defineVaporComponent({
+      setup() {
+        const n0 = template('<div>')() as any
+        setInsertionState(n0, null, true)
+        createSlot('default')
+        return n0
+      },
+    })
+    const Parent = defineVaporComponent({
+      __scopeId: 'data-v-parent',
+      setup() {
+        const n3 = createComponent(
+          Child,
+          null,
+          {
+            default: withVaporCtx(() => {
+              const n2 = createComponent(
+                Child,
+                null,
+                {
+                  default: withVaporCtx(() => {
+                    const n1 = createComponent(
+                      Child,
+                      null,
+                      {
+                        default: () => {
+                          const t0 = template('test')() as any
+                          return t0
+                        },
+                      },
+                      true,
+                    )
+                    return n1
+                  }),
+                },
+                true,
+              )
+              return n2
+            }),
+          },
+          true,
+        )
+        return n3
+      },
+    })
+
+    const { host } = define({
+      __scopeId: 'app',
+      setup() {
+        return createComponent(Parent)
+      },
+    }).render()
+
+    expect(host.innerHTML).toBe(
+      `<div data-v-parent="" app="">` +
+        `<div data-v-parent="">` +
+        `<div data-v-parent="">test<!--slot-->` +
+        `</div><!--slot-->` +
+        `</div><!--slot-->` +
+        `</div>`,
+    )
+  })
 })
 
 describe('vdom interop', () => {

+ 36 - 9
packages/runtime-vapor/src/apiDefineAsyncComponent.ts

@@ -24,11 +24,11 @@ import {
   isHydrating,
   locateEndAnchor,
   removeFragmentNodes,
+  setCurrentHydrationNode,
 } from './dom/hydration'
-import { invokeArrayFns } from '@vue/shared'
 import { type TransitionOptions, insert, remove } from './block'
-import { parentNode } from './dom/node'
-import { setTransitionHooks } from './components/Transition'
+import { _next, parentNode } from './dom/node'
+import { invokeArrayFns } from '@vue/shared'
 
 /*@ __NO_SIDE_EFFECTS__ */
 export function defineVaporAsyncComponent<T extends VaporComponent>(
@@ -60,7 +60,38 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
       // not the actual hydrate function
       hydrate: () => void,
     ) {
-      // if async component needs to be updated before hydration, hydration is no longer needed.
+      // early return allows tree-shaking of hydration logic when not used
+      if (!isHydrating) return
+
+      // Create placeholder block that matches the adopted DOM.
+      // The async component may get unmounted before its inner component is loaded,
+      // so we need to give it a placeholder block.
+      if (isComment(el, '[')) {
+        const end = _next(locateEndAnchor(el)!)
+        const block = (instance.block = [el as Node])
+        let cur = el as Node
+        while (true) {
+          let n = _next(cur)
+          if (n && n !== end) {
+            block.push((cur = n))
+          } else {
+            break
+          }
+        }
+      } else {
+        instance.block = el
+      }
+
+      // Mark as mounted to ensure it can be unmounted before
+      // its inner component is resolved
+      instance.isMounted = true
+
+      // Advance current hydration node to the nextSibling
+      setCurrentHydrationNode(
+        isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling,
+      )
+
+      // If async component needs to be updated before hydration, hydration is no longer needed.
       let isHydrated = false
       watch(
         () => instance.attrs,
@@ -167,7 +198,6 @@ export function defineVaporAsyncComponent<T extends VaporComponent>(
           render = () => createComponent(loadingComponent)
         }
 
-        if (instance.$transition) frag!.$transition = instance.$transition
         frag.update(render)
         // Manually trigger cacheBlock for KeepAlive
         if (frag.keepAliveCtx) frag.keepAliveCtx.cacheBlock()
@@ -183,7 +213,7 @@ function createInnerComp(
   parent: VaporComponentInstance & TransitionOptions,
   frag?: DynamicFragment,
 ): VaporComponentInstance {
-  const { rawProps, rawSlots, appContext, $transition } = parent
+  const { rawProps, rawSlots, appContext } = parent
   const instance = createComponent(
     comp,
     rawProps,
@@ -195,9 +225,6 @@ function createInnerComp(
     appContext,
   )
 
-  // set transition hooks
-  if ($transition) setTransitionHooks(instance, $transition)
-
   // set ref
   frag && frag.setAsyncRef && frag.setAsyncRef(instance)
 

+ 2 - 31
packages/runtime-vapor/src/component.ts

@@ -93,14 +93,12 @@ import {
   adoptTemplate,
   advanceHydrationNode,
   currentHydrationNode,
-  isComment,
   isHydrating,
-  locateEndAnchor,
   locateHydrationNode,
   locateNextNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
-import { _next, createComment, createElement, createTextNode } from './dom/node'
+import { createComment, createElement, createTextNode } from './dom/node'
 import {
   type TeleportFragment,
   isTeleportFragment,
@@ -368,34 +366,7 @@ export function createComponent(
     component.__asyncHydrate &&
     !component.__asyncResolved
   ) {
-    // it may get unmounted before its inner component is loaded,
-    // so we need to give it a placeholder block that matches its
-    // adopted DOM
-    const el = currentHydrationNode!
-    if (isComment(el, '[')) {
-      const end = _next(locateEndAnchor(el)!)
-      const block = (instance.block = [el as Node])
-      let cur = el as Node
-      while (true) {
-        let n = _next(cur)
-        if (n && n !== end) {
-          block.push((cur = n))
-        } else {
-          break
-        }
-      }
-    } else {
-      instance.block = el
-    }
-    // also mark it as mounted to ensure it can be unmounted before
-    // its inner component is resolved
-    instance.isMounted = true
-
-    // advance current hydration node to the nextSibling
-    setCurrentHydrationNode(
-      isComment(el, '[') ? locateEndAnchor(el)! : el.nextSibling,
-    )
-    component.__asyncHydrate(el as Element, instance, () =>
+    component.__asyncHydrate(currentHydrationNode as Element, instance, () =>
       setupComponent(instance, component),
     )
   } else {

+ 1 - 1
packages/runtime-vapor/src/componentSlots.ts

@@ -159,7 +159,7 @@ export function getScopeOwner(): VaporComponentInstance | null {
  * 2. Elements inherit the slot owner's scopeId
  */
 export function withVaporCtx(fn: Function): BlockFn {
-  const owner = currentInstance as VaporComponentInstance
+  const owner = getScopeOwner()
   return (...args: any[]) => {
     const prevOwner = setCurrentSlotOwner(owner)
     try {

+ 3 - 0
packages/runtime-vapor/src/components/Teleport.ts

@@ -264,6 +264,7 @@ export class TeleportFragment extends VaporFragment {
   }
 
   private hydrateDisabledTeleport(targetNode: Node | null): void {
+    if (!isHydrating) return
     let nextNode = this.placeholder!.nextSibling!
     setCurrentHydrationNode(nextNode)
     this.mountAnchor = this.anchor = locateTeleportEndAnchor(nextNode)!
@@ -274,6 +275,7 @@ export class TeleportFragment extends VaporFragment {
   }
 
   private mountChildren(target: Node): void {
+    if (!isHydrating) return
     target.appendChild((this.targetStart = createTextNode('')))
     target.appendChild(
       (this.mountAnchor = this.targetAnchor = createTextNode('')),
@@ -294,6 +296,7 @@ export class TeleportFragment extends VaporFragment {
   }
 
   hydrate = (): void => {
+    if (!isHydrating) return
     const target = (this.target = resolveTeleportTarget(
       this.resolvedProps!,
       querySelector,

+ 41 - 35
packages/runtime-vapor/src/components/Transition.ts

@@ -32,7 +32,11 @@ import {
 } from '../component'
 import { isArray } from '@vue/shared'
 import { renderEffect } from '../renderEffect'
-import { type VaporFragment, isFragment } from '../fragment'
+import {
+  type DynamicFragment,
+  type VaporFragment,
+  isFragment,
+} from '../fragment'
 import {
   currentHydrationNode,
   isHydrating,
@@ -52,6 +56,30 @@ export const ensureTransitionHooksRegistered = (): void => {
   }
 }
 
+const hydrateTransitionImpl = () => {
+  if (!currentHydrationNode || !isTemplateNode(currentHydrationNode)) return
+  // replace <template> node with inner child
+  const {
+    content: { firstChild },
+    parentNode,
+  } = currentHydrationNode
+  if (firstChild) {
+    parentNode!.replaceChild(firstChild, currentHydrationNode)
+    setCurrentHydrationNode(firstChild)
+
+    if (firstChild instanceof HTMLElement || firstChild instanceof SVGElement) {
+      const originalDisplay = firstChild.style.display
+      firstChild.style.display = 'none'
+
+      return (hooks: TransitionHooks) => {
+        hooks.beforeEnter(firstChild)
+        firstChild.style.display = originalDisplay
+        queuePostFlushCb(() => hooks.enter(firstChild))
+      }
+    }
+  }
+}
+
 const decorate = (t: typeof VaporTransition) => {
   t.displayName = displayName
   t.props = TransitionPropsValidators
@@ -64,36 +92,7 @@ export const VaporTransition: FunctionalVaporComponent<TransitionProps> =
     // Register transition hooks on first use
     ensureTransitionHooksRegistered()
 
-    // wrapped <transition appear>
-    let performAppear: Function | undefined
-    if (
-      isHydrating &&
-      currentHydrationNode &&
-      isTemplateNode(currentHydrationNode)
-    ) {
-      // replace <template> node with inner child
-      const {
-        content: { firstChild },
-        parentNode,
-      } = currentHydrationNode
-      if (firstChild) {
-        if (
-          firstChild instanceof HTMLElement ||
-          firstChild instanceof SVGElement
-        ) {
-          const originalDisplay = firstChild.style.display
-          firstChild.style.display = 'none'
-          performAppear = () => {
-            hooks.beforeEnter(firstChild)
-            firstChild.style.display = originalDisplay
-            queuePostFlushCb(() => hooks.enter(firstChild))
-          }
-        }
-
-        parentNode!.replaceChild(firstChild, currentHydrationNode)
-        setCurrentHydrationNode(firstChild)
-      }
-    }
+    const performAppear = isHydrating ? hydrateTransitionImpl() : undefined
 
     const children = (slots.default && slots.default()) as any as Block
     if (!children) return []
@@ -117,7 +116,7 @@ export const VaporTransition: FunctionalVaporComponent<TransitionProps> =
     } as VaporTransitionHooks)
 
     if (resolvedProps!.appear && performAppear) {
-      performAppear()
+      performAppear(hooks)
     }
 
     return children
@@ -288,9 +287,16 @@ export function findTransitionBlock(
     // transition can only be applied on Element child
     if (block instanceof Element) child = block
   } else if (isVaporComponent(block)) {
-    // should save hooks on unresolved async wrapper, so that it can be applied after resolved
-    if (isAsyncWrapper(block) && !block.type.__asyncResolved) {
-      child = block
+    if (isAsyncWrapper(block)) {
+      // for unresolved async wrapper, set transition hooks on inner fragment
+      if (!block.type.__asyncResolved) {
+        onFragment && onFragment(block.block! as DynamicFragment)
+      } else {
+        child = findTransitionBlock(
+          (block.block! as DynamicFragment).nodes,
+          onFragment,
+        )
+      }
     } else {
       // stop searching if encountering nested Transition component
       if (getComponentName(block.type) === displayName) return undefined

+ 2 - 80
packages/runtime-vapor/src/dom/hydration.ts

@@ -1,9 +1,4 @@
-import {
-  MismatchTypes,
-  isMismatchAllowed,
-  queuePostFlushCb,
-  warn,
-} from '@vue/runtime-dom'
+import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom'
 import {
   type ChildItem,
   insertionAnchor,
@@ -14,7 +9,6 @@ import {
 import {
   _child,
   _next,
-  createComment,
   createElement,
   createTextNode,
   disableHydrationNodeLookup,
@@ -22,15 +16,10 @@ import {
   locateChildByLogicalIndex,
   parentNode,
 } from './node'
-import { findBlockNode, remove } from '../block'
-import type { DynamicFragment } from '../fragment'
+import { remove } from '../block'
 
 export let currentHydrationNode: Node | null = null
 
-let _hydrateDynamicFragment:
-  | ((frag: DynamicFragment, isEmpty: boolean) => void)
-  | undefined
-
 export let isHydrating = false
 function setIsHydrating(value: boolean) {
   try {
@@ -59,7 +48,6 @@ function performHydration<T>(
   if (!isOptimized) {
     adoptTemplate = adoptTemplateImpl
     locateHydrationNode = locateHydrationNodeImpl
-    _hydrateDynamicFragment = hydrateDynamicFragmentImpl
     // optimize anchor cache lookup
     ;(Comment.prototype as any).$fe = undefined
     ;(Node.prototype as any).$pns = undefined
@@ -318,69 +306,3 @@ export function removeFragmentNodes(node: Node, endAnchor?: Node): void {
     }
   }
 }
-
-/* @__NO_SIDE_EFFECTS__ */
-export function hydrateDynamicFragment(
-  frag: DynamicFragment,
-  isEmpty: boolean,
-): void {
-  _hydrateDynamicFragment && _hydrateDynamicFragment(frag, isEmpty)
-}
-
-// Hydrate implementation for DynamicFragment
-function hydrateDynamicFragmentImpl(
-  frag: DynamicFragment,
-  isEmpty: boolean,
-): void {
-  // avoid repeated hydration during fallback rendering
-  if (frag.anchor) return
-
-  if (frag.anchorLabel === 'if') {
-    // reuse the empty comment node as the anchor for empty if
-    // e.g. `<div v-if="false"></div>` -> `<!---->`
-    if (isEmpty) {
-      frag.anchor = locateFragmentEndAnchor('')!
-      if (__DEV__ && !frag.anchor) {
-        throw new Error(
-          'Failed to locate if anchor. this is likely a Vue internal bug.',
-        )
-      } else {
-        if (__DEV__) {
-          ;(frag.anchor as Comment).data = frag.anchorLabel
-        }
-        return
-      }
-    }
-  } else if (frag.anchorLabel === 'slot') {
-    // reuse the empty comment node for empty slot
-    // e.g. `<slot v-if="false"></slot>`
-    if (isEmpty && isComment(currentHydrationNode!, '')) {
-      frag.anchor = currentHydrationNode!
-      if (__DEV__) {
-        ;(frag.anchor as Comment).data = frag.anchorLabel!
-      }
-      return
-    }
-
-    // reuse the vdom fragment end anchor
-    frag.anchor = locateFragmentEndAnchor()!
-    if (__DEV__ && !frag.anchor) {
-      throw new Error(
-        'Failed to locate slot anchor. this is likely a Vue internal bug.',
-      )
-    } else {
-      return
-    }
-  }
-
-  const { parentNode: pn, nextNode } = findBlockNode(frag.nodes)!
-  // create an anchor
-  queuePostFlushCb(() => {
-    pn!.insertBefore(
-      (frag.anchor = __DEV__
-        ? createComment(frag.anchorLabel!)
-        : createTextNode()),
-      nextNode,
-    )
-  })
-}

+ 59 - 2
packages/runtime-vapor/src/fragment.ts

@@ -7,6 +7,7 @@ import {
   type VaporTransitionHooks,
   applyTransitionHooks,
   applyTransitionLeaveHooks,
+  findBlockNode,
   insert,
   isValidBlock,
   remove,
@@ -16,6 +17,7 @@ import {
   type TransitionHooks,
   type VNode,
   currentInstance,
+  queuePostFlushCb,
   setCurrentInstance,
   warnExtraneousAttributes,
 } from '@vue/runtime-dom'
@@ -26,8 +28,10 @@ import {
 } from './component'
 import type { NodeRef } from './apiTemplateRef'
 import {
-  hydrateDynamicFragment,
+  currentHydrationNode,
+  isComment,
   isHydrating,
+  locateFragmentEndAnchor,
   locateHydrationNode,
 } from './dom/hydration'
 import { isArray } from '@vue/shared'
@@ -290,7 +294,60 @@ export class DynamicFragment extends VaporFragment {
   }
 
   hydrate = (isEmpty = false): void => {
-    hydrateDynamicFragment(this, isEmpty)
+    // early return allows tree-shaking of hydration logic when not used
+    if (!isHydrating) return
+
+    // avoid repeated hydration during fallback rendering
+    if (this.anchor) return
+
+    if (this.anchorLabel === 'if') {
+      // reuse the empty comment node as the anchor for empty if
+      // e.g. `<div v-if="false"></div>` -> `<!---->`
+      if (isEmpty) {
+        this.anchor = locateFragmentEndAnchor('')!
+        if (__DEV__ && !this.anchor) {
+          throw new Error(
+            'Failed to locate if anchor. this is likely a Vue internal bug.',
+          )
+        } else {
+          if (__DEV__) {
+            ;(this.anchor as Comment).data = this.anchorLabel
+          }
+          return
+        }
+      }
+    } else if (this.anchorLabel === 'slot') {
+      // reuse the empty comment node for empty slot
+      // e.g. `<slot v-if="false"></slot>`
+      if (isEmpty && isComment(currentHydrationNode!, '')) {
+        this.anchor = currentHydrationNode!
+        if (__DEV__) {
+          ;(this.anchor as Comment).data = this.anchorLabel!
+        }
+        return
+      }
+
+      // reuse the vdom fragment end anchor
+      this.anchor = locateFragmentEndAnchor()!
+      if (__DEV__ && !this.anchor) {
+        throw new Error(
+          'Failed to locate slot anchor. this is likely a Vue internal bug.',
+        )
+      } else {
+        return
+      }
+    }
+
+    const { parentNode: pn, nextNode } = findBlockNode(this.nodes)!
+    // create an anchor
+    queuePostFlushCb(() => {
+      pn!.insertBefore(
+        (this.anchor = __DEV__
+          ? createComment(this.anchorLabel!)
+          : createTextNode()),
+        nextNode,
+      )
+    })
   }
 }
 

+ 11 - 0
packages/runtime-vapor/src/vdomInterop.ts

@@ -25,6 +25,7 @@ import {
   isEmitListener,
   isKeepAlive,
   isVNode,
+  isHydrating as isVdomHydrating,
   normalizeRef,
   onScopeDispose,
   queuePostFlushCb,
@@ -218,12 +219,19 @@ const vaporInteropImpl: Omit<
   },
 
   hydrate(vnode, node, container, anchor, parentComponent, parentSuspense) {
+    // Check both vapor's isHydrating (for createVaporSSRApp) and
+    // VDOM's isVdomHydrating (for createSSRApp).
+    // In CSR (createApp/createVaporApp + vaporInteropPlugin), both are false,
+    // so this logic is tree-shaken.
+    if (!isHydrating && !isVdomHydrating) return node
     vaporHydrateNode(node, () =>
       this.mount(vnode, container, anchor, parentComponent, parentSuspense),
     )
     return _next(node)
   },
+
   hydrateSlot(vnode, node) {
+    if (!isHydrating && !isVdomHydrating) return node
     const { slot } = vnode.vs!
     const propsRef = (vnode.vs!.ref = shallowRef(vnode.props))
     vaporHydrateNode(node, () => {
@@ -328,6 +336,7 @@ function mountVNode(
   }
 
   frag.hydrate = () => {
+    if (!isHydrating) return
     hydrateVNode(vnode, parentComponent as any)
     onScopeDispose(unmount, true)
     isMounted = true
@@ -457,6 +466,7 @@ function createVDOMComponent(
   }
 
   frag.hydrate = () => {
+    if (!isHydrating) return
     hydrateVNode(
       vnode,
       parentComponent as any,
@@ -667,6 +677,7 @@ function renderVDOMSlot(
   }
 
   frag.hydrate = () => {
+    if (!isHydrating) return
     render()
     isMounted = true
   }