diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
index 360f48085a1..32461df61af 100644
--- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
+++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts
@@ -2,13 +2,15 @@ import path from 'node:path'
import {
E2E_TIMEOUT,
setupPuppeteer,
+ timeout,
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'
+const { page, click, text, enterValue, html } = setupPuppeteer()
-describe('vdom / vapor interop', () => {
- const { page, click, text, enterValue } = setupPuppeteer()
+const duration = process.env.CI ? 200 : 50
+describe('vdom / vapor interop', () => {
let server: any
const port = '8193'
beforeAll(() => {
@@ -22,12 +24,15 @@ describe('vdom / vapor interop', () => {
server.close()
})
+ beforeEach(async () => {
+ const baseUrl = `http://localhost:${port}/interop/`
+ await page().goto(baseUrl)
+ await page().waitForSelector('#app')
+ })
+
test(
'should work',
async () => {
- const baseUrl = `http://localhost:${port}/interop/`
- await page().goto(baseUrl)
-
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
expect(await text('.vapor-prop')).toContain('hello')
@@ -81,4 +86,19 @@ 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('loading...')
+
+ await timeout(duration)
+ expect(await html(testContainer)).toBe('
foo
')
+ },
+ E2E_TIMEOUT,
+ )
+ })
})
diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue
index 772a6989dd7..c8c6c945da1 100644
--- a/packages-private/vapor-e2e-test/interop/App.vue
+++ b/packages-private/vapor-e2e-test/interop/App.vue
@@ -1,9 +1,23 @@
@@ -19,4 +33,12 @@ const passSlot = ref(true)
A test slot
+
+
+
+
diff --git a/packages-private/vapor-e2e-test/interop/VaporComp.vue b/packages-private/vapor-e2e-test/interop/components/VaporComp.vue
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 88a60c782c0..09b08154ae3 100644
--- a/packages-private/vapor-e2e-test/interop/VaporComp.vue
+++ b/packages-private/vapor-e2e-test/interop/components/VaporComp.vue
@@ -27,7 +27,8 @@ const slotProp = ref('slot prop')
change slot prop
- #default:
+ #default:
+
#test:
fallback content
diff --git a/packages-private/vapor-e2e-test/interop/VdomComp.vue b/packages-private/vapor-e2e-test/interop/components/VdomComp.vue
similarity index 100%
rename from packages-private/vapor-e2e-test/interop/VdomComp.vue
rename to packages-private/vapor-e2e-test/interop/components/VdomComp.vue
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
index 00000000000..ee13cfbb1ab
--- /dev/null
+++ b/packages-private/vapor-e2e-test/interop/components/VdomFoo.vue
@@ -0,0 +1,5 @@
+
+
+
+ foo
+
diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts
index 07e7fc67fef..8eee833ac4b 100644
--- a/packages/runtime-core/src/apiAsyncComponent.ts
+++ b/packages/runtime-core/src/apiAsyncComponent.ts
@@ -12,7 +12,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'
@@ -24,10 +24,10 @@ export type AsyncComponentLoader
= () => Promise<
AsyncComponentResolveResult
>
-export interface AsyncComponentOptions {
+export interface AsyncComponentOptions {
loader: AsyncComponentLoader
- loadingComponent?: Component
- errorComponent?: Component
+ loadingComponent?: C
+ errorComponent?: C
delay?: number
timeout?: number
suspensible?: boolean
@@ -46,75 +46,20 @@ export const isAsyncWrapper = (i: GenericComponentInstance | VNode): boolean =>
/*! #__NO_SIDE_EFFECTS__ */
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance },
->(source: AsyncComponentLoader | AsyncComponentOptions): T {
- if (isFunction(source)) {
- source = { loader: source }
- }
-
+>(source: AsyncComponentLoader | AsyncComponentOptions): T {
const {
- loader,
- loadingComponent,
- errorComponent,
- delay = 200,
- hydrate: hydrateStrategy,
- timeout, // undefined = never times out
- suspensible = true,
- onError: userOnError,
- } = source
-
- let pendingRequest: Promise | null = null
- let resolvedComp: ConcreteComponent | undefined
-
- let retries = 0
- const retry = () => {
- retries++
- pendingRequest = null
- return load()
- }
-
- const load = (): Promise => {
- let thisRequest: Promise
- 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',
@@ -132,7 +77,7 @@ export function defineAsyncComponent<
}
}
: hydrate
- if (resolvedComp) {
+ if (getResolvedComp()) {
doHydrate()
} else {
load().then(() => !instance.isUnmounted && doHydrate())
@@ -140,7 +85,7 @@ export function defineAsyncComponent<
},
get __asyncResolved() {
- return resolvedComp
+ return getResolvedComp()
},
setup() {
@@ -148,12 +93,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,
@@ -182,27 +128,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(() => {
@@ -223,6 +153,7 @@ export function defineAsyncComponent<
})
return () => {
+ resolvedComp = getResolvedComp()
if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
@@ -252,3 +183,114 @@ function createInnerComp(
return vnode
}
+
+type AsyncComponentContext = {
+ load: () => Promise
+ source: AsyncComponentOptions
+ getResolvedComp: () => C | undefined
+ setPendingRequest: (request: Promise | null) => void
+}
+
+// shared between core and vapor
+export function createAsyncComponentContext(
+ source: AsyncComponentLoader | AsyncComponentOptions,
+): AsyncComponentContext {
+ if (isFunction(source)) {
+ source = { loader: source }
+ }
+
+ const { loader, onError: userOnError } = source
+ let pendingRequest: Promise | null = null
+ let resolvedComp: C | undefined
+
+ let retries = 0
+ const retry = () => {
+ retries++
+ pendingRequest = null
+ return load()
+ }
+
+ const load = (): Promise => {
+ let thisRequest: Promise
+ 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 | null) =>
+ (pendingRequest = request),
+ }
+}
+
+// shared between core and vapor
+export const useAsyncComponentState = (
+ delay: number | undefined,
+ timeout: number | undefined,
+ onError: (err: Error) => void,
+): {
+ loaded: Ref
+ error: Ref
+ delayed: Ref
+} => {
+ 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 }
+}
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index c7150e38e80..62677d732b8 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -557,3 +557,15 @@ export { startMeasure, endMeasure } from './profiling'
* @internal
*/
export { initFeatureFlags } from './featureFlags'
+/**
+ * @internal
+ */
+export {
+ createAsyncComponentContext,
+ useAsyncComponentState,
+ isAsyncWrapper,
+} from './apiAsyncComponent'
+/**
+ * @internal
+ */
+export { markAsyncBoundary } from './helpers/useId'
diff --git a/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts
new file mode 100644
index 00000000000..fa7f481707c
--- /dev/null
+++ b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts
@@ -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('')
+ resolve!(() => template('resolved')())
+
+ await timeout()
+ expect(html()).toBe('resolved')
+
+ toggle.value = false
+ await nextTick()
+ expect(html()).toBe('')
+
+ // already resolved component should update on nextTick
+ toggle.value = true
+ await nextTick()
+ expect(html()).toBe('resolved')
+ })
+
+ 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('')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(html()).toBe('loading')
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(html()).toBe('resolved')
+
+ toggle.value = false
+ await nextTick()
+ expect(html()).toBe('')
+
+ // already resolved component should update on nextTick without loading
+ // state
+ toggle.value = true
+ await nextTick()
+ expect(html()).toBe('resolved')
+ })
+
+ 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')
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(html()).toBe('resolved')
+
+ toggle.value = false
+ await nextTick()
+ expect(html()).toBe('')
+
+ // already resolved component should update on nextTick without loading
+ // state
+ toggle.value = true
+ await nextTick()
+ expect(html()).toBe('resolved')
+ })
+
+ 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('')
+
+ const err = new Error('foo')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(handler.mock.calls[0][0]).toBe(err)
+ expect(root.innerHTML).toBe('')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(root.innerHTML).toBe('errored out')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ expect(root.innerHTML).toBe('errored out')
+ expect(
+ 'Unhandled error during execution of async component loader',
+ ).toHaveBeenWarned()
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(root.innerHTML).toBe('errored out')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+
+ await timeout(1)
+ expect(handler).toHaveBeenCalled()
+ expect(handler.mock.calls[0][0].message).toMatch(
+ `Async component timed out after 1ms.`,
+ )
+ expect(root.innerHTML).toBe('')
+
+ // if it resolved after timeout, should still work
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+
+ await timeout(1)
+ expect(handler).toHaveBeenCalled()
+ expect(root.innerHTML).toBe('timed out')
+
+ // if it resolved after timeout, should still work
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading')
+
+ await timeout(16)
+ expect(root.innerHTML).toBe('timed out')
+ expect(handler).toHaveBeenCalled()
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading')
+
+ 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')
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+ 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('')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(handler).not.toHaveBeenCalled()
+ expect(root.innerHTML).toBe('resolved')
+ })
+
+ 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('')
+ 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('')
+ })
+
+ 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('')
+ 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('')
+
+ // 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('')
+ })
+
+ test('template ref forwarding', async () => {
+ let resolve: (comp: VaporComponent) => void
+ const Foo = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ resolve = r as any
+ }),
+ )
+
+ const fooRef = ref(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('')
+ 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')
+ expect(fooRef.value.id).toBe('foo')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+ expect(fooRef.value).toBe(null)
+
+ // already resolved component should update on nextTick
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('resolved')
+ 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(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(``)()
+ renderEffect(() => setElementText(n1, updater.value))
+ return [n0, n1]
+ },
+ )
+ },
+ }).create()
+ mount(root)
+
+ expect(root.innerHTML).toBe('0')
+ expect(fooRef.value).toBe(null)
+
+ resolve!({
+ setup: (props, { expose }) => {
+ expose({
+ id: 'foo',
+ })
+ return template('resolved')()
+ },
+ })
+
+ await timeout()
+ expect(root.innerHTML).toBe(
+ 'resolved0',
+ )
+ expect(fooRef.value.id).toBe('foo')
+
+ updater.value++
+ await nextTick()
+ expect(root.innerHTML).toBe(
+ 'resolved1',
+ )
+ expect(fooRef.value.id).toBe('foo')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('')
+ 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
index 00000000000..ddd91c06c8b
--- /dev/null
+++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts
@@ -0,0 +1,137 @@
+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(
+ source: AsyncComponentLoader | AsyncComponentOptions,
+): T {
+ const {
+ load,
+ getResolvedComp,
+ setPendingRequest,
+ source: {
+ loadingComponent,
+ errorComponent,
+ delay,
+ // hydrate: hydrateStrategy,
+ timeout,
+ // suspensible = true,
+ },
+ } = createAsyncComponentContext(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
+ 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
+}
diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts
index c5a6c5fb2b6..1ec0d65ef10 100644
--- a/packages/runtime-vapor/src/apiTemplateRef.ts
+++ b/packages/runtime-vapor/src/apiTemplateRef.ts
@@ -9,6 +9,7 @@ import {
ErrorCodes,
type SchedulerJob,
callWithErrorHandling,
+ isAsyncWrapper,
queuePostFlushCb,
warn,
} from '@vue/runtime-dom'
@@ -20,6 +21,7 @@ import {
isString,
remove,
} from '@vue/shared'
+import type { DynamicFragment } from './block'
export type NodeRef = string | Ref | ((ref: Element) => void)
export type RefEl = Element | VaporComponentInstance
@@ -48,8 +50,24 @@ export function setRef(
): NodeRef | undefined {
if (!instance || instance.isUnmounted) 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 = isVaporComponent(el) ? getExposed(el) || el : el
+ const refValue = isVaporComp
+ ? getExposed(el as VaporComponentInstance) || el
+ : el
const refs =
instance.refs === EMPTY_OBJ ? (instance.refs = {}) : instance.refs
diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts
index b782afd38d3..ca2b97a0681 100644
--- a/packages/runtime-vapor/src/block.ts
+++ b/packages/runtime-vapor/src/block.ts
@@ -23,6 +23,7 @@ export class VaporFragment {
anchor?: Node
insert?: (parent: ParentNode, anchor: Node | null) => void
remove?: (parent?: ParentNode) => void
+ setRef?: (comp: VaporComponentInstance) => void
constructor(nodes: Block) {
this.nodes = nodes
diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts
index 548babebf8b..4dfc6a09b1d 100644
--- a/packages/runtime-vapor/src/component.ts
+++ b/packages/runtime-vapor/src/component.ts
@@ -92,6 +92,8 @@ export interface ObjectVaporComponent
name?: string
vapor?: boolean
+ __asyncLoader?: () => Promise
+ __asyncResolved?: VaporComponent
}
interface SharedInternalOptions {
diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts
index a5e9daad229..fcff365c7a5 100644
--- a/packages/runtime-vapor/src/componentProps.ts
+++ b/packages/runtime-vapor/src/componentProps.ts
@@ -182,7 +182,7 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
source = dynamicSources[i]
isDynamic = isFunction(source)
source = isDynamic ? (source as Function)() : source
- if (hasOwn(source, key)) {
+ if (source && hasOwn(source, key)) {
const value = isDynamic ? source[key] : source[key]()
if (merged) {
merged.push(value)
diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts
index 682532fa4d8..7cd81c3e102 100644
--- a/packages/runtime-vapor/src/index.ts
+++ b/packages/runtime-vapor/src/index.ts
@@ -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'