]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): support rendering VNodes in dynamic components (#14278)
authoredison <daiwei521@126.com>
Mon, 12 Jan 2026 00:55:57 +0000 (08:55 +0800)
committerGitHub <noreply@github.com>
Mon, 12 Jan 2026 00:55:57 +0000 (08:55 +0800)
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/__tests__/vdomInterop.spec.ts
packages/runtime-vapor/src/apiCreateDynamicComponent.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/vdomInterop.ts

index 514b7878bfd6cf3146bbcf100ce97149470a72dd..cb6fad79281f0356e3367cf3e3dd4a67c9e519ce 100644 (file)
@@ -225,6 +225,7 @@ export interface VaporInteropInterface {
     parentComponent: any,
     props?: any,
     slots?: any,
+    isSingleRoot?: boolean,
   ) => any
   vdomUnmount: UnmountComponentFn
   vdomSlot: (
@@ -234,6 +235,10 @@ export interface VaporInteropInterface {
     parentComponent: any, // VaporComponentInstance
     fallback?: any, // VaporSlot
   ) => any
+  vdomMountVNode: (
+    vnode: VNode,
+    parentComponent: any, // VaporComponentInstance
+  ) => any
 }
 
 /**
index 068184e82651d382ae4368ca190d0c1caa9ae334..e9bcded4e4e40956af47a89f08317305384f1150 100644 (file)
@@ -154,6 +154,7 @@ export {
   resolveComponent,
   resolveDirective,
   resolveDynamicComponent,
+  NULL_DYNAMIC_COMPONENT,
 } from './helpers/resolveAssets'
 // For integration with runtime compiler
 export { registerRuntimeCompiler, isRuntimeOnly } from './component'
index bfc0448fa4b8fc3aac812ca5ba7feef8a7b6ad02..6a4066163a865cb6c559593cc481c1e43c71659b 100644 (file)
@@ -4733,6 +4733,50 @@ describe('VDOM interop', () => {
     expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(`"false"`)
   })
 
+  test('nested components (VDOM -> Vapor(multi-root) -> VDOM)', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVDOMApp(
+      `<script setup>const data = _data; const components = _components;</script>
+      <template>
+        <components.VaporChild/>
+      </template>`,
+      {
+        // Vapor component with multiple root nodes, VDOM child as first element
+        // This ensures hydration starts at <!--[--> and tests skipFragmentAnchor
+        VaporChild: {
+          code: `<template><components.VdomChild/><div>second</div></template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<script setup>const data = _data;</script>
+            <template><span>{{ data }}</span></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><span>foo</span><div>second</div><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><span>bar</span><div>second</div><!--]-->
+      "
+    `,
+    )
+  })
+
   test('nested components (VDOM -> Vapor -> VDOM (with slot fallback))', async () => {
     const data = ref(true)
     const { container } = await testWithVDOMApp(
@@ -4906,6 +4950,51 @@ describe('VDOM interop', () => {
     )
   })
 
+  test('vapor slot render vdom component (multi-root slot content)', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>const data = _data; const components = _components;</script>
+      <template>
+        <components.VaporChild>
+          <components.VdomChild/>
+          <div>vapor content</div>
+        </components.VaporChild>
+      </template>`,
+      {
+        VaporChild: {
+          code: `<template><div><slot/></div></template>`,
+          vapor: true,
+        },
+        VdomChild: {
+          code: `<script setup>const data = _data;</script>
+            <template><span>{{ data }}</span></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "<div>
+      <!--[--><span>foo</span><div>vapor content</div><!--]-->
+      </div>"
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "<div>
+      <!--[--><span>bar</span><div>vapor content</div><!--]-->
+      </div>"
+    `,
+    )
+  })
+
   test('vapor slot render vdom component (render function)', async () => {
     const data = ref(true)
     const { container } = await testWithVaporApp(
@@ -4952,4 +5041,179 @@ describe('VDOM interop', () => {
     `,
     )
   })
+
+  test('hydrate VNode rendered via createDynamicComponent', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        import { h } from 'vue'
+        const data = _data; const components = _components;
+
+        // Simulating RouterView pattern: VDOM component passes VNode through slot
+        const RouterView = {
+          setup(_, { slots }) {
+            return () => {
+              const component = h(components.VaporChild)
+              return slots.default({ Component: component })
+            }
+          }
+        }
+      </script>
+      <template>
+        <RouterView v-slot="{ Component }">
+          <component :is="Component" />
+        </RouterView>
+      </template>`,
+      {
+        VaporChild: {
+          code: `<template><div>{{ data }}</div></template>`,
+          vapor: true,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>foo</div><!--dynamic-component--><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>bar</div><!--dynamic-component--><!--]-->
+      "
+    `,
+    )
+  })
+
+  test('hydrate VDOM slot content', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        const data = _data; const components = _components;
+      </script>
+      <template>
+        <components.VdomWrapper>
+          <div>{{ data }}</div>
+        </components.VdomWrapper>
+      </template>`,
+      {
+        VdomWrapper: {
+          code: `<script setup>const data = _data;</script>
+            <template><slot /></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>foo</div><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>bar</div><!--]-->
+      "
+    `,
+    )
+  })
+
+  test('hydrate VDOM slot fallback', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        const data = _data; const components = _components;
+      </script>
+      <template>
+        <components.VdomWrapper />
+      </template>`,
+      {
+        VdomWrapper: {
+          code: `<script setup>const data = _data;</script>
+            <template><slot><div>{{ data }}</div></slot></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>foo</div><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>bar</div><!--]-->
+      "
+    `,
+    )
+  })
+
+  test('hydrate VDOM component returning Fragment', async () => {
+    const data = ref('foo')
+    const { container } = await testWithVaporApp(
+      `<script setup>
+        const data = _data; const components = _components;
+      </script>
+      <template>
+        <components.VdomFragmentComp />
+      </template>`,
+      {
+        // VDOM component that returns a Fragment (multiple root nodes)
+        VdomFragmentComp: {
+          code: `<script setup>const data = _data;</script>
+            <template><div>first {{ data }}</div><div>second {{ data }}</div></template>`,
+          vapor: false,
+        },
+      },
+      data,
+    )
+
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>first foo</div><div>second foo</div><!--]-->
+      "
+    `,
+    )
+
+    expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+
+    data.value = 'bar'
+    await nextTick()
+    expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
+      `
+      "
+      <!--[--><div>first bar</div><div>second bar</div><!--]-->
+      "
+    `,
+    )
+  })
 })
index e348ab708f6401e570b8ee5342062673fd98ff39..4b198a0a6f1cca9362c0aff71c0eba6e9d15a2c6 100644 (file)
@@ -22,10 +22,12 @@ import {
 } from '@vue/runtime-dom'
 import { makeInteropRender } from './_utils'
 import {
+  VaporKeepAlive,
   applyTextModel,
   applyVShow,
   child,
   createComponent,
+  createDynamicComponent,
   defineVaporAsyncComponent,
   defineVaporComponent,
   renderEffect,
@@ -513,6 +515,432 @@ describe('vdomInterop', () => {
       await nextTick()
       expect(html()).toBe('<div>vapor child</div>')
     })
+
+    describe('render VNodes', () => {
+      it('should render VNode containing vapor component from VDOM slot', async () => {
+        const VaporComp = defineVaporComponent({
+          setup() {
+            return template('<div>vapor comp</div>')() as any
+          },
+        })
+
+        const RouterView = defineComponent({
+          setup(_, { slots }) {
+            return () => {
+              const component = h(VaporComp as any)
+              return slots.default!({ Component: component })
+            }
+          },
+        })
+
+        const App = defineVaporComponent({
+          setup() {
+            return createComponent(
+              RouterView as any,
+              null,
+              {
+                default: (slotProps: { Component: any }) => {
+                  return createDynamicComponent(() => slotProps.Component)
+                },
+              },
+              true,
+            )
+          },
+        })
+
+        const { html } = define({
+          setup() {
+            return () => h(App as any)
+          },
+        }).render()
+
+        expect(html()).toBe('<div>vapor comp</div><!--dynamic-component-->')
+      })
+
+      it('should render VNode containing vdom component from VDOM slot', async () => {
+        const VdomComp = defineComponent({
+          setup() {
+            return () => h('div', 'vdom comp')
+          },
+        })
+
+        const RouterView = defineComponent({
+          setup(_, { slots }) {
+            return () => {
+              const component = h(VdomComp)
+              return slots.default!({ Component: component })
+            }
+          },
+        })
+
+        const App = defineVaporComponent({
+          setup() {
+            return createComponent(
+              RouterView as any,
+              null,
+              {
+                default: (slotProps: { Component: any }) => {
+                  return createDynamicComponent(() => slotProps.Component)
+                },
+              },
+              true,
+            )
+          },
+        })
+
+        const { html } = define({
+          setup() {
+            return () => h(App as any)
+          },
+        }).render()
+
+        expect(html()).toBe('<div>vdom comp</div><!--dynamic-component-->')
+      })
+
+      it('should update when VNode changes', async () => {
+        const VaporCompA = defineVaporComponent({
+          setup() {
+            return template('<div>vapor A</div>')() as any
+          },
+        })
+
+        const VaporCompB = defineVaporComponent({
+          setup() {
+            return template('<div>vapor B</div>')() as any
+          },
+        })
+
+        const current = shallowRef<any>(VaporCompA)
+
+        const RouterView = defineComponent({
+          setup(_, { slots }) {
+            return () => {
+              const component = h(current.value as any)
+              return slots.default!({ Component: component })
+            }
+          },
+        })
+
+        const App = defineVaporComponent({
+          setup() {
+            return createComponent(
+              RouterView as any,
+              null,
+              {
+                default: (slotProps: { Component: any }) => {
+                  return createDynamicComponent(() => slotProps.Component)
+                },
+              },
+              true,
+            )
+          },
+        })
+
+        const { html } = define({
+          setup() {
+            return () => h(App as any)
+          },
+        }).render()
+
+        expect(html()).toBe('<div>vapor A</div><!--dynamic-component-->')
+        current.value = VaporCompB
+        await nextTick()
+        expect(html()).toBe('<div>vapor B</div><!--dynamic-component-->')
+      })
+
+      describe('with VaporKeepAlive', () => {
+        it('switch VNode with inner vapor components', async () => {
+          const hooksA = {
+            mounted: vi.fn(),
+            activated: vi.fn(),
+            deactivated: vi.fn(),
+            unmounted: vi.fn(),
+          }
+          const hooksB = {
+            mounted: vi.fn(),
+            activated: vi.fn(),
+            deactivated: vi.fn(),
+            unmounted: vi.fn(),
+          }
+
+          const VaporCompA = defineVaporComponent({
+            setup() {
+              onMounted(() => hooksA.mounted())
+              onActivated(() => hooksA.activated())
+              onDeactivated(() => hooksA.deactivated())
+              onUnmounted(() => hooksA.unmounted())
+              return template('<div>vapor A</div>')() as any
+            },
+          })
+
+          const VaporCompB = defineVaporComponent({
+            setup() {
+              onMounted(() => hooksB.mounted())
+              onActivated(() => hooksB.activated())
+              onDeactivated(() => hooksB.deactivated())
+              onUnmounted(() => hooksB.unmounted())
+              return template('<div>vapor B</div>')() as any
+            },
+          })
+
+          const current = shallowRef<any>(VaporCompA)
+
+          const RouterView = defineComponent({
+            setup(_, { slots }) {
+              return () => {
+                const component = h(current.value as any)
+                return slots.default!({ Component: component })
+              }
+            },
+          })
+
+          const App = defineVaporComponent({
+            setup() {
+              return createComponent(
+                RouterView as any,
+                null,
+                {
+                  default: (slotProps: { Component: any }) => {
+                    return createComponent(VaporKeepAlive, null, {
+                      default: () =>
+                        createDynamicComponent(() => slotProps.Component),
+                    })
+                  },
+                },
+                true,
+              )
+            },
+          })
+
+          const { html } = define({
+            setup() {
+              return () => h(App as any)
+            },
+          }).render()
+
+          expect(html()).toBe('<div>vapor A</div><!--dynamic-component-->')
+          // A: mounted + activated
+          expect(hooksA.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksA.activated).toHaveBeenCalledTimes(1)
+          expect(hooksA.deactivated).toHaveBeenCalledTimes(0)
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+
+          current.value = VaporCompB
+          await nextTick()
+          expect(html()).toBe('<div>vapor B</div><!--dynamic-component-->')
+          // A: deactivated (cached)
+          expect(hooksA.deactivated).toHaveBeenCalledTimes(1)
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+          // B: mounted + activated
+          expect(hooksB.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksB.activated).toHaveBeenCalledTimes(1)
+
+          current.value = VaporCompA
+          await nextTick()
+          expect(html()).toBe('<div>vapor A</div><!--dynamic-component-->')
+          // B: deactivated (cached)
+          expect(hooksB.deactivated).toHaveBeenCalledTimes(1)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(0)
+          // A: re-activated (not re-mounted)
+          expect(hooksA.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksA.activated).toHaveBeenCalledTimes(2)
+        })
+
+        it('switch VNode with inner VDOM components', async () => {
+          const hooksA = {
+            mounted: vi.fn(),
+            activated: vi.fn(),
+            deactivated: vi.fn(),
+            unmounted: vi.fn(),
+          }
+          const hooksB = {
+            mounted: vi.fn(),
+            activated: vi.fn(),
+            deactivated: vi.fn(),
+            unmounted: vi.fn(),
+          }
+
+          const VDOMCompA = defineComponent({
+            setup() {
+              onMounted(() => hooksA.mounted())
+              onActivated(() => hooksA.activated())
+              onDeactivated(() => hooksA.deactivated())
+              onUnmounted(() => hooksA.unmounted())
+              return () => h('div', 'vdom A')
+            },
+          })
+
+          const VDOMCompB = defineComponent({
+            setup() {
+              onMounted(() => hooksB.mounted())
+              onActivated(() => hooksB.activated())
+              onDeactivated(() => hooksB.deactivated())
+              onUnmounted(() => hooksB.unmounted())
+              return () => h('div', 'vdom B')
+            },
+          })
+
+          const current = shallowRef<any>(VDOMCompA)
+
+          const RouterView = defineComponent({
+            setup(_, { slots }) {
+              return () => {
+                const component = h(current.value as any)
+                return slots.default!({ Component: component })
+              }
+            },
+          })
+
+          const App = defineVaporComponent({
+            setup() {
+              return createComponent(
+                RouterView as any,
+                null,
+                {
+                  default: (slotProps: { Component: any }) => {
+                    return createComponent(VaporKeepAlive, null, {
+                      default: () =>
+                        createDynamicComponent(() => slotProps.Component),
+                    })
+                  },
+                },
+                true,
+              )
+            },
+          })
+
+          const { html } = define({
+            setup() {
+              return () => h(App as any)
+            },
+          }).render()
+
+          expect(html()).toBe('<div>vdom A</div><!--dynamic-component-->')
+          // A: mounted + activated
+          expect(hooksA.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksA.activated).toHaveBeenCalledTimes(1)
+          expect(hooksA.deactivated).toHaveBeenCalledTimes(0)
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+
+          current.value = VDOMCompB
+          await nextTick()
+          expect(html()).toBe('<div>vdom B</div><!--dynamic-component-->')
+          // A: deactivated (cached)
+          expect(hooksA.deactivated).toHaveBeenCalledTimes(1)
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+          // B: mounted + activated
+          expect(hooksB.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksB.activated).toHaveBeenCalledTimes(1)
+
+          current.value = VDOMCompA
+          await nextTick()
+          expect(html()).toBe('<div>vdom A</div><!--dynamic-component-->')
+          // B: deactivated (cached)
+          expect(hooksB.deactivated).toHaveBeenCalledTimes(1)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(0)
+          // A: re-activated (not re-mounted)
+          expect(hooksA.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksA.activated).toHaveBeenCalledTimes(2)
+        })
+
+        it('switch VNode with inner mixed vapor/VDOM components', async () => {
+          const hooksA = {
+            mounted: vi.fn(),
+            activated: vi.fn(),
+            deactivated: vi.fn(),
+            unmounted: vi.fn(),
+          }
+          const hooksB = {
+            mounted: vi.fn(),
+            activated: vi.fn(),
+            deactivated: vi.fn(),
+            unmounted: vi.fn(),
+          }
+
+          const VaporCompA = defineVaporComponent({
+            setup() {
+              onMounted(() => hooksA.mounted())
+              onActivated(() => hooksA.activated())
+              onDeactivated(() => hooksA.deactivated())
+              onUnmounted(() => hooksA.unmounted())
+              return template('<div>vapor A</div>')()
+            },
+          })
+
+          const VDOMCompB = defineComponent({
+            setup() {
+              onMounted(() => hooksB.mounted())
+              onActivated(() => hooksB.activated())
+              onDeactivated(() => hooksB.deactivated())
+              onUnmounted(() => hooksB.unmounted())
+              return () => h('div', 'vdom B')
+            },
+          })
+
+          const current = shallowRef<any>(VaporCompA)
+
+          const RouterView = defineComponent({
+            setup(_, { slots }) {
+              return () => {
+                const component = h(current.value as any)
+                return slots.default!({ Component: component })
+              }
+            },
+          })
+
+          const App = defineVaporComponent({
+            setup() {
+              return createComponent(
+                RouterView as any,
+                null,
+                {
+                  default: (slotProps: { Component: any }) => {
+                    return createComponent(VaporKeepAlive, null, {
+                      default: () =>
+                        createDynamicComponent(() => slotProps.Component),
+                    })
+                  },
+                },
+                true,
+              )
+            },
+          })
+
+          const { html } = define({
+            setup() {
+              return () => h(App as any)
+            },
+          }).render()
+
+          expect(html()).toBe('<div>vapor A</div><!--dynamic-component-->')
+          // A (vapor): mounted + activated
+          expect(hooksA.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksA.activated).toHaveBeenCalledTimes(1)
+          expect(hooksA.deactivated).toHaveBeenCalledTimes(0)
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+
+          current.value = VDOMCompB
+          await nextTick()
+          expect(html()).toBe('<div>vdom B</div><!--dynamic-component-->')
+          // A (vapor): deactivated (cached)
+          expect(hooksA.deactivated).toHaveBeenCalledTimes(1)
+          expect(hooksA.unmounted).toHaveBeenCalledTimes(0)
+          // B (vdom): mounted + activated
+          expect(hooksB.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksB.activated).toHaveBeenCalledTimes(1)
+
+          current.value = VaporCompA
+          await nextTick()
+          expect(html()).toBe('<div>vapor A</div><!--dynamic-component-->')
+          // B (vdom): deactivated (cached)
+          expect(hooksB.deactivated).toHaveBeenCalledTimes(1)
+          expect(hooksB.unmounted).toHaveBeenCalledTimes(0)
+          // A (vapor): re-activated (not re-mounted)
+          expect(hooksA.mounted).toHaveBeenCalledTimes(1)
+          expect(hooksA.activated).toHaveBeenCalledTimes(2)
+        })
+      })
+    })
   })
 
   describe('attribute fallthrough', () => {
index a9bd06174c0132a7c041285b1eb5453a7eaf7def..bf82ec757a7a93c35beff4dcf5440df6b07b31e9 100644 (file)
@@ -1,4 +1,9 @@
-import { currentInstance, resolveDynamicComponent } from '@vue/runtime-dom'
+import {
+  currentInstance,
+  isKeepAlive,
+  isVNode,
+  resolveDynamicComponent,
+} from '@vue/runtime-dom'
 import { insert, isBlock } from './block'
 import { createComponentWithFallback, emptyContext } from './component'
 import { renderEffect } from './renderEffect'
@@ -12,6 +17,7 @@ import {
 } from './insertionState'
 import { advanceHydrationNode, isHydrating } from './dom/hydration'
 import { DynamicFragment, type VaporFragment } from './fragment'
+import type { KeepAliveInstance } from './components/KeepAlive'
 
 export function createDynamicComponent(
   getter: () => any,
@@ -34,21 +40,38 @@ export function createDynamicComponent(
     const value = getter()
     const appContext =
       (currentInstance && currentInstance.appContext) || emptyContext
-    frag.update(
-      () =>
-        // Support integration with VaporRouterView/VaporRouterLink by accepting blocks
-        isBlock(value)
-          ? value
-          : createComponentWithFallback(
-              resolveDynamicComponent(value) as any,
-              rawProps,
-              rawSlots,
-              isSingleRoot,
-              once,
-              appContext,
-            ),
-      value,
-    )
+    frag.update(() => {
+      // Support integration with VaporRouterView/VaporRouterLink by accepting blocks
+      if (isBlock(value)) return value
+
+      // Handles VNodes passed from VDOM components (e.g., `h(VaporComp)` from slots)
+      if (appContext.vapor && isVNode(value)) {
+        if (isKeepAlive(currentInstance)) {
+          const frag = (
+            currentInstance as KeepAliveInstance
+          ).ctx.getCachedComponent(value.type as any) as VaporFragment
+          if (frag) return frag
+        }
+
+        const frag = appContext.vapor.vdomMountVNode(value, currentInstance)
+        if (isHydrating) {
+          frag.hydrate()
+          if (_isLastInsertion) {
+            advanceHydrationNode(_insertionParent!)
+          }
+        }
+        return frag
+      }
+
+      return createComponentWithFallback(
+        resolveDynamicComponent(value) as any,
+        rawProps,
+        rawSlots,
+        isSingleRoot,
+        once,
+        appContext,
+      )
+    }, value)
   }
 
   if (once) renderFn()
index 65ebd8ddd80585c86baf42b6bc2b70c4747b777d..7a669740e4a8ffb92bbf1e5db151fcd30103588e 100644 (file)
@@ -12,6 +12,7 @@ import {
   type GenericAppContext,
   type GenericComponentInstance,
   type LifecycleHook,
+  NULL_DYNAMIC_COMPONENT,
   type NormalizedPropsOptions,
   type ObjectEmitsOptions,
   type ShallowUnwrapRef,
@@ -99,7 +100,7 @@ import {
   locateNextNode,
   setCurrentHydrationNode,
 } from './dom/hydration'
-import { _next, createElement } from './dom/node'
+import { _next, createComment, createElement, createTextNode } from './dom/node'
 import {
   type TeleportFragment,
   isTeleportFragment,
@@ -292,6 +293,7 @@ export function createComponent(
       currentInstance as any,
       rawProps,
       rawSlots,
+      isSingleRoot,
     )
     if (!isHydrating) {
       if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
@@ -760,13 +762,19 @@ export function isVaporComponent(
  * element if the resolution fails.
  */
 export function createComponentWithFallback(
-  comp: VaporComponent | string,
+  comp: VaporComponent | typeof NULL_DYNAMIC_COMPONENT | string,
   rawProps?: LooseRawProps | null,
   rawSlots?: LooseRawSlots | null,
   isSingleRoot?: boolean,
   once?: boolean,
   appContext?: GenericAppContext,
 ): HTMLElement | VaporComponentInstance {
+  if (comp === NULL_DYNAMIC_COMPONENT) {
+    return (__DEV__
+      ? createComment('ndc')
+      : createTextNode('')) as any as HTMLElement
+  }
+
   if (!isString(comp)) {
     return createComponent(
       comp,
index 615b8fdad032a79adcc0c39c967a2d18f3181c56..97cbcbed92c7fe2803987bbd3eb00ac38f16c9a0 100644 (file)
@@ -2,7 +2,6 @@ import {
   type App,
   type ComponentInternalInstance,
   type ConcreteComponent,
-  Fragment,
   type HydrationRenderer,
   type KeepAliveContext,
   MoveType,
@@ -25,7 +24,6 @@ import {
   ensureVaporSlotFallback,
   isEmitListener,
   isKeepAlive,
-  isRef,
   isVNode,
   normalizeRef,
   onScopeDispose,
@@ -46,7 +44,6 @@ import {
   VaporComponentInstance,
   createComponent,
   getCurrentScopeId,
-  isVaporComponent,
   mountComponent,
   unmountComponent,
 } from './component'
@@ -89,14 +86,15 @@ export const interopKey: unique symbol = Symbol(`interop`)
 // mounting vapor components and slots in vdom
 const vaporInteropImpl: Omit<
   VaporInteropInterface,
-  'vdomMount' | 'vdomUnmount' | 'vdomSlot'
+  'vdomMount' | 'vdomUnmount' | 'vdomSlot' | 'vdomMountVNode'
 > = {
   mount(vnode, container, anchor, parentComponent, parentSuspense) {
-    let selfAnchor = (vnode.el = vnode.anchor = createTextNode())
+    let selfAnchor = (vnode.anchor = createTextNode())
     if (isHydrating) {
       // avoid vdom hydration children mismatch by the selfAnchor, delay its insertion
       queuePostFlushCb(() => container.insertBefore(selfAnchor, anchor))
     } else {
+      vnode.el = selfAnchor
       container.insertBefore(selfAnchor, anchor)
     }
     const prev = currentInstance
@@ -236,7 +234,7 @@ const vaporInteropImpl: Omit<
         )
       }
     })
-    return _next(vnode.anchor as Node)
+    return vnode.anchor as Node
   },
 
   setTransitionHooks(component, hooks) {
@@ -247,7 +245,6 @@ const vaporInteropImpl: Omit<
     const cached = (parentComponent.ctx as KeepAliveContext).getCachedComponent(
       vnode,
     )
-
     vnode.el = cached.el
     vnode.component = cached.component
     vnode.anchor = cached.anchor
@@ -294,6 +291,102 @@ const vaporSlotsProxyHandler: ProxyHandler<any> = {
 
 let vdomHydrateNode: HydrationRenderer['hydrateNode'] | undefined
 
+/**
+ * Mount VNode in vapor
+ */
+function mountVNode(
+  internals: RendererInternals,
+  vnode: VNode,
+  parentComponent: VaporComponentInstance | null,
+): VaporFragment {
+  const frag = new VaporFragment([])
+  frag.vnode = vnode
+
+  let isMounted = false
+  const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
+    if (transition) setVNodeTransitionHooks(vnode, transition)
+    if (vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
+      if ((vnode.type as any).__vapor) {
+        deactivate(
+          vnode.component as any,
+          (parentComponent as KeepAliveInstance)!.ctx.getStorageContainer(),
+        )
+      } else {
+        vdomDeactivate(
+          vnode,
+          (parentComponent as KeepAliveInstance)!.ctx.getStorageContainer(),
+          internals,
+          parentComponent as any,
+          null,
+        )
+      }
+    } else {
+      internals.um(vnode, parentComponent as any, null, !!parentNode)
+    }
+  }
+
+  frag.hydrate = () => {
+    hydrateVNode(vnode, parentComponent as any)
+    onScopeDispose(unmount, true)
+    isMounted = true
+    frag.nodes = vnode.el as any
+  }
+
+  frag.insert = (parentNode, anchor, transition) => {
+    if (isHydrating) return
+    if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
+      if ((vnode.type as any).__vapor) {
+        activate(vnode.component as any, parentNode, anchor)
+      } else {
+        vdomActivate(
+          vnode,
+          parentNode,
+          anchor,
+          internals,
+          parentComponent as any,
+          null,
+          undefined,
+          false,
+        )
+      }
+      return
+    } else {
+      const prev = currentInstance
+      simpleSetCurrentInstance(parentComponent)
+      if (!isMounted) {
+        if (transition) setVNodeTransitionHooks(vnode, transition)
+        internals.p(
+          null,
+          vnode,
+          parentNode,
+          anchor,
+          parentComponent as any,
+          null, // parentSuspense
+          undefined, // namespace
+          vnode.slotScopeIds,
+        )
+        onScopeDispose(unmount, true)
+        isMounted = true
+      } else {
+        // move
+        internals.m(
+          vnode,
+          parentNode,
+          anchor,
+          MoveType.REORDER,
+          parentComponent as any,
+        )
+      }
+      simpleSetCurrentInstance(prev)
+    }
+    frag.nodes = vnode.el as any
+    if (isMounted && frag.onUpdated) frag.onUpdated.forEach(m => m())
+  }
+
+  frag.remove = unmount
+  return frag
+}
+
 /**
  * Mount vdom component in vapor
  */
@@ -303,6 +396,7 @@ function createVDOMComponent(
   parentComponent: VaporComponentInstance | null,
   rawProps?: LooseRawProps | null,
   rawSlots?: LooseRawSlots | null,
+  isSingleRoot?: boolean,
 ): VaporFragment {
   const frag = new VaporFragment([])
   const vnode = (frag.vnode = createVNode(
@@ -355,7 +449,13 @@ function createVDOMComponent(
   }
 
   frag.hydrate = () => {
-    hydrateVNode(vnode, parentComponent as any)
+    hydrateVNode(
+      vnode,
+      parentComponent as any,
+      // skip fragment start anchor for multi-root component
+      // to avoid mismatch
+      !isSingleRoot,
+    )
     onScopeDispose(unmount, true)
     isMounted = true
     frag.nodes = vnode.el as any
@@ -572,6 +672,7 @@ export const vaporInteropPlugin: Plugin = app => {
     vdomMount: createVDOMComponent.bind(null, internals),
     vdomUnmount: internals.umt,
     vdomSlot: renderVDOMSlot.bind(null, internals),
+    vdomMountVNode: mountVNode.bind(null, internals),
   })
   const mount = app.mount
   app.mount = ((...args) => {
@@ -583,28 +684,18 @@ export const vaporInteropPlugin: Plugin = app => {
 function hydrateVNode(
   vnode: VNode,
   parentComponent: ComponentInternalInstance | null,
+  skipFragmentAnchor: boolean = false,
 ) {
   locateHydrationNode()
 
-  // skip fragment start anchor
   let node = currentHydrationNode!
-  while (
-    isComment(node, '[') &&
-    // vnode is not a fragment
-    vnode.type !== Fragment &&
-    // not inside vdom slot
-    !(
-      isVaporComponent(parentComponent) &&
-      isRef((parentComponent as VaporComponentInstance).rawSlots._)
-    )
-  ) {
-    node = node.nextSibling!
+  if (skipFragmentAnchor && isComment(node, '[')) {
+    setCurrentHydrationNode((node = node.nextSibling!))
   }
-  if (currentHydrationNode !== node) setCurrentHydrationNode(node)
 
   if (!vdomHydrateNode) vdomHydrateNode = ensureHydrationRenderer().hydrateNode!
   const nextNode = vdomHydrateNode(
-    currentHydrationNode!,
+    node,
     vnode,
     parentComponent,
     null,