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'
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
/*! #__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',
// 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.`,
}
}
: performHydrate
- if (resolvedComp) {
+ if (getResolvedComp()) {
doHydrate()
} else {
load().then(() => !instance.isUnmounted && doHydrate())
},
get __asyncResolved() {
- return resolvedComp
+ return getResolvedComp()
},
setup() {
markAsyncBoundary(instance)
// already resolved
+ let resolvedComp = getResolvedComp()
if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance)
}
const onError = (err: Error) => {
- pendingRequest = null
+ setPendingRequest(null)
handleError(
err,
instance,
})
}
- 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(() => {
})
return () => {
+ resolvedComp = getResolvedComp()
if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
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 }
+}
--- /dev/null
+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 () => {})
+})