]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): defineVaporAsyncComponent (#13059)
authoredison <daiwei521@126.com>
Mon, 20 Oct 2025 07:44:55 +0000 (15:44 +0800)
committerGitHub <noreply@github.com>
Mon, 20 Oct 2025 07:44:55 +0000 (15:44 +0800)
12 files changed:
packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
packages-private/vapor-e2e-test/interop/App.vue
packages-private/vapor-e2e-test/interop/components/VaporComp.vue [moved from packages-private/vapor-e2e-test/interop/VaporComp.vue with 96% similarity]
packages-private/vapor-e2e-test/interop/components/VdomComp.vue [moved from packages-private/vapor-e2e-test/interop/VdomComp.vue with 100% similarity]
packages-private/vapor-e2e-test/interop/components/VdomFoo.vue [new file with mode: 0644]
packages/runtime-core/src/apiAsyncComponent.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts [new file with mode: 0644]
packages/runtime-vapor/src/apiDefineAsyncComponent.ts [new file with mode: 0644]
packages/runtime-vapor/src/apiTemplateRef.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/index.ts

index eda60f14eefca840cbe7a219fa215e3aa0bb4334..df190fe8e15dd2c7c74bc05859476d6d4a752ac3 100644 (file)
@@ -41,12 +41,6 @@ const buffer = process.env.CI ? 50 : 20
 const transitionFinish = (time = duration) => timeout(time + buffer)
 
 describe('vdom / vapor interop', () => {
-  beforeEach(async () => {
-    const baseUrl = `http://localhost:${port}/interop/`
-    await page().goto(baseUrl)
-    await page().waitForSelector('#app')
-  })
-
   test(
     'should work',
     async () => {
@@ -104,6 +98,21 @@ describe('vdom / vapor interop', () => {
     E2E_TIMEOUT,
   )
 
+  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,
+    )
+  })
+
   describe('keepalive', () => {
     test(
       'render vapor component',
index ae7a2b8ee6174f9e550b6432df954fa2ad1458a8..30e99a2e5e372bea1c165a48179c02afacc5f6c0 100644 (file)
@@ -1,6 +1,7 @@
 <script setup lang="ts">
-import { ref, shallowRef } from 'vue'
-import VaporComp from './VaporComp.vue'
+import { ref, defineVaporAsyncComponent, h, shallowRef } from 'vue'
+import VaporComp from './components/VaporComp.vue'
+import VdomFoo from './components/VdomFoo.vue'
 import SimpleVaporComp from './components/SimpleVaporComp.vue'
 import VaporCompA from '../transition/components/VaporCompA.vue'
 import VdomComp from '../transition/components/VdomComp.vue'
@@ -9,6 +10,18 @@ import VaporSlot from '../transition/components/VaporSlot.vue'
 const msg = ref('hello')
 const passSlot = 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...'),
+})
 ;(window as any).calls = []
 ;(window as any).getCalls = () => {
   const ret = (window as any).calls.slice()
@@ -43,6 +56,13 @@ const enterClick = () => items.value.push('d', 'e')
     <template #test v-if="passSlot">A test slot</template>
   </VaporComp>
 
+  <!-- async component  -->
+  <div class="async-component-interop">
+    <div class="with-vdom-component">
+      <AsyncVDomFoo />
+    </div>
+  </div>
+  <!-- async component end -->
   <!-- keepalive -->
   <div class="render-vapor-component">
     <button class="btn-show" @click="show = !show">show</button>
similarity index 96%
rename from packages-private/vapor-e2e-test/interop/VaporComp.vue
rename to packages-private/vapor-e2e-test/interop/components/VaporComp.vue
index 88a60c782c091edc622eaaa07db174ecf7395900..09b08154ae37f496a1f80d31d8ca0288206f241e 100644 (file)
@@ -27,7 +27,8 @@ const slotProp = ref('slot prop')
         change slot prop
       </button>
       <div class="vdom-slot-in-vapor-default">
-        #default: <slot :foo="slotProp" />
+        #default:
+        <slot :foo="slotProp" />
       </div>
       <div class="vdom-slot-in-vapor-test">
         #test: <slot name="test">fallback content</slot>
diff --git a/packages-private/vapor-e2e-test/interop/components/VdomFoo.vue b/packages-private/vapor-e2e-test/interop/components/VdomFoo.vue
new file mode 100644 (file)
index 0000000..ee13cfb
--- /dev/null
@@ -0,0 +1,5 @@
+<script setup lang="ts"></script>
+
+<template>
+  <div>foo</div>
+</template>
index 1ff9fa067e7e75cfe5d4baea4ebba83f0d71816a..1b7d60c8b23b85dd7d52ce8de68caf3202f84c45 100644 (file)
@@ -13,7 +13,7 @@ import type { ComponentPublicInstance } from './componentPublicInstance'
 import { type VNode, createVNode } from './vnode'
 import { defineComponent } from './apiDefineComponent'
 import { warn } from './warning'
-import { ref } from '@vue/reactivity'
+import { type Ref, ref } from '@vue/reactivity'
 import { ErrorCodes, handleError } from './errorHandling'
 import { isKeepAlive } from './components/KeepAlive'
 import { markAsyncBoundary } from './helpers/useId'
@@ -25,10 +25,10 @@ export type AsyncComponentLoader<T = any> = () => Promise<
   AsyncComponentResolveResult<T>
 >
 
-export interface AsyncComponentOptions<T = any> {
+export interface AsyncComponentOptions<T = any, C = any> {
   loader: AsyncComponentLoader<T>
-  loadingComponent?: Component
-  errorComponent?: Component
+  loadingComponent?: C
+  errorComponent?: C
   delay?: number
   timeout?: number
   suspensible?: boolean
@@ -47,75 +47,20 @@ export const isAsyncWrapper = (i: GenericComponentInstance | VNode): boolean =>
 /*! #__NO_SIDE_EFFECTS__ */
 export function defineAsyncComponent<
   T extends Component = { new (): ComponentPublicInstance },
->(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
-  if (isFunction(source)) {
-    source = { loader: source }
-  }
-
+>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T, Component>): T {
   const {
-    loader,
-    loadingComponent,
-    errorComponent,
-    delay = 200,
-    hydrate: hydrateStrategy,
-    timeout, // undefined = never times out
-    suspensible = true,
-    onError: userOnError,
-  } = source
-
-  let pendingRequest: Promise<ConcreteComponent> | null = null
-  let resolvedComp: ConcreteComponent | undefined
-
-  let retries = 0
-  const retry = () => {
-    retries++
-    pendingRequest = null
-    return load()
-  }
-
-  const load = (): Promise<ConcreteComponent> => {
-    let thisRequest: Promise<ConcreteComponent>
-    return (
-      pendingRequest ||
-      (thisRequest = pendingRequest =
-        loader()
-          .catch(err => {
-            err = err instanceof Error ? err : new Error(String(err))
-            if (userOnError) {
-              return new Promise((resolve, reject) => {
-                const userRetry = () => resolve(retry())
-                const userFail = () => reject(err)
-                userOnError(err, userRetry, userFail, retries + 1)
-              })
-            } else {
-              throw err
-            }
-          })
-          .then((comp: any) => {
-            if (thisRequest !== pendingRequest && pendingRequest) {
-              return pendingRequest
-            }
-            if (__DEV__ && !comp) {
-              warn(
-                `Async component loader resolved to undefined. ` +
-                  `If you are using retry(), make sure to return its return value.`,
-              )
-            }
-            // interop module default
-            if (
-              comp &&
-              (comp.__esModule || comp[Symbol.toStringTag] === 'Module')
-            ) {
-              comp = comp.default
-            }
-            if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
-              throw new Error(`Invalid async component load result: ${comp}`)
-            }
-            resolvedComp = comp
-            return comp
-          }))
-    )
-  }
+    load,
+    getResolvedComp,
+    setPendingRequest,
+    source: {
+      loadingComponent,
+      errorComponent,
+      delay,
+      hydrate: hydrateStrategy,
+      timeout,
+      suspensible = true,
+    },
+  } = createAsyncComponentContext(source)
 
   return defineComponent({
     name: 'AsyncComponentWrapper',
@@ -129,6 +74,7 @@ export function defineAsyncComponent<
         // skip hydration if the component has been patched
         if (patched) {
           if (__DEV__) {
+            const resolvedComp = getResolvedComp()!
             warn(
               `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` +
                 `it was updated before lazy hydration performed.`,
@@ -148,7 +94,7 @@ export function defineAsyncComponent<
             }
           }
         : performHydrate
-      if (resolvedComp) {
+      if (getResolvedComp()) {
         doHydrate()
       } else {
         load().then(() => !instance.isUnmounted && doHydrate())
@@ -156,7 +102,7 @@ export function defineAsyncComponent<
     },
 
     get __asyncResolved() {
-      return resolvedComp
+      return getResolvedComp()
     },
 
     setup() {
@@ -164,12 +110,13 @@ export function defineAsyncComponent<
       markAsyncBoundary(instance)
 
       // already resolved
+      let resolvedComp = getResolvedComp()
       if (resolvedComp) {
         return () => createInnerComp(resolvedComp!, instance)
       }
 
       const onError = (err: Error) => {
-        pendingRequest = null
+        setPendingRequest(null)
         handleError(
           err,
           instance,
@@ -198,27 +145,11 @@ export function defineAsyncComponent<
           })
       }
 
-      const loaded = ref(false)
-      const error = ref()
-      const delayed = ref(!!delay)
-
-      if (delay) {
-        setTimeout(() => {
-          delayed.value = false
-        }, delay)
-      }
-
-      if (timeout != null) {
-        setTimeout(() => {
-          if (!loaded.value && !error.value) {
-            const err = new Error(
-              `Async component timed out after ${timeout}ms.`,
-            )
-            onError(err)
-            error.value = err
-          }
-        }, timeout)
-      }
+      const { loaded, error, delayed } = useAsyncComponentState(
+        delay,
+        timeout,
+        onError,
+      )
 
       load()
         .then(() => {
@@ -239,6 +170,7 @@ export function defineAsyncComponent<
         })
 
       return () => {
+        resolvedComp = getResolvedComp()
         if (loaded.value && resolvedComp) {
           return createInnerComp(resolvedComp, instance)
         } else if (error.value && errorComponent) {
@@ -268,3 +200,114 @@ function createInnerComp(
 
   return vnode
 }
+
+type AsyncComponentContext<T, C = ConcreteComponent> = {
+  load: () => Promise<C>
+  source: AsyncComponentOptions<T>
+  getResolvedComp: () => C | undefined
+  setPendingRequest: (request: Promise<C> | null) => void
+}
+
+// shared between core and vapor
+export function createAsyncComponentContext<T, C = ConcreteComponent>(
+  source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
+): AsyncComponentContext<T, C> {
+  if (isFunction(source)) {
+    source = { loader: source }
+  }
+
+  const { loader, onError: userOnError } = source
+  let pendingRequest: Promise<C> | null = null
+  let resolvedComp: C | undefined
+
+  let retries = 0
+  const retry = () => {
+    retries++
+    pendingRequest = null
+    return load()
+  }
+
+  const load = (): Promise<C> => {
+    let thisRequest: Promise<C>
+    return (
+      pendingRequest ||
+      (thisRequest = pendingRequest =
+        loader()
+          .catch(err => {
+            err = err instanceof Error ? err : new Error(String(err))
+            if (userOnError) {
+              return new Promise((resolve, reject) => {
+                const userRetry = () => resolve(retry())
+                const userFail = () => reject(err)
+                userOnError(err, userRetry, userFail, retries + 1)
+              })
+            } else {
+              throw err
+            }
+          })
+          .then((comp: any) => {
+            if (thisRequest !== pendingRequest && pendingRequest) {
+              return pendingRequest
+            }
+            if (__DEV__ && !comp) {
+              warn(
+                `Async component loader resolved to undefined. ` +
+                  `If you are using retry(), make sure to return its return value.`,
+              )
+            }
+            if (
+              comp &&
+              (comp.__esModule || comp[Symbol.toStringTag] === 'Module')
+            ) {
+              comp = comp.default
+            }
+            if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
+              throw new Error(`Invalid async component load result: ${comp}`)
+            }
+            resolvedComp = comp
+            return comp
+          }))
+    )
+  }
+
+  return {
+    load,
+    source,
+    getResolvedComp: () => resolvedComp,
+    setPendingRequest: (request: Promise<C> | null) =>
+      (pendingRequest = request),
+  }
+}
+
+// shared between core and vapor
+export const useAsyncComponentState = (
+  delay: number | undefined,
+  timeout: number | undefined,
+  onError: (err: Error) => void,
+): {
+  loaded: Ref<boolean>
+  error: Ref<Error | undefined>
+  delayed: Ref<boolean>
+} => {
+  const loaded = ref(false)
+  const error = ref()
+  const delayed = ref(!!delay)
+
+  if (delay) {
+    setTimeout(() => {
+      delayed.value = false
+    }, delay)
+  }
+
+  if (timeout != null) {
+    setTimeout(() => {
+      if (!loaded.value && !error.value) {
+        const err = new Error(`Async component timed out after ${timeout}ms.`)
+        onError(err)
+        error.value = err
+      }
+    }, timeout)
+  }
+
+  return { loaded, error, delayed }
+}
index d0d4686af3b3cba79ab31d4342274487c6c75c7c..d0fae060a318b718b30b2666f2ebe700f6167ec0 100644 (file)
@@ -567,6 +567,18 @@ export { startMeasure, endMeasure } from './profiling'
  * @internal
  */
 export { initFeatureFlags } from './featureFlags'
+/**
+ * @internal
+ */
+export {
+  createAsyncComponentContext,
+  useAsyncComponentState,
+  isAsyncWrapper,
+} from './apiAsyncComponent'
+/**
+ * @internal
+ */
+export { markAsyncBoundary } from './helpers/useId'
 /**
  * @internal
  */
diff --git a/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts
new file mode 100644 (file)
index 0000000..fa7f481
--- /dev/null
@@ -0,0 +1,764 @@
+import { nextTick, ref } from '@vue/runtime-dom'
+import { type VaporComponent, createComponent } from '../src/component'
+import { defineVaporAsyncComponent } from '../src/apiDefineAsyncComponent'
+import { makeRender } from './_utils'
+import {
+  createIf,
+  createTemplateRefSetter,
+  renderEffect,
+  template,
+} from '@vue/runtime-vapor'
+import { setElementText } from '../src/dom/prop'
+
+const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
+
+const define = makeRender()
+
+describe('api: defineAsyncComponent', () => {
+  test('simple usage', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent(
+      () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+    )
+
+    const toggle = ref(true)
+    const { html } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).render()
+
+    expect(html()).toBe('<!--async component--><!--if-->')
+    resolve!(() => template('resolved')())
+
+    await timeout()
+    expect(html()).toBe('resolved<!--async component--><!--if-->')
+
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe('<!--if-->')
+
+    // already resolved component should update on nextTick
+    toggle.value = true
+    await nextTick()
+    expect(html()).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('with loading component', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+      loadingComponent: () => template('loading')(),
+      delay: 1, // defaults to 200
+    })
+
+    const toggle = ref(true)
+    const { html } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).render()
+
+    // due to the delay, initial mount should be empty
+    expect(html()).toBe('<!--async component--><!--if-->')
+
+    // loading show up after delay
+    await timeout(1)
+    expect(html()).toBe('loading<!--async component--><!--if-->')
+
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(html()).toBe('resolved<!--async component--><!--if-->')
+
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe('<!--if-->')
+
+    // already resolved component should update on nextTick without loading
+    // state
+    toggle.value = true
+    await nextTick()
+    expect(html()).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('with loading component + explicit delay (0)', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+      loadingComponent: () => template('loading')(),
+      delay: 0,
+    })
+
+    const toggle = ref(true)
+    const { html } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).render()
+
+    // with delay: 0, should show loading immediately
+    expect(html()).toBe('loading<!--async component--><!--if-->')
+
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(html()).toBe('resolved<!--async component--><!--if-->')
+
+    toggle.value = false
+    await nextTick()
+    expect(html()).toBe('<!--if-->')
+
+    // already resolved component should update on nextTick without loading
+    // state
+    toggle.value = true
+    await nextTick()
+    expect(html()).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('error without error component', async () => {
+    let resolve: (comp: VaporComponent) => void
+    let reject: (e: Error) => void
+    const Foo = defineVaporAsyncComponent(
+      () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        }),
+    )
+
+    const toggle = ref(true)
+    const { app, mount } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).create()
+
+    const handler = (app.config.errorHandler = vi.fn())
+    const root = document.createElement('div')
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    const err = new Error('foo')
+    reject!(err)
+    await timeout()
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0]).toBe(err)
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--if-->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    // should render this time
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('error with error component', async () => {
+    let resolve: (comp: VaporComponent) => void
+    let reject: (e: Error) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        }),
+      errorComponent: (props: { error: Error }) =>
+        template(props.error.message)(),
+    })
+
+    const toggle = ref(true)
+    const { app, mount } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).create()
+    const handler = (app.config.errorHandler = vi.fn())
+    const root = document.createElement('div')
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    const err = new Error('errored out')
+    reject!(err)
+    await timeout()
+    expect(handler).toHaveBeenCalled()
+    expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--if-->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    // should render this time
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('error with error component, without global handler', async () => {
+    let resolve: (comp: VaporComponent) => void
+    let reject: (e: Error) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        }),
+      errorComponent: (props: { error: Error }) =>
+        template(props.error.message)(),
+    })
+
+    const toggle = ref(true)
+    const { mount } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).create()
+    const root = document.createElement('div')
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    const err = new Error('errored out')
+    reject!(err)
+    await timeout()
+    expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
+    expect(
+      'Unhandled error during execution of async component loader',
+    ).toHaveBeenWarned()
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--if-->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    // should render this time
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('error with error + loading components', async () => {
+    let resolve: (comp: VaporComponent) => void
+    let reject: (e: Error) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        }),
+      errorComponent: (props: { error: Error }) =>
+        template(props.error.message)(),
+      loadingComponent: () => template('loading')(),
+      delay: 1,
+    })
+
+    const toggle = ref(true)
+    const { app, mount } = define({
+      setup() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            return createComponent(Foo)
+          },
+        )
+      },
+    }).create()
+    const handler = (app.config.errorHandler = vi.fn())
+    const root = document.createElement('div')
+    mount(root)
+
+    // due to the delay, initial mount should be empty
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    // loading show up after delay
+    await timeout(1)
+    expect(root.innerHTML).toBe('loading<!--async component--><!--if-->')
+
+    const err = new Error('errored out')
+    reject!(err)
+    await timeout()
+    expect(handler).toHaveBeenCalled()
+    expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--if-->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+    // loading show up after delay
+    await timeout(1)
+    expect(root.innerHTML).toBe('loading<!--async component--><!--if-->')
+
+    // should render this time
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+  })
+
+  test('timeout without error component', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      timeout: 1,
+    })
+
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+    const handler = vi.fn()
+    app.config.errorHandler = handler
+
+    const root = document.createElement('div')
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+
+    await timeout(1)
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0].message).toMatch(
+      `Async component timed out after 1ms.`,
+    )
+    expect(root.innerHTML).toBe('<!--async component-->')
+
+    // if it resolved after timeout, should still work
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component-->')
+  })
+
+  test('timeout with error component', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      timeout: 1,
+      errorComponent: () => template('timed out')(),
+    })
+
+    const root = document.createElement('div')
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+
+    const handler = (app.config.errorHandler = vi.fn())
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+
+    await timeout(1)
+    expect(handler).toHaveBeenCalled()
+    expect(root.innerHTML).toBe('timed out<!--async component-->')
+
+    // if it resolved after timeout, should still work
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component-->')
+  })
+
+  test('timeout with error + loading components', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      delay: 1,
+      timeout: 16,
+      errorComponent: () => template('timed out')(),
+      loadingComponent: () => template('loading')(),
+    })
+
+    const root = document.createElement('div')
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+    const handler = (app.config.errorHandler = vi.fn())
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+    await timeout(1)
+    expect(root.innerHTML).toBe('loading<!--async component-->')
+
+    await timeout(16)
+    expect(root.innerHTML).toBe('timed out<!--async component-->')
+    expect(handler).toHaveBeenCalled()
+
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component-->')
+  })
+
+  test('timeout without error component, but with loading component', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      delay: 1,
+      timeout: 16,
+      loadingComponent: () => template('loading')(),
+    })
+
+    const root = document.createElement('div')
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+    const handler = vi.fn()
+    app.config.errorHandler = handler
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+    await timeout(1)
+    expect(root.innerHTML).toBe('loading<!--async component-->')
+
+    await timeout(16)
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0].message).toMatch(
+      `Async component timed out after 16ms.`,
+    )
+    // should still display loading
+    expect(root.innerHTML).toBe('loading<!--async component-->')
+
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component-->')
+  })
+
+  test('retry (success)', async () => {
+    let loaderCallCount = 0
+    let resolve: (comp: VaporComponent) => void
+    let reject: (e: Error) => void
+
+    const Foo = defineVaporAsyncComponent({
+      loader: () => {
+        loaderCallCount++
+        return new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        })
+      },
+      onError(error, retry, fail) {
+        if (error.message.match(/foo/)) {
+          retry()
+        } else {
+          fail()
+        }
+      },
+    })
+
+    const root = document.createElement('div')
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+
+    const handler = (app.config.errorHandler = vi.fn())
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+    expect(loaderCallCount).toBe(1)
+
+    const err = new Error('foo')
+    reject!(err)
+    await timeout()
+    expect(handler).not.toHaveBeenCalled()
+    expect(loaderCallCount).toBe(2)
+    expect(root.innerHTML).toBe('<!--async component-->')
+
+    // should render this time
+    resolve!(() => template('resolved')())
+    await timeout()
+    expect(handler).not.toHaveBeenCalled()
+    expect(root.innerHTML).toBe('resolved<!--async component-->')
+  })
+
+  test('retry (skipped)', async () => {
+    let loaderCallCount = 0
+    let reject: (e: Error) => void
+
+    const Foo = defineVaporAsyncComponent({
+      loader: () => {
+        loaderCallCount++
+        return new Promise((_resolve, _reject) => {
+          reject = _reject
+        })
+      },
+      onError(error, retry, fail) {
+        if (error.message.match(/bar/)) {
+          retry()
+        } else {
+          fail()
+        }
+      },
+    })
+
+    const root = document.createElement('div')
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+
+    const handler = (app.config.errorHandler = vi.fn())
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+    expect(loaderCallCount).toBe(1)
+
+    const err = new Error('foo')
+    reject!(err)
+    await timeout()
+    // should fail because retryWhen returns false
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0]).toBe(err)
+    expect(loaderCallCount).toBe(1)
+    expect(root.innerHTML).toBe('<!--async component-->')
+  })
+
+  test('retry (fail w/ max retry attempts)', async () => {
+    let loaderCallCount = 0
+    let reject: (e: Error) => void
+
+    const Foo = defineVaporAsyncComponent({
+      loader: () => {
+        loaderCallCount++
+        return new Promise((_resolve, _reject) => {
+          reject = _reject
+        })
+      },
+      onError(error, retry, fail, attempts) {
+        if (error.message.match(/foo/) && attempts <= 1) {
+          retry()
+        } else {
+          fail()
+        }
+      },
+    })
+
+    const root = document.createElement('div')
+    const { app, mount } = define({
+      setup() {
+        return createComponent(Foo)
+      },
+    }).create()
+
+    const handler = (app.config.errorHandler = vi.fn())
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component-->')
+    expect(loaderCallCount).toBe(1)
+
+    // first retry
+    const err = new Error('foo')
+    reject!(err)
+    await timeout()
+    expect(handler).not.toHaveBeenCalled()
+    expect(loaderCallCount).toBe(2)
+    expect(root.innerHTML).toBe('<!--async component-->')
+
+    // 2nd retry, should fail due to reaching maxRetries
+    reject!(err)
+    await timeout()
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0]).toBe(err)
+    expect(loaderCallCount).toBe(2)
+    expect(root.innerHTML).toBe('<!--async component-->')
+  })
+
+  test('template ref forwarding', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent(
+      () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+    )
+
+    const fooRef = ref<any>(null)
+    const toggle = ref(true)
+    const root = document.createElement('div')
+    const { mount } = define({
+      setup() {
+        return { fooRef, toggle }
+      },
+      render() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            const setTemplateRef = createTemplateRefSetter()
+            const n0 = createComponent(Foo, null, null, true)
+            setTemplateRef(n0, 'fooRef')
+            return n0
+          },
+        )
+      },
+    }).create()
+    mount(root)
+    expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+    expect(fooRef.value).toBe(null)
+
+    resolve!({
+      setup: (props, { expose }) => {
+        expose({
+          id: 'foo',
+        })
+        return template('resolved')()
+      },
+    })
+    // first time resolve, wait for macro task since there are multiple
+    // microtasks / .then() calls
+    await timeout()
+    expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+    expect(fooRef.value.id).toBe('foo')
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--if-->')
+    expect(fooRef.value).toBe(null)
+
+    // already resolved component should update on nextTick
+    toggle.value = true
+    await nextTick()
+    expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+    expect(fooRef.value.id).toBe('foo')
+  })
+
+  test('the forwarded template ref should always exist when doing multi patching', async () => {
+    let resolve: (comp: VaporComponent) => void
+    const Foo = defineVaporAsyncComponent(
+      () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+    )
+
+    const fooRef = ref<any>(null)
+    const toggle = ref(true)
+    const updater = ref(0)
+
+    const root = document.createElement('div')
+    const { mount } = define({
+      setup() {
+        return { fooRef, toggle, updater }
+      },
+      render() {
+        return createIf(
+          () => toggle.value,
+          () => {
+            const setTemplateRef = createTemplateRefSetter()
+            const n0 = createComponent(Foo, null, null, true)
+            setTemplateRef(n0, 'fooRef')
+            const n1 = template(`<span>`)()
+            renderEffect(() => setElementText(n1, updater.value))
+            return [n0, n1]
+          },
+        )
+      },
+    }).create()
+    mount(root)
+
+    expect(root.innerHTML).toBe('<!--async component--><span>0</span><!--if-->')
+    expect(fooRef.value).toBe(null)
+
+    resolve!({
+      setup: (props, { expose }) => {
+        expose({
+          id: 'foo',
+        })
+        return template('resolved')()
+      },
+    })
+
+    await timeout()
+    expect(root.innerHTML).toBe(
+      'resolved<!--async component--><span>0</span><!--if-->',
+    )
+    expect(fooRef.value.id).toBe('foo')
+
+    updater.value++
+    await nextTick()
+    expect(root.innerHTML).toBe(
+      'resolved<!--async component--><span>1</span><!--if-->',
+    )
+    expect(fooRef.value.id).toBe('foo')
+
+    toggle.value = false
+    await nextTick()
+    expect(root.innerHTML).toBe('<!--if-->')
+    expect(fooRef.value).toBe(null)
+  })
+
+  test.todo('with suspense', async () => {})
+
+  test.todo('suspensible: false', async () => {})
+
+  test.todo('suspense with error handling', async () => {})
+
+  test.todo('with KeepAlive', async () => {})
+
+  test.todo('with KeepAlive + include', async () => {})
+})
diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts
new file mode 100644 (file)
index 0000000..b06e255
--- /dev/null
@@ -0,0 +1,138 @@
+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 { DynamicFragment } from './block'
+import { renderEffect } from './renderEffect'
+
+/*! #__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,
+    appContext,
+  )
+
+  // set ref
+  // @ts-expect-error
+  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 8d5d8585a56c52c91a4bb7edaed097cef7cd6b72..5ddba415abd93fa4c5cf38dd139a83a38cd63734 100644 (file)
@@ -10,6 +10,7 @@ import {
   type SchedulerJob,
   callWithErrorHandling,
   createCanSetSetupRefChecker,
+  isAsyncWrapper,
   queuePostFlushCb,
   warn,
 } from '@vue/runtime-dom'
@@ -60,6 +61,20 @@ export function setRef(
     return
   }
 
+  const isVaporComp = isVaporComponent(el)
+  if (isVaporComp && isAsyncWrapper(el as VaporComponentInstance)) {
+    const i = el as VaporComponentInstance
+    const frag = i.block as DynamicFragment
+    // async component not resolved yet
+    if (!i.type.__asyncResolved) {
+      frag.setRef = i => setRef(instance, i, ref, oldRef, refFor)
+      return
+    }
+
+    // set ref to the inner component instead
+    el = frag.nodes as VaporComponentInstance
+  }
+
   const setupState: any = __DEV__ ? instance.setupState || {} : null
   const refValue = getRefValue(el)
   const refs =
index 755554e996c07352dd2689c515b3799ca55d03bb..0ab63342115769bf5203fa2e9d65a5cc25c5aa23 100644 (file)
@@ -104,6 +104,8 @@ export interface ObjectVaporComponent
 
   name?: string
   vapor?: boolean
+  __asyncLoader?: () => Promise<VaporComponent>
+  __asyncResolved?: VaporComponent
 }
 
 interface SharedInternalOptions {
index 6e8f4d9694031d35181ad017588b77fdcd90e9bf..61128da8f5a0e7f5535abeada00b50d071f79cd3 100644 (file)
@@ -1,6 +1,7 @@
 // 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 { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive'