]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
chore: Merge branch 'edison/feat/vaporAsyncComponent' into edison/testVapor
authordaiwei <daiwei521@126.com>
Mon, 23 Jun 2025 07:53:10 +0000 (15:53 +0800)
committerdaiwei <daiwei521@126.com>
Mon, 23 Jun 2025 07:53:10 +0000 (15:53 +0800)
1  2 
packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
packages-private/vapor-e2e-test/interop/App.vue
packages/runtime-core/src/index.ts
packages/runtime-vapor/src/apiDefineAsyncComponent.ts
packages/runtime-vapor/src/apiTemplateRef.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/componentProps.ts
packages/runtime-vapor/src/fragment.ts
packages/runtime-vapor/src/index.ts

index e4959121cad7714f2c8be3014fdeba775c972ba9,32461df61af3686832ae466c2860d3ec97165581..33d7502b3a4f05c0f11ee903a6031bb0c55f4ddd
@@@ -264,33 -100,5 +264,47 @@@ describe('vdom / vapor interop', () => 
        },
        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,
++      )
++    })
    })
  })
index 7bfdd6abf0fd5a934abb3f6c3c1e68541f91fd90,c8c6c945da136d35bfa6df701e23e8526164ba07..e50c86d2daa2d99ee5daaad9a72ba492429b3aeb
@@@ -1,25 -1,23 +1,39 @@@
  <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>
index a4063a06668e268af3befe0fe48cc38793e469c4,920e64eac6aa75f00c8034402163f8227416352f..01a123e7dca38d887b4e74f12a3b38a2d96094f2
@@@ -569,19 -560,15 +569,28 @@@ export { initFeatureFlags } from './fea
  /**
   * @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
   */
index 0000000000000000000000000000000000000000,ddd91c06c8b45930f51f592f543adf42670674e9..e609dfa795d13af982bc3fe3a2d6f5fe9ed4671b
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,137 +1,139 @@@
 -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
+ }
index 258e848b0434ecf91c2484233e7dc42206ff0ed6,0000000000000000000000000000000000000000..1e4328ac7fae166f7f47a754696ee2ec819e80a0
mode 100644,000000..100644
--- /dev/null
@@@ -1,140 -1,0 +1,142 @@@
 +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
 +}
index ef2b6188b775f4788eeeab773ee141e564d23cea,7cd81c3e1022171daa8985a200ab0398d45ea3aa..f02063da1cadffaa2804b9c1ee6d1e778cad1214
@@@ -1,12 -1,12 +1,13 @@@
  // 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'