},
E2E_TIMEOUT,
)
+ describe('teleport', () => {
+ const testSelector = '.teleport'
+ test('render vapor component', async () => {
+ const targetSelector = `${testSelector} .teleport-target`
+ const containerSelector = `${testSelector} .render-vapor-comp`
+ const buttonSelector = `${containerSelector} button`
+
+ // teleport is disabled by default
+ expect(await html(containerSelector)).toBe(
+ `<button>toggle</button><div>vapor comp</div>`,
+ )
+ expect(await html(targetSelector)).toBe('')
+
+ // disabled -> enabled
+ await click(buttonSelector)
+ await nextTick()
+ expect(await html(containerSelector)).toBe(`<button>toggle</button>`)
+ expect(await html(targetSelector)).toBe('<div>vapor comp</div>')
+
+ // enabled -> disabled
+ await click(buttonSelector)
+ await nextTick()
+ expect(await html(containerSelector)).toBe(
+ `<button>toggle</button><div>vapor comp</div>`,
+ )
+ expect(await html(targetSelector)).toBe('')
+ })
+ })
++ describe('async component', () => {
++ const container = '.async-component-interop'
++ test(
++ 'with-vdom-inner-component',
++ async () => {
++ const testContainer = `${container} .with-vdom-component`
++ expect(await html(testContainer)).toBe('<span>loading...</span>')
++
++ await timeout(duration)
++ expect(await html(testContainer)).toBe('<div>foo</div>')
++ },
++ E2E_TIMEOUT,
++ )
++ })
})
})
<script setup lang="ts">
-import { ref, defineVaporAsyncComponent, h } from 'vue'
+import { ref, shallowRef } from 'vue'
import VaporComp from './components/VaporComp.vue'
+import VaporCompA from '../transition/components/VaporCompA.vue'
+import VdomComp from '../transition/components/VdomComp.vue'
+import VaporSlot from '../transition/components/VaporSlot.vue'
++import { defineVaporAsyncComponent, h } from 'vue'
+ import VdomFoo from './components/VdomFoo.vue'
const msg = ref('hello')
const passSlot = ref(true)
+const toggleVapor = ref(true)
+const interopComponent = shallowRef(VdomComp)
+function toggleInteropComponent() {
+ interopComponent.value =
+ interopComponent.value === VaporCompA ? VdomComp : VaporCompA
+}
+
+const items = ref(['a', 'b', 'c'])
+const enterClick = () => items.value.push('d', 'e')
+import SimpleVaporComp from './components/SimpleVaporComp.vue'
+
+const disabled = ref(true)
+ const duration = typeof process !== 'undefined' && process.env.CI ? 200 : 50
+
+ const AsyncVDomFoo = defineVaporAsyncComponent({
+ loader: () => {
+ return new Promise(r => {
+ setTimeout(() => {
+ r(VdomFoo as any)
+ }, duration)
+ })
+ },
+ loadingComponent: () => h('span', 'loading...'),
+ })
</script>
<template>
<template #test v-if="passSlot">A test slot</template>
</VaporComp>
+ <!-- transition interop -->
+ <div>
+ <div class="trans-vapor">
+ <button @click="toggleVapor = !toggleVapor">
+ toggle vapor component
+ </button>
+ <div>
+ <Transition>
+ <VaporCompA v-if="toggleVapor" />
+ </Transition>
+ </div>
+ </div>
+ <div class="trans-vdom-vapor-out-in">
+ <button @click="toggleInteropComponent">
+ switch between vdom/vapor component out-in mode
+ </button>
+ <div>
+ <Transition name="fade" mode="out-in">
+ <component :is="interopComponent"></component>
+ </Transition>
+ </div>
+ </div>
+ </div>
+ <!-- transition-group interop -->
+ <div>
+ <div class="trans-group-vapor">
+ <button @click="enterClick">insert items</button>
+ <div>
+ <transition-group name="test">
+ <VaporSlot v-for="item in items" :key="item">
+ <div>{{ item }}</div>
+ </VaporSlot>
+ </transition-group>
+ </div>
+ </div>
+ </div>
+ <!-- teleport -->
+ <div class="teleport">
+ <div class="teleport-target"></div>
+ <div class="render-vapor-comp">
+ <button @click="disabled = !disabled">toggle</button>
+ <Teleport to=".teleport-target" defer :disabled="disabled">
+ <SimpleVaporComp />
+ </Teleport>
+ </div>
+ </div>
+ <!-- teleport end-->
+ <!-- async component -->
+ <div class="async-component-interop">
+ <div class="with-vdom-component">
+ <AsyncVDomFoo />
+ </div>
+ </div>
+ <!-- async component end -->
</template>
/**
* @internal
*/
+export { performTransitionEnter, performTransitionLeave } from './renderer'
+/**
+ * @internal
+ */
+export { ensureVaporSlotFallback } from './helpers/renderSlot'
+/**
+ * @internal
+ */
+export {
+ resolveTarget as resolveTeleportTarget,
+ isTeleportDisabled,
+ isTeleportDeferred,
+} from './components/Teleport'
+ export {
+ createAsyncComponentContext,
+ useAsyncComponentState,
+ isAsyncWrapper,
+ } from './apiAsyncComponent'
+ /**
+ * @internal
+ */
+ export { markAsyncBoundary } from './helpers/useId'
/**
* @internal
*/
--- /dev/null
-import { DynamicFragment } from './block'
+ import {
+ type AsyncComponentLoader,
+ type AsyncComponentOptions,
+ ErrorCodes,
+ createAsyncComponentContext,
+ currentInstance,
+ handleError,
+ markAsyncBoundary,
+ useAsyncComponentState,
+ } from '@vue/runtime-dom'
+ import { defineVaporComponent } from './apiDefineComponent'
+ import {
+ type VaporComponent,
+ type VaporComponentInstance,
+ createComponent,
+ } from './component'
+ import { renderEffect } from './renderEffect'
++import { DynamicFragment } from './fragment'
+
+ /*! #__NO_SIDE_EFFECTS__ */
+ export function defineVaporAsyncComponent<T extends VaporComponent>(
+ source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
+ ): T {
+ const {
+ load,
+ getResolvedComp,
+ setPendingRequest,
+ source: {
+ loadingComponent,
+ errorComponent,
+ delay,
+ // hydrate: hydrateStrategy,
+ timeout,
+ // suspensible = true,
+ },
+ } = createAsyncComponentContext<T, VaporComponent>(source)
+
+ return defineVaporComponent({
+ name: 'VaporAsyncComponentWrapper',
+
+ __asyncLoader: load,
+
+ // __asyncHydrate(el, instance, hydrate) {
+ // // TODO async hydrate
+ // },
+
+ get __asyncResolved() {
+ return getResolvedComp()
+ },
+
+ setup() {
+ const instance = currentInstance as VaporComponentInstance
+ markAsyncBoundary(instance)
+
+ const frag = __DEV__
+ ? new DynamicFragment('async component')
+ : new DynamicFragment()
+
+ // already resolved
+ let resolvedComp = getResolvedComp()
+ if (resolvedComp) {
+ frag.update(() => createInnerComp(resolvedComp!, instance))
+ return frag
+ }
+
+ const onError = (err: Error) => {
+ setPendingRequest(null)
+ handleError(
+ err,
+ instance,
+ ErrorCodes.ASYNC_COMPONENT_LOADER,
+ !errorComponent /* do not throw in dev if user provided error component */,
+ )
+ }
+
+ // TODO suspense-controlled or SSR.
+
+ const { loaded, error, delayed } = useAsyncComponentState(
+ delay,
+ timeout,
+ onError,
+ )
+
+ load()
+ .then(() => {
+ loaded.value = true
+ // TODO parent is keep-alive, force update so the loaded component's
+ // name is taken into account
+ })
+ .catch(err => {
+ onError(err)
+ error.value = err
+ })
+
+ renderEffect(() => {
+ resolvedComp = getResolvedComp()
+ let render
+ if (loaded.value && resolvedComp) {
+ render = () => createInnerComp(resolvedComp!, instance, frag)
+ } else if (error.value && errorComponent) {
+ render = () =>
+ createComponent(errorComponent, { error: () => error.value })
+ } else if (loadingComponent && !delayed.value) {
+ render = () => createComponent(loadingComponent)
+ }
+ frag.update(render)
+ })
+
+ return frag
+ },
+ }) as T
+ }
+
+ function createInnerComp(
+ comp: VaporComponent,
+ parent: VaporComponentInstance,
+ frag?: DynamicFragment,
+ ): VaporComponentInstance {
+ const { rawProps, rawSlots, isSingleRoot, appContext } = parent
+ const instance = createComponent(
+ comp,
+ rawProps,
+ rawSlots,
+ isSingleRoot,
++ undefined,
++ undefined,
+ appContext,
+ )
+
+ // set ref
+ frag && frag.setRef && frag.setRef(instance)
+
+ // TODO custom element
+ // pass the custom element callback on to the inner comp
+ // and remove it from the async wrapper
+ // i.ce = ce
+ // delete parent.ce
+ return instance
+ }
--- /dev/null
+import { EffectScope, pauseTracking, resetTracking } from '@vue/reactivity'
+import { createComment, createTextNode } from './dom/node'
+import {
+ type Block,
+ type BlockFn,
+ type TransitionOptions,
+ type VaporTransitionHooks,
+ insert,
+ isValidBlock,
+ remove,
+} from './block'
+import type { TransitionHooks } from '@vue/runtime-dom'
+import {
+ currentHydrationNode,
+ isComment,
+ isHydrating,
+ locateHydrationNode,
+ locateVaporFragmentAnchor,
+} from './dom/hydration'
+import {
+ applyTransitionHooks,
+ applyTransitionLeaveHooks,
+} from './components/Transition'
++import type { VaporComponentInstance } from './component'
+
+export class VaporFragment implements TransitionOptions {
+ $key?: any
+ $transition?: VaporTransitionHooks | undefined
+ nodes: Block
+ anchor?: Node
+ insert?: (
+ parent: ParentNode,
+ anchor: Node | null,
+ transitionHooks?: TransitionHooks,
+ ) => void
+ remove?: (parent?: ParentNode, transitionHooks?: TransitionHooks) => void
+ fallback?: BlockFn
+
+ target?: ParentNode | null
+ targetAnchor?: Node | null
+ getNodes?: () => Block
++ setRef?: (comp: VaporComponentInstance) => void
+
+ constructor(nodes: Block) {
+ this.nodes = nodes
+ }
+}
+
+export class DynamicFragment extends VaporFragment {
+ anchor!: Node
+ scope: EffectScope | undefined
+ current?: BlockFn
+ fallback?: BlockFn
+ /**
+ * slot only
+ * indicates forwarded slot
+ */
+ forwarded?: boolean
+
+ constructor(anchorLabel?: string) {
+ super([])
+ if (isHydrating) {
+ locateHydrationNode(true)
+ this.hydrate(anchorLabel!)
+ } else {
+ this.anchor =
+ __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
+ }
+ }
+
+ update(render?: BlockFn, key: any = render): void {
+ if (key === this.current) {
+ return
+ }
+ this.current = key
+
+ pauseTracking()
+ const parent = this.anchor.parentNode
+ const transition = this.$transition
+ const renderBranch = () => {
+ if (render) {
+ this.scope = new EffectScope()
+ this.nodes = this.scope.run(render) || []
+ if (transition) {
+ this.$transition = applyTransitionHooks(this.nodes, transition)
+ }
+ if (parent) insert(this.nodes, parent, this.anchor)
+ } else {
+ this.scope = undefined
+ this.nodes = []
+ }
+ }
+
+ // teardown previous branch
+ if (this.scope) {
+ this.scope.stop()
+ const mode = transition && transition.mode
+ if (mode) {
+ applyTransitionLeaveHooks(this.nodes, transition, renderBranch)
+ parent && remove(this.nodes, parent)
+ if (mode === 'out-in') {
+ resetTracking()
+ return
+ }
+ } else {
+ parent && remove(this.nodes, parent)
+ }
+ }
+
+ renderBranch()
+
+ if (this.fallback && !isValidBlock(this.nodes)) {
+ parent && remove(this.nodes, parent)
+ this.nodes =
+ (this.scope || (this.scope = new EffectScope())).run(this.fallback) ||
+ []
+ parent && insert(this.nodes, parent, this.anchor)
+ }
+
+ resetTracking()
+ }
+
+ hydrate(label: string): void {
+ // for `v-if="false"` the node will be an empty comment, use it as the anchor.
+ // otherwise, find next sibling vapor fragment anchor
+ if (isComment(currentHydrationNode!, '')) {
+ this.anchor = currentHydrationNode
+ } else {
+ const anchor = locateVaporFragmentAnchor(currentHydrationNode!, label)!
+ if (anchor) {
+ this.anchor = anchor
+ } else if (__DEV__) {
+ // this should not happen
+ throw new Error(`${label} fragment anchor node was not found.`)
+ }
+ }
+ }
+}
+
+export function isFragment(val: NonNullable<unknown>): val is VaporFragment {
+ return val instanceof VaporFragment
+}
// public APIs
export { createVaporApp, createVaporSSRApp } from './apiCreateApp'
export { defineVaporComponent } from './apiDefineComponent'
+ export { defineVaporAsyncComponent } from './apiDefineAsyncComponent'
export { vaporInteropPlugin } from './vdomInterop'
export type { VaporDirective } from './directives/custom'
+export { VaporTeleportImpl as VaporTeleport } from './components/Teleport'
// compiler-use only
-export { insert, prepend, remove, isFragment, VaporFragment } from './block'
+export { insert, prepend, remove } from './block'
export { setInsertionState } from './insertionState'
export { createComponent, createComponentWithFallback } from './component'
export { renderEffect } from './renderEffect'