From: edison Date: Mon, 12 Jan 2026 00:55:57 +0000 (+0800) Subject: feat(vapor): support rendering VNodes in dynamic components (#14278) X-Git-Tag: v3.6.0-beta.3~2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b074a81b2a80b8d856a622fc764d0a830be23bf4;p=thirdparty%2Fvuejs%2Fcore.git feat(vapor): support rendering VNodes in dynamic components (#14278) --- diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 514b7878bf..cb6fad7928 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -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 } /** diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 068184e826..e9bcded4e4 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -154,6 +154,7 @@ export { resolveComponent, resolveDirective, resolveDynamicComponent, + NULL_DYNAMIC_COMPONENT, } from './helpers/resolveAssets' // For integration with runtime compiler export { registerRuntimeCompiler, isRuntimeOnly } from './component' diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index bfc0448fa4..6a4066163a 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -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( + ` + `, + { + // Vapor component with multiple root nodes, VDOM child as first element + // This ensures hydration starts at and tests skipFragmentAnchor + VaporChild: { + code: ``, + vapor: true, + }, + VdomChild: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " + foo
second
+ " + `, + ) + + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + + data.value = 'bar' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " + bar
second
+ " + `, + ) + }) + 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( + ` + `, + { + VaporChild: { + code: ``, + vapor: true, + }, + VdomChild: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + "
+ foo
vapor content
+
" + `, + ) + + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + + data.value = 'bar' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + "
+ bar
vapor content
+
" + `, + ) + }) + 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( + ` + `, + { + VaporChild: { + code: ``, + vapor: true, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
foo
+ " + `, + ) + + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + + data.value = 'bar' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
bar
+ " + `, + ) + }) + + test('hydrate VDOM slot content', async () => { + const data = ref('foo') + const { container } = await testWithVaporApp( + ` + `, + { + VdomWrapper: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
foo
+ " + `, + ) + + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + + data.value = 'bar' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
bar
+ " + `, + ) + }) + + test('hydrate VDOM slot fallback', async () => { + const data = ref('foo') + const { container } = await testWithVaporApp( + ` + `, + { + VdomWrapper: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
foo
+ " + `, + ) + + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + + data.value = 'bar' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
bar
+ " + `, + ) + }) + + test('hydrate VDOM component returning Fragment', async () => { + const data = ref('foo') + const { container } = await testWithVaporApp( + ` + `, + { + // VDOM component that returns a Fragment (multiple root nodes) + VdomFragmentComp: { + code: ` + `, + vapor: false, + }, + }, + data, + ) + + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
first foo
second foo
+ " + `, + ) + + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + + data.value = 'bar' + await nextTick() + expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot( + ` + " +
first bar
second bar
+ " + `, + ) + }) }) diff --git a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts index e348ab708f..4b198a0a6f 100644 --- a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts +++ b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts @@ -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('
vapor child
') }) + + describe('render VNodes', () => { + it('should render VNode containing vapor component from VDOM slot', async () => { + const VaporComp = defineVaporComponent({ + setup() { + return template('
vapor comp
')() 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('
vapor comp
') + }) + + 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('
vdom comp
') + }) + + it('should update when VNode changes', async () => { + const VaporCompA = defineVaporComponent({ + setup() { + return template('
vapor A
')() as any + }, + }) + + const VaporCompB = defineVaporComponent({ + setup() { + return template('
vapor B
')() as any + }, + }) + + const current = shallowRef(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('
vapor A
') + current.value = VaporCompB + await nextTick() + expect(html()).toBe('
vapor B
') + }) + + 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('
vapor A
')() as any + }, + }) + + const VaporCompB = defineVaporComponent({ + setup() { + onMounted(() => hooksB.mounted()) + onActivated(() => hooksB.activated()) + onDeactivated(() => hooksB.deactivated()) + onUnmounted(() => hooksB.unmounted()) + return template('
vapor B
')() as any + }, + }) + + const current = shallowRef(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('
vapor A
') + // 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('
vapor B
') + // 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('
vapor A
') + // 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(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('
vdom A
') + // 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('
vdom B
') + // 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('
vdom A
') + // 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('
vapor 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(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('
vapor A
') + // 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('
vdom B
') + // 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('
vapor A
') + // 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', () => { diff --git a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts index a9bd06174c..bf82ec757a 100644 --- a/packages/runtime-vapor/src/apiCreateDynamicComponent.ts +++ b/packages/runtime-vapor/src/apiCreateDynamicComponent.ts @@ -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() diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 65ebd8ddd8..7a669740e4 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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, diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 615b8fdad0..97cbcbed92 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -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 = { 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,