diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
new file mode 100644
index 00000000000..14cf58d3e11
--- /dev/null
+++ b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
@@ -0,0 +1,464 @@
+import {
+ createAsyncComponent,
+ h,
+ Component,
+ ref,
+ nextTick,
+ Suspense
+} from '../src'
+import { createApp, nodeOps, serializeInner } from '@vue/runtime-test'
+
+const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
+
+describe('api: createAsyncComponent', () => {
+ test('simple usage', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent(
+ () =>
+ new Promise(r => {
+ resolve = r as any
+ })
+ )
+
+ const toggle = ref(true)
+ const root = nodeOps.createElement('div')
+ createApp({
+ components: { Foo },
+ render: () => (toggle.value ? h(Foo) : null)
+ }).mount(root)
+
+ expect(serializeInner(root)).toBe('')
+
+ resolve!(() => 'resolved')
+ // first time resolve, wait for macro task since there are multiple
+ // microtasks / .then() calls
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // already resolved component should update on nextTick
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('with loading component', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(r => {
+ resolve = r as any
+ }),
+ loading: () => 'loading',
+ delay: 1 // defaults to 200
+ })
+
+ const toggle = ref(true)
+ const root = nodeOps.createElement('div')
+ createApp({
+ components: { Foo },
+ render: () => (toggle.value ? h(Foo) : null)
+ }).mount(root)
+
+ // due to the delay, initial mount should be empty
+ expect(serializeInner(root)).toBe('')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(serializeInner(root)).toBe('loading')
+
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // already resolved component should update on nextTick without loading
+ // state
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('with loading component + explicit delay (0)', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(r => {
+ resolve = r as any
+ }),
+ loading: () => 'loading',
+ delay: 0
+ })
+
+ const toggle = ref(true)
+ const root = nodeOps.createElement('div')
+ createApp({
+ components: { Foo },
+ render: () => (toggle.value ? h(Foo) : null)
+ }).mount(root)
+
+ // with delay: 0, should show loading immediately
+ expect(serializeInner(root)).toBe('loading')
+
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // already resolved component should update on nextTick without loading
+ // state
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('error without error component', async () => {
+ let resolve: (comp: Component) => void
+ let reject: (e: Error) => void
+ const Foo = createAsyncComponent(
+ () =>
+ new Promise((_resolve, _reject) => {
+ resolve = _resolve as any
+ reject = _reject
+ })
+ )
+
+ const toggle = ref(true)
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => (toggle.value ? h(Foo) : null)
+ })
+
+ const handler = (app.config.errorHandler = jest.fn())
+
+ app.mount(root)
+ expect(serializeInner(root)).toBe('')
+
+ const err = new Error('foo')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(handler.mock.calls[0][0]).toBe(err)
+ expect(serializeInner(root)).toBe('')
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // should render this time
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('error with error component', async () => {
+ let resolve: (comp: Component) => void
+ let reject: (e: Error) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise((_resolve, _reject) => {
+ resolve = _resolve as any
+ reject = _reject
+ }),
+ error: (props: { error: Error }) => props.error.message
+ })
+
+ const toggle = ref(true)
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => (toggle.value ? h(Foo) : null)
+ })
+
+ const handler = (app.config.errorHandler = jest.fn())
+
+ app.mount(root)
+ expect(serializeInner(root)).toBe('')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ // error handler will not be called if error component is present
+ expect(handler).not.toHaveBeenCalled()
+ expect(serializeInner(root)).toBe('errored out')
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // should render this time
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('error with error + loading components', async () => {
+ let resolve: (comp: Component) => void
+ let reject: (e: Error) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise((_resolve, _reject) => {
+ resolve = _resolve as any
+ reject = _reject
+ }),
+ error: (props: { error: Error }) => props.error.message,
+ loading: () => 'loading',
+ delay: 1
+ })
+
+ const toggle = ref(true)
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => (toggle.value ? h(Foo) : null)
+ })
+
+ const handler = (app.config.errorHandler = jest.fn())
+
+ app.mount(root)
+
+ // due to the delay, initial mount should be empty
+ expect(serializeInner(root)).toBe('')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(serializeInner(root)).toBe('loading')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ // error handler will not be called if error component is present
+ expect(handler).not.toHaveBeenCalled()
+ expect(serializeInner(root)).toBe('errored out')
+
+ toggle.value = false
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(serializeInner(root)).toBe('')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(serializeInner(root)).toBe('loading')
+
+ // should render this time
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('timeout without error component', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(_resolve => {
+ resolve = _resolve as any
+ }),
+ timeout: 1
+ })
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => h(Foo)
+ })
+
+ const handler = (app.config.errorHandler = jest.fn())
+
+ app.mount(root)
+ expect(serializeInner(root)).toBe('')
+
+ await timeout(1)
+ expect(handler).toHaveBeenCalled()
+ expect(handler.mock.calls[0][0].message).toMatch(
+ `Async component timed out after 1ms.`
+ )
+ expect(serializeInner(root)).toBe('')
+
+ // if it resolved after timeout, should still work
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('timeout with error component', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(_resolve => {
+ resolve = _resolve as any
+ }),
+ timeout: 1,
+ error: () => 'timed out'
+ })
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => h(Foo)
+ })
+
+ const handler = (app.config.errorHandler = jest.fn())
+
+ app.mount(root)
+ expect(serializeInner(root)).toBe('')
+
+ await timeout(1)
+ expect(handler).not.toHaveBeenCalled()
+ expect(serializeInner(root)).toBe('timed out')
+
+ // if it resolved after timeout, should still work
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('timeout with error + loading components', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(_resolve => {
+ resolve = _resolve as any
+ }),
+ delay: 1,
+ timeout: 16,
+ error: () => 'timed out',
+ loading: () => 'loading'
+ })
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => h(Foo)
+ })
+ app.mount(root)
+ expect(serializeInner(root)).toBe('')
+ await timeout(1)
+ expect(serializeInner(root)).toBe('loading')
+
+ await timeout(16)
+ expect(serializeInner(root)).toBe('timed out')
+
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('timeout without error component, but with loading component', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(_resolve => {
+ resolve = _resolve as any
+ }),
+ delay: 1,
+ timeout: 16,
+ loading: () => 'loading'
+ })
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () => h(Foo)
+ })
+ const handler = (app.config.errorHandler = jest.fn())
+ app.mount(root)
+ expect(serializeInner(root)).toBe('')
+ await timeout(1)
+ expect(serializeInner(root)).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(serializeInner(root)).toBe('loading')
+
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved')
+ })
+
+ test('with suspense', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent(
+ () =>
+ new Promise(_resolve => {
+ resolve = _resolve as any
+ })
+ )
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () =>
+ h(Suspense, null, {
+ default: () => [h(Foo), ' & ', h(Foo)],
+ fallback: () => 'loading'
+ })
+ })
+
+ app.mount(root)
+ expect(serializeInner(root)).toBe('loading')
+
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved & resolved')
+ })
+
+ test('suspensible: false', async () => {
+ let resolve: (comp: Component) => void
+ const Foo = createAsyncComponent({
+ loader: () =>
+ new Promise(_resolve => {
+ resolve = _resolve as any
+ }),
+ suspensible: false
+ })
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () =>
+ h(Suspense, null, {
+ default: () => [h(Foo), ' & ', h(Foo)],
+ fallback: () => 'loading'
+ })
+ })
+
+ app.mount(root)
+ // should not show suspense fallback
+ expect(serializeInner(root)).toBe(' & ')
+
+ resolve!(() => 'resolved')
+ await timeout()
+ expect(serializeInner(root)).toBe('resolved & resolved')
+ })
+
+ // TODO
+ test.todo('suspense with error handling')
+})
diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts
index 67387bc52be..0720eef61c4 100644
--- a/packages/runtime-core/__tests__/hydration.spec.ts
+++ b/packages/runtime-core/__tests__/hydration.spec.ts
@@ -379,6 +379,9 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toMatch(`23`)
})
+ // TODO
+ test.todo('async component')
+
describe('mismatch handling', () => {
test('text node', () => {
const { container } = mountWithHydration(`foo`, () => 'bar')
diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts
new file mode 100644
index 00000000000..20ea83a3dc9
--- /dev/null
+++ b/packages/runtime-core/src/apiAsyncComponent.ts
@@ -0,0 +1,155 @@
+import {
+ PublicAPIComponent,
+ Component,
+ currentSuspense,
+ currentInstance,
+ ComponentInternalInstance
+} from './component'
+import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
+import { ComponentPublicInstance } from './componentProxy'
+import { createVNode } from './vnode'
+import { defineComponent } from './apiDefineComponent'
+import { warn } from './warning'
+import { ref } from '@vue/reactivity'
+import { handleError, ErrorCodes } from './errorHandling'
+
+export type AsyncComponentResolveResult =
+ | T
+ | { default: T } // es modules
+
+export type AsyncComponentLoader = () => Promise<
+ AsyncComponentResolveResult
+>
+
+export interface AsyncComponentOptions {
+ loader: AsyncComponentLoader
+ loading?: PublicAPIComponent
+ error?: PublicAPIComponent
+ delay?: number
+ timeout?: number
+ suspensible?: boolean
+}
+
+export function createAsyncComponent<
+ T extends PublicAPIComponent = { new (): ComponentPublicInstance }
+>(source: AsyncComponentLoader | AsyncComponentOptions): T {
+ if (isFunction(source)) {
+ source = { loader: source }
+ }
+
+ const {
+ suspensible = true,
+ loader,
+ loading: loadingComponent,
+ error: errorComponent,
+ delay = 200,
+ timeout // undefined = never times out
+ } = source
+
+ let pendingRequest: Promise | null = null
+ let resolvedComp: Component | undefined
+
+ const load = (): Promise => {
+ return (
+ pendingRequest ||
+ (pendingRequest = loader().then((comp: any) => {
+ // interop module default
+ if (comp.__esModule || comp[Symbol.toStringTag] === 'Module') {
+ comp = comp.default
+ }
+ if (__DEV__ && !isObject(comp) && !isFunction(comp)) {
+ warn(`Invalid async component load result: `, comp)
+ }
+ resolvedComp = comp
+ return comp
+ }))
+ )
+ }
+
+ return defineComponent({
+ name: 'AsyncComponentWrapper',
+ setup() {
+ const instance = currentInstance!
+
+ // already resolved
+ if (resolvedComp) {
+ return () => createInnerComp(resolvedComp!, instance)
+ }
+
+ // suspense-controlled
+ if (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) {
+ return load().then(comp => {
+ return () => createInnerComp(comp, instance)
+ })
+ // TODO suspense error handling
+ }
+
+ // self-controlled
+ if (__NODE_JS__) {
+ // TODO SSR
+ }
+ // TODO hydration
+
+ 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) {
+ const err = new Error(
+ `Async component timed out after ${timeout}ms.`
+ )
+ if (errorComponent) {
+ error.value = err
+ } else {
+ handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
+ }
+ }
+ }, timeout)
+ }
+
+ load()
+ .then(() => {
+ loaded.value = true
+ })
+ .catch(err => {
+ pendingRequest = null
+ if (errorComponent) {
+ error.value = err
+ } else {
+ handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
+ }
+ })
+
+ return () => {
+ if (loaded.value && resolvedComp) {
+ return createInnerComp(resolvedComp, instance)
+ } else if (error.value && errorComponent) {
+ return createVNode(errorComponent as Component, {
+ error: error.value
+ })
+ } else if (loadingComponent && !delayed.value) {
+ return createVNode(loadingComponent as Component)
+ }
+ }
+ }
+ }) as any
+}
+
+function createInnerComp(
+ comp: Component,
+ { props, slots }: ComponentInternalInstance
+) {
+ return createVNode(
+ comp,
+ props === EMPTY_OBJ ? null : props,
+ slots === EMPTY_OBJ ? null : slots
+ )
+}
diff --git a/packages/runtime-core/src/errorHandling.ts b/packages/runtime-core/src/errorHandling.ts
index c57fd8a99c4..095661218c6 100644
--- a/packages/runtime-core/src/errorHandling.ts
+++ b/packages/runtime-core/src/errorHandling.ts
@@ -19,6 +19,7 @@ export const enum ErrorCodes {
APP_ERROR_HANDLER,
APP_WARN_HANDLER,
FUNCTION_REF,
+ ASYNC_COMPONENT_LOADER,
SCHEDULER
}
@@ -49,6 +50,7 @@ export const ErrorTypeStrings: Record = {
[ErrorCodes.APP_ERROR_HANDLER]: 'app errorHandler',
[ErrorCodes.APP_WARN_HANDLER]: 'app warnHandler',
[ErrorCodes.FUNCTION_REF]: 'ref function',
+ [ErrorCodes.ASYNC_COMPONENT_LOADER]: 'async component loader',
[ErrorCodes.SCHEDULER]:
'scheduler flush. This is likely a Vue internals bug. ' +
'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue-next'
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index b05acf093c5..2f3b6543800 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -34,6 +34,7 @@ export {
export { provide, inject } from './apiInject'
export { nextTick } from './scheduler'
export { defineComponent } from './apiDefineComponent'
+export { createAsyncComponent } from './apiAsyncComponent'
// Advanced API ----------------------------------------------------------------
@@ -204,4 +205,8 @@ export {
} from './directives'
export { SuspenseBoundary } from './components/Suspense'
export { TransitionState, TransitionHooks } from './components/BaseTransition'
+export {
+ AsyncComponentOptions,
+ AsyncComponentLoader
+} from './apiAsyncComponent'
export { HMRRuntime } from './hmr'