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

fix(runtime-vapor): avoid hydrating vapor slots during vdom collection (#14793)

edison 1 месяц назад
Родитель
Сommit
1de6fc418a

+ 96 - 0
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -9928,6 +9928,102 @@ describe('VDOM interop', () => {
     )
   })
 
+  test('hydrate VDOM slot vnode collection with forwarded Vapor slot', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        const data = _data; const components = _components;
+      </script>
+      <template>
+        <components.Forwarder>
+          <p>{{ data }}</p>
+        </components.Forwarder>
+        <span>after</span>
+      </template>`,
+      {
+        Collector: {
+          code: `<script setup>
+            import { computed, useSlots } from 'vue'
+
+            const slots = useSlots()
+            const count = computed(() => slots.default?.().length || 0)
+          </script>
+          <template>
+            <div>
+              <span>{{ count }}</span>
+              <slot />
+            </div>
+          </template>`,
+          vapor: false,
+        },
+        Forwarder: `<script setup>
+          const components = _components
+        </script>
+        <template>
+          <components.Collector>
+            <slot />
+          </components.Collector>
+        </template>`,
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div><span>1</span>
+      <!--[-->
+      <!--[--><p>foo</p><!--]-->
+      <!--]-->
+      </div><span>after</span><!--]-->
+      "
+    `)
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+  })
+
+  test('hydrate VDOM slot vnode collection with plain Vapor slot content', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        const data = _data; const components = _components;
+      </script>
+      <template>
+        <components.Collector>
+          <p>{{ data }}</p>
+        </components.Collector>
+        <span>after</span>
+      </template>`,
+      {
+        Collector: {
+          code: `<script setup>
+            import { computed, useSlots } from 'vue'
+
+            const slots = useSlots()
+            const count = computed(() => slots.default?.().length || 0)
+          </script>
+          <template>
+            <div>
+              <span>{{ count }}</span>
+              <slot />
+            </div>
+          </template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`
+      "
+      <!--[--><div><span>1</span>
+      <!--[--><p>foo</p><!--]-->
+      </div><span>after</span><!--]-->
+      "
+    `)
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+  })
+
   test('hydrate VDOM slot content should unmount hydrated slot child before first insert', async () => {
     const data = ref({
       unmounted: vi.fn(),

+ 499 - 1
packages/runtime-vapor/__tests__/vdomInterop.spec.ts

@@ -33,7 +33,13 @@ import {
   withDirectives,
 } from '@vue/runtime-dom'
 import { VaporSlot } from '../../runtime-core/src/vnode'
-import { makeInteropRender } from './_utils'
+import { compile, makeInteropRender } from './_utils'
+import {
+  insertionAnchor,
+  insertionIndex,
+  insertionParent,
+  resetInsertionState,
+} from '../src/insertionState'
 import {
   VaporKeepAlive,
   VaporTeleport,
@@ -51,6 +57,7 @@ import {
   defineVaporComponent,
   insert,
   renderEffect,
+  setInsertionState,
   setText,
   template,
   txt,
@@ -882,6 +889,375 @@ describe('vdomInterop', () => {
       expect(html()).toBe('default slot')
     })
 
+    test('collects compiled vdom component vnodes without hydrating vapor slot content', () => {
+      const data = ref({})
+      const VDomTabs = compile(
+        `<script setup>
+          import { computed, useSlots } from 'vue'
+          const slots = useSlots()
+          const labels = computed(() => {
+            return slots.default?.()
+              .filter(vnode =>
+                typeof vnode.type === 'object' &&
+                vnode.type.__name === 'PluginTabsTab' &&
+                vnode.props
+              )
+              .map(vnode => vnode.props.label) || []
+          })
+        </script>
+        <template>
+          <div>
+            <div class="tabs">
+              <button v-for="label in labels" :key="label">{{ label }}</button>
+            </div>
+            <slot />
+          </div>
+        </template>`,
+        data,
+        {},
+        { vapor: false },
+      )
+      const VDomTab = compile(
+        `<script setup>
+          defineOptions({ __name: 'PluginTabsTab' })
+          defineProps({ label: String })
+        </script>
+        <template>
+          <div class="panel"><slot /></div>
+        </template>`,
+        data,
+        {},
+        { vapor: false },
+      )
+
+      const VaporChild = compile(
+        `<script vapor>const components = _components</script>
+        <template>
+          <components.VDomTabs>
+            <components.VDomTab label="Playwright">
+              <p>Playwright panel</p>
+            </components.VDomTab>
+            <components.VDomTab label="WebdriverIO">
+              <p>WebdriverIO panel</p>
+            </components.VDomTab>
+          </components.VDomTabs>
+        </template>`,
+        data,
+        {
+          VDomTabs,
+          VDomTab,
+        },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toMatchInlineSnapshot(
+        `"<div><div class="tabs"><button>Playwright</button><button>WebdriverIO</button></div><div class="panel"><p>Playwright panel</p></div><div class="panel"><p>WebdriverIO panel</p></div></div>"`,
+      )
+    })
+
+    test('reuses dry-collected vdom slot vnodes for direct rendering and updates', async () => {
+      const data = ref({
+        first: 'Playwright',
+        second: 'WebdriverIO',
+      })
+      const VDomTabs = defineComponent({
+        setup(_, { slots }) {
+          return () => {
+            const children = slots.default?.() || []
+            const labels = children
+              .filter(
+                (vnode: any) =>
+                  typeof vnode.type === 'object' &&
+                  vnode.type.__name === 'PluginTabsTab' &&
+                  vnode.props,
+              )
+              .map((vnode: any) => vnode.props.label)
+            return h('div', [
+              h(
+                'div',
+                { class: 'tabs' },
+                labels.map(label => h('button', { key: label }, label)),
+              ),
+              ...children,
+            ])
+          }
+        },
+      })
+      const VDomTab = compile(
+        `<script setup>
+          defineOptions({ __name: 'PluginTabsTab' })
+          defineProps({ label: String })
+        </script>
+        <template>
+          <div class="panel"><slot /></div>
+        </template>`,
+        data,
+        {},
+        { vapor: false },
+      )
+
+      const VaporChild = compile(
+        `<script vapor>
+          const data = _data
+          const components = _components
+        </script>
+        <template>
+          <components.VDomTabs>
+            <components.VDomTab :label="data.first" :data-test="data.first">
+              <p>{{ data.first }} panel</p>
+            </components.VDomTab>
+            <components.VDomTab :label="data.second" :data-test="data.second">
+              <p>{{ data.second }} panel</p>
+            </components.VDomTab>
+          </components.VDomTabs>
+        </template>`,
+        data,
+        {
+          VDomTabs,
+          VDomTab,
+        },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toMatchInlineSnapshot(
+        `"<div><div class="tabs"><button>Playwright</button><button>WebdriverIO</button></div><div class="panel" data-test="Playwright"><p>Playwright panel</p></div><div class="panel" data-test="WebdriverIO"><p>WebdriverIO panel</p></div></div>"`,
+      )
+
+      data.value = {
+        first: 'Vitest',
+        second: 'Cypress',
+      }
+      await nextTick()
+
+      expect(html()).toMatchInlineSnapshot(
+        `"<div><div class="tabs"><button>Vitest</button><button>Cypress</button></div><div class="panel" data-test="Vitest"><p>Vitest panel</p></div><div class="panel" data-test="Cypress"><p>Cypress panel</p></div></div>"`,
+      )
+    })
+
+    test('collects scoped vapor slot vnodes for direct rendering and updates', async () => {
+      const data = ref({
+        first: 'Playwright',
+        second: 'WebdriverIO',
+      })
+      const VDomTabs = defineComponent({
+        setup(_, { slots }) {
+          return () => {
+            const children = slots.default?.({ prefix: 'V-' }) || []
+            const labels = children
+              .filter(
+                (vnode: any) =>
+                  typeof vnode.type === 'object' &&
+                  vnode.type.__name === 'PluginTabsTab' &&
+                  vnode.props,
+              )
+              .map((vnode: any) => vnode.props.label)
+            return h('div', [
+              h(
+                'div',
+                { class: 'tabs' },
+                labels.map(label => h('button', { key: label }, label)),
+              ),
+              ...children,
+            ])
+          }
+        },
+      })
+      const VDomTab = compile(
+        `<script setup>
+          defineOptions({ __name: 'PluginTabsTab' })
+          defineProps({ label: String })
+        </script>
+        <template>
+          <div class="panel"><slot /></div>
+        </template>`,
+        data,
+        {},
+        { vapor: false },
+      )
+
+      const VaporChild = compile(
+        `<script vapor>
+          const data = _data
+          const components = _components
+        </script>
+        <template>
+          <components.VDomTabs v-slot="{ prefix }">
+            <components.VDomTab :label="prefix + data.first">
+              <p>{{ prefix + data.first }} panel</p>
+            </components.VDomTab>
+            <components.VDomTab :label="prefix + data.second">
+              <p>{{ prefix + data.second }} panel</p>
+            </components.VDomTab>
+          </components.VDomTabs>
+        </template>`,
+        data,
+        {
+          VDomTabs,
+          VDomTab,
+        },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toMatchInlineSnapshot(
+        `"<div><div class="tabs"><button>V-Playwright</button><button>V-WebdriverIO</button></div><div class="panel"><p>V-Playwright panel</p></div><div class="panel"><p>V-WebdriverIO panel</p></div></div>"`,
+      )
+
+      data.value = {
+        first: 'Vitest',
+        second: 'Cypress',
+      }
+      await nextTick()
+
+      expect(html()).toMatchInlineSnapshot(
+        `"<div><div class="tabs"><button>V-Vitest</button><button>V-Cypress</button></div><div class="panel"><p>V-Vitest panel</p></div><div class="panel"><p>V-Cypress panel</p></div></div>"`,
+      )
+    })
+
+    test('falls back to renderable slot vnode for plain vapor slot content', async () => {
+      const data = ref('foo')
+      const VDomCollector = defineComponent({
+        setup(_, { slots }) {
+          return () => {
+            const children = slots.default?.() || []
+            return h('div', [h('span', String(children.length)), ...children])
+          }
+        },
+      })
+
+      const VaporChild = compile(
+        `<script vapor>
+          const data = _data
+          const components = _components
+        </script>
+        <template>
+          <components.VDomCollector>
+            <p>{{ data }}</p>
+          </components.VDomCollector>
+        </template>`,
+        data,
+        { VDomCollector },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toBe('<div><span>1</span><p>foo</p></div>')
+
+      data.value = 'bar'
+      await nextTick()
+
+      expect(html()).toBe('<div><span>1</span><p>bar</p></div>')
+    })
+
+    test('falls back to renderable slot vnode for forwarded vapor slot content', async () => {
+      const data = ref('foo')
+      const VDomCollector = defineComponent({
+        setup(_, { slots }) {
+          return () => {
+            const children = slots.default?.() || []
+            return h('div', [h('span', String(children.length)), ...children])
+          }
+        },
+      })
+      const Forwarder = compile(`<template><slot /></template>`, data)
+
+      const VaporChild = compile(
+        `<script vapor>
+          const data = _data
+          const components = _components
+        </script>
+        <template>
+          <components.VDomCollector>
+            <components.Forwarder>
+              <p>{{ data }}</p>
+            </components.Forwarder>
+          </components.VDomCollector>
+        </template>`,
+        data,
+        {
+          VDomCollector,
+          Forwarder,
+        },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toBe('<div><span>1</span><p>foo</p><!--slot--></div>')
+
+      data.value = 'bar'
+      await nextTick()
+
+      expect(html()).toBe('<div><span>1</span><p>bar</p><!--slot--></div>')
+    })
+
+    test('falls back to renderable slot vnode for vapor component slot content', async () => {
+      const data = ref('foo')
+      const VDomCollector = defineComponent({
+        setup(_, { slots }) {
+          return () => {
+            const children = slots.default?.() || []
+            return h('div', [h('span', String(children.length)), ...children])
+          }
+        },
+      })
+      const VaporPanel = compile(
+        `<script vapor>const data = _data</script>
+        <template><p>{{ data }}</p></template>`,
+        data,
+      )
+
+      const VaporChild = compile(
+        `<script vapor>
+          const components = _components
+        </script>
+        <template>
+          <components.VDomCollector>
+            <components.VaporPanel />
+          </components.VDomCollector>
+        </template>`,
+        data,
+        {
+          VDomCollector,
+          VaporPanel,
+        },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toBe('<div><span>1</span><p>foo</p></div>')
+
+      data.value = 'bar'
+      await nextTick()
+
+      expect(html()).toBe('<div><span>1</span><p>bar</p></div>')
+    })
+
     test('normalizes raw VDOM slot function values passed to Vapor', async () => {
       const msg = ref('default slot')
       const VaporChild = defineVaporComponent(() => createSlot('default', null))
@@ -1126,6 +1502,128 @@ describe('vdomInterop', () => {
       expect(html()).toBe('<div>direct call slot</div>')
     })
 
+    test('slots.default() direct invocation should preserve active insertion state', () => {
+      let capturedSlot: (() => any[]) | undefined
+
+      const VDomChild = defineComponent({
+        setup(_, { slots }) {
+          capturedSlot = slots.default
+          return () => h('div', null, renderSlot(slots, 'default'))
+        },
+      })
+
+      const VaporChild = defineVaporComponent({
+        setup() {
+          return createComponent(
+            VDomChild as any,
+            null,
+            {
+              default: () => template('direct call slot')(),
+            },
+            true,
+          )
+        },
+      })
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toBe('<div>direct call slot</div>')
+
+      const sentinelParent = document.createElement('div') as any
+      const sentinelAnchor = document.createComment('anchor')
+      sentinelParent.appendChild(sentinelAnchor)
+
+      try {
+        setInsertionState(sentinelParent, sentinelAnchor, 3)
+
+        const preview = capturedSlot!()
+
+        expect(preview).toHaveLength(1)
+        expect(sentinelParent.innerHTML).toBe('<!--anchor-->')
+        expect(insertionParent).toBe(sentinelParent)
+        expect(insertionAnchor).toBe(sentinelAnchor)
+        expect(insertionIndex).toBe(3)
+      } finally {
+        resetInsertionState()
+      }
+    })
+
+    test('slots.default() direct invocation outside render keeps slot owner appContext', async () => {
+      const data = ref({
+        first: 'Playwright',
+        second: 'WebdriverIO',
+      })
+      let capturedSlot: (() => any[]) | undefined
+
+      const VDomTabs = defineComponent({
+        setup(_, { slots }) {
+          capturedSlot = slots.default
+          return () => h('div', null, renderSlot(slots, 'default'))
+        },
+      })
+      const VDomTab = compile(
+        `<script setup>
+          defineOptions({ __name: 'PluginTabsTab' })
+          defineProps({ label: String })
+        </script>
+        <template>
+          <div class="panel"><slot /></div>
+        </template>`,
+        data,
+        {},
+        { vapor: false },
+      )
+
+      const VaporChild = compile(
+        `<script vapor>
+          const data = _data
+          const components = _components
+        </script>
+        <template>
+          <components.VDomTabs>
+            <components.VDomTab :label="data.first">
+              <p>{{ data.first }} panel</p>
+            </components.VDomTab>
+            <components.VDomTab :label="data.second">
+              <p>{{ data.second }} panel</p>
+            </components.VDomTab>
+          </components.VDomTabs>
+        </template>`,
+        data,
+        {
+          VDomTabs,
+          VDomTab,
+        },
+      )
+
+      const { html } = define({
+        setup() {
+          return () => h(VaporChild as any)
+        },
+      }).render()
+
+      expect(html()).toBe(
+        '<div><div class="panel"><p>Playwright panel</p></div><div class="panel"><p>WebdriverIO panel</p></div></div>',
+      )
+
+      const getLabels = () =>
+        (capturedSlot!() || []).map((vnode: any) => vnode.props?.label)
+
+      expect(getLabels()).toEqual(['Playwright', 'WebdriverIO'])
+
+      data.value = {
+        first: 'Vitest',
+        second: 'Cypress',
+      }
+      await nextTick()
+
+      expect(getLabels()).toEqual(['Vitest', 'Cypress'])
+    })
+
     test('slots.default() access should return a stable wrapper', () => {
       const VDomChild = defineComponent({
         setup(_, { slots }) {

+ 19 - 1
packages/runtime-vapor/src/component.ts

@@ -129,7 +129,10 @@ import {
   parentSuspense,
   setParentSuspense,
 } from './suspense'
-import { isInteropEnabled } from './vdomInteropState'
+import {
+  isCollectingVdomSlotVNodes,
+  isInteropEnabled,
+} from './vdomInteropState'
 import { setComponentScopeId, setScopeId } from './scopeId'
 import { isTransitionEnabled, isVaporTransition } from './transition'
 
@@ -255,6 +258,16 @@ export function createComponent(
     emptyContext,
   managedMount = false,
 ): VaporComponentInstance {
+  if (isInteropEnabled && isCollectingVdomSlotVNodes) {
+    if (component.__vapor) {
+      // Vapor components cannot be represented as VDOM child metadata. Bail out
+      // with undefined so slots.default() falls back to the real renderSlot path.
+      return undefined as any
+    }
+    const owner = getScopeOwner()
+    if (owner) appContext = owner.appContext
+  }
+
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
   let hydrationClose: Node | null = null
@@ -335,6 +348,11 @@ export function createComponent(
         rawProps,
         rawSlots,
       )
+      if (isCollectingVdomSlotVNodes) {
+        // VDOM interop children already expose frag.vnode for collection. Do not
+        // mount or hydrate the dry fragment.
+        return frag as any
+      }
       if (!isHydrating) {
         if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
       } else {

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

@@ -29,7 +29,10 @@ import {
 } from './fragment'
 import { createElement } from './dom/node'
 import { setDynamicProps } from './dom/prop'
-import { isInteropEnabled } from './vdomInteropState'
+import {
+  isCollectingVdomSlotVNodes,
+  isInteropEnabled,
+} from './vdomInteropState'
 import { setScopeId } from './scopeId'
 
 /**
@@ -187,6 +190,12 @@ export function createSlot(
   noSlotted?: boolean,
   once?: boolean,
 ): Block {
+  if (isInteropEnabled && isCollectingVdomSlotVNodes) {
+    // A Vapor <slot/> cannot expose child vnode metadata without real slot
+    // hydration. Bail out so renderSlot() handles it for real.
+    return undefined as any
+  }
+
   const _insertionParent = insertionParent
   const _insertionAnchor = insertionAnchor
   if (!isHydrating) resetInsertionState()

+ 126 - 7
packages/runtime-vapor/src/vdomInterop.ts

@@ -95,9 +95,17 @@ import {
   isHydrating,
   isHydrationAnchor,
   locateEndAnchor,
+  runWithoutHydration,
   setCurrentHydrationNode,
   hydrateNode as vaporHydrateNode,
 } from './dom/hydration'
+import {
+  insertionAnchor,
+  insertionIndex,
+  insertionParent,
+  resetInsertionState,
+  setInsertionState,
+} from './insertionState'
 import {
   SlotFallbackController,
   SlotFragment,
@@ -118,7 +126,11 @@ import {
   getVNodeKey,
   setTransitionHooks as setVaporTransitionHooks,
 } from './components/Transition'
-import { setInteropEnabled } from './vdomInteropState'
+import {
+  isCollectingVdomSlotVNodes,
+  setInteropEnabled,
+  withVdomSlotVNodeCollection,
+} from './vdomInteropState'
 import {
   type KeepAliveInstance,
   activate,
@@ -623,11 +635,16 @@ const vaporSlotsProxyHandler: ProxyHandler<any> = {
         return cached.wrapped
       }
 
-      // Create a wrapper that internally uses renderSlot for proper vapor slot handling
-      // This ensures that calling slots.default() works the same as renderSlot(slots, 'default')
-      const wrapped = (props?: Record<string, any>) => [
-        renderSlot({ [key]: slot }, key as string, props),
-      ]
+      // Direct slots.default() calls may be used for vnode introspection.
+      // Try collecting VDOM child metadata first; if the Vapor slot cannot be
+      // represented as VDOM vnodes, fall back to the real renderSlot protocol.
+      const wrapped = (props?: Record<string, any>) => {
+        return (
+          normalizeVaporSlotVNodes(slot, props) || [
+            renderSlot({ [key]: slot }, key as string, props),
+          ]
+        )
+      }
       ;(wrapped as any).__vs = slot
       wrappers.set(key, { slot, wrapped })
       return wrapped
@@ -636,6 +653,79 @@ const vaporSlotsProxyHandler: ProxyHandler<any> = {
   },
 }
 
+const collectedVdomSlotVNodes = new WeakMap<VaporFragment, VNode>()
+
+function normalizeVaporSlotVNodes(
+  slot: Function,
+  props: Record<string, any> | undefined,
+): VNode[] | undefined {
+  if (props && hasVNodeSlotProps(props)) {
+    return
+  }
+  const scope = effectScope()
+  let value: any
+  try {
+    value = runVdomSlotVNodeCollection(() =>
+      scope.run(() => withVdomSlotVNodeCollection(() => slot(props))),
+    )
+  } finally {
+    scope.stop()
+  }
+  const children = isArray(value) ? value : [value]
+  const vnodes: VNode[] = []
+  for (const child of children) {
+    if (isVNode(child)) {
+      vnodes.push(child)
+      continue
+    }
+    const vnode =
+      child &&
+      isObject(child) &&
+      collectedVdomSlotVNodes.get(child as VaporFragment)
+    if (!isVNode(vnode)) return
+    vnodes.push(vnode)
+  }
+  return vnodes
+}
+
+function hasVNodeSlotProps(props: Record<string, any>): boolean {
+  for (const key in props) {
+    const value = props[key]
+    if (isVNode(value)) {
+      return true
+    }
+    if (isArray(value)) {
+      for (let i = 0; i < value.length; i++) {
+        if (isVNode(value[i])) {
+          return true
+        }
+      }
+    }
+  }
+  return false
+}
+
+function runVdomSlotVNodeCollection<T>(fn: () => T): T {
+  const prevInsertionParent = insertionParent
+  const prevInsertionAnchor = insertionAnchor
+  const prevInsertionIndex = insertionIndex
+  try {
+    // Collection only probes metadata. It must not adopt DOM or advance the
+    // Vapor hydration cursor while evaluating the slot body.
+    return runWithoutHydration(fn)
+  } finally {
+    if (prevInsertionParent) {
+      setInsertionState(
+        prevInsertionParent,
+        prevInsertionAnchor,
+        prevInsertionIndex,
+      )
+    } else {
+      resetInsertionState()
+    }
+  }
+}
+
 let vdomHydrateNode: HydrationRenderer['hydrateNode'] | undefined
 
 // Static/Fragment/Teleport vnodes represent a root range [el..anchor].
@@ -845,7 +935,11 @@ function createVDOMComponent(
   frag.$key = vnode.key
   trackFragmentVNodeUpdates(frag, vnode)
 
-  if (isKeepAliveEnabled && currentKeepAliveCtx) {
+  if (
+    !isCollectingVdomSlotVNodes &&
+    isKeepAliveEnabled &&
+    currentKeepAliveCtx
+  ) {
     currentKeepAliveCtx.processShapeFlag(frag)
     // for VDOM async components, trigger cacheBlock after resolution
     if ((component as any).__asyncLoader) {
@@ -871,6 +965,13 @@ function createVDOMComponent(
     undefined,
   )
 
+  if (isCollectingVdomSlotVNodes) {
+    collectedVdomSlotVNodes.set(
+      frag,
+      createCollectedVDOMSlotVNode(component, rawProps, wrapper.slots),
+    )
+  }
+
   // overwrite how the vdom instance handles props
   vnode.vi = (instance: ComponentInternalInstance) => {
     // ensure props are shallow reactive to align with VDOM behavior.
@@ -1033,6 +1134,24 @@ function createVDOMComponent(
   return frag
 }
 
+function createCollectedVDOMSlotVNode(
+  component: ConcreteComponent,
+  rawProps: LooseRawProps | null | undefined,
+  slots: RawSlots,
+): VNode {
+  // This vnode is returned to a VDOM slots.default() caller and may be rendered
+  // by the VDOM renderer directly. Keep it as a normal VDOM vnode; the real
+  // Vapor-owned interop mount path uses frag.vnode with vi instead.
+  const vnode = createVNode(
+    component,
+    rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)),
+    slots === EMPTY_OBJ ? null : new Proxy(slots, vaporSlotsProxyHandler),
+  )
+  vnode.scopeId = getCurrentScopeId() || null
+  vnode.slotScopeIds = currentSlotScopeIds
+  return vnode
+}
+
 const rendererBridgeCache = new WeakMap<
   ConcreteComponent,
   FunctionalComponent

+ 14 - 0
packages/runtime-vapor/src/vdomInteropState.ts

@@ -5,3 +5,17 @@ export let isInteropEnabled = false
 export function setInteropEnabled(): void {
   isInteropEnabled = true
 }
+
+// Active while probing a Vapor slot for VDOM child metadata. This dry pass must
+// not mount or hydrate the real slot output.
+export let isCollectingVdomSlotVNodes = false
+
+export function withVdomSlotVNodeCollection<T>(fn: () => T): T {
+  const prev = isCollectingVdomSlotVNodes
+  isCollectingVdomSlotVNodes = true
+  try {
+    return fn()
+  } finally {
+    isCollectingVdomSlotVNodes = prev
+  }
+}