diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
index 14cf58d3e11..eb3ff7b283e 100644
--- a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
+++ b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
@@ -193,8 +193,7 @@ describe('api: createAsyncComponent', () => {
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(handler).toHaveBeenCalled()
expect(serializeInner(root)).toBe('errored out')
toggle.value = false
@@ -247,8 +246,7 @@ describe('api: createAsyncComponent', () => {
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(handler).toHaveBeenCalled()
expect(serializeInner(root)).toBe('errored out')
toggle.value = false
@@ -327,7 +325,7 @@ describe('api: createAsyncComponent', () => {
expect(serializeInner(root)).toBe('')
await timeout(1)
- expect(handler).not.toHaveBeenCalled()
+ expect(handler).toHaveBeenCalled()
expect(serializeInner(root)).toBe('timed out')
// if it resolved after timeout, should still work
@@ -354,6 +352,7 @@ describe('api: createAsyncComponent', () => {
components: { Foo },
render: () => h(Foo)
})
+ const handler = (app.config.errorHandler = jest.fn())
app.mount(root)
expect(serializeInner(root)).toBe('')
await timeout(1)
@@ -361,6 +360,7 @@ describe('api: createAsyncComponent', () => {
await timeout(16)
expect(serializeInner(root)).toBe('timed out')
+ expect(handler).toHaveBeenCalled()
resolve!(() => 'resolved')
await timeout()
@@ -459,6 +459,32 @@ describe('api: createAsyncComponent', () => {
expect(serializeInner(root)).toBe('resolved & resolved')
})
- // TODO
- test.todo('suspense with error handling')
+ test('suspense with error handling', async () => {
+ let reject: (e: Error) => void
+ const Foo = createAsyncComponent(
+ () =>
+ new Promise((_resolve, _reject) => {
+ reject = _reject
+ })
+ )
+
+ const root = nodeOps.createElement('div')
+ const app = createApp({
+ components: { Foo },
+ render: () =>
+ h(Suspense, null, {
+ default: () => [h(Foo), ' & ', h(Foo)],
+ fallback: () => 'loading'
+ })
+ })
+
+ const handler = (app.config.errorHandler = jest.fn())
+ app.mount(root)
+ expect(serializeInner(root)).toBe('loading')
+
+ reject!(new Error('no'))
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(serializeInner(root)).toBe(' & ')
+ })
})
diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts
index fb08d74459d..96686c2ea21 100644
--- a/packages/runtime-core/__tests__/hydration.spec.ts
+++ b/packages/runtime-core/__tests__/hydration.spec.ts
@@ -7,7 +7,8 @@ import {
Portal,
createStaticVNode,
Suspense,
- onMounted
+ onMounted,
+ createAsyncComponent
} from '@vue/runtime-dom'
import { renderToString } from '@vue/server-renderer'
import { mockWarn } from '@vue/shared'
@@ -381,8 +382,64 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toMatch(`23`)
})
- // TODO
- test.todo('async component')
+ test('async component', async () => {
+ const spy = jest.fn()
+ const Comp = () =>
+ h(
+ 'button',
+ {
+ onClick: spy
+ },
+ 'hello!'
+ )
+
+ let serverResolve: any
+ let AsyncComp = createAsyncComponent(
+ () =>
+ new Promise(r => {
+ serverResolve = r
+ })
+ )
+
+ const App = {
+ render() {
+ return ['hello', h(AsyncComp), 'world']
+ }
+ }
+
+ // server render
+ const htmlPromise = renderToString(h(App))
+ serverResolve(Comp)
+ const html = await htmlPromise
+ expect(html).toMatchInlineSnapshot(
+ `"helloworld"`
+ )
+
+ // hydration
+ let clientResolve: any
+ AsyncComp = createAsyncComponent(
+ () =>
+ new Promise(r => {
+ clientResolve = r
+ })
+ )
+
+ const container = document.createElement('div')
+ container.innerHTML = html
+ createSSRApp(App).mount(container)
+
+ // hydration not complete yet
+ triggerEvent('click', container.querySelector('button')!)
+ expect(spy).not.toHaveBeenCalled()
+
+ // resolve
+ clientResolve(Comp)
+ await new Promise(r => setTimeout(r))
+
+ // should be hydrated now
+ triggerEvent('click', container.querySelector('button')!)
+ expect(spy).toHaveBeenCalled()
+ })
describe('mismatch handling', () => {
test('text node', () => {
diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts
index 20ea83a3dc9..39862e07def 100644
--- a/packages/runtime-core/src/apiAsyncComponent.ts
+++ b/packages/runtime-core/src/apiAsyncComponent.ts
@@ -3,7 +3,8 @@ import {
Component,
currentSuspense,
currentInstance,
- ComponentInternalInstance
+ ComponentInternalInstance,
+ isInSSRComponentSetup
} from './component'
import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
import { ComponentPublicInstance } from './componentProxy'
@@ -67,6 +68,7 @@ export function createAsyncComponent<
}
return defineComponent({
+ __asyncLoader: load,
name: 'AsyncComponentWrapper',
setup() {
const instance = currentInstance!
@@ -76,18 +78,29 @@ export function createAsyncComponent<
return () => createInnerComp(resolvedComp!, instance)
}
- // suspense-controlled
- if (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) {
- return load().then(comp => {
- return () => createInnerComp(comp, instance)
- })
- // TODO suspense error handling
+ const onError = (err: Error) => {
+ pendingRequest = null
+ handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
}
- // self-controlled
- if (__NODE_JS__) {
- // TODO SSR
+ // suspense-controlled or SSR.
+ if (
+ (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) ||
+ (__NODE_JS__ && isInSSRComponentSetup)
+ ) {
+ return load()
+ .then(comp => {
+ return () => createInnerComp(comp, instance)
+ })
+ .catch(err => {
+ onError(err)
+ return () =>
+ errorComponent
+ ? createVNode(errorComponent as Component, { error: err })
+ : null
+ })
}
+
// TODO hydration
const loaded = ref(false)
@@ -106,11 +119,8 @@ export function createAsyncComponent<
const err = new Error(
`Async component timed out after ${timeout}ms.`
)
- if (errorComponent) {
- error.value = err
- } else {
- handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
- }
+ onError(err)
+ error.value = err
}
}, timeout)
}
@@ -120,12 +130,8 @@ export function createAsyncComponent<
loaded.value = true
})
.catch(err => {
- pendingRequest = null
- if (errorComponent) {
- error.value = err
- } else {
- handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
- }
+ onError(err)
+ error.value = err
})
return () => {
diff --git a/packages/runtime-core/src/apiOptions.ts b/packages/runtime-core/src/apiOptions.ts
index 9c369d9ab55..aea558107de 100644
--- a/packages/runtime-core/src/apiOptions.ts
+++ b/packages/runtime-core/src/apiOptions.ts
@@ -4,7 +4,8 @@ import {
SetupContext,
RenderFunction,
SFCInternalOptions,
- PublicAPIComponent
+ PublicAPIComponent,
+ Component
} from './component'
import {
isFunction,
@@ -77,6 +78,8 @@ export interface ComponentOptionsBase<
// type-only differentiator to separate OptionWithoutProps from a constructor
// type returned by defineComponent() or FunctionalComponent
call?: never
+ // marker for AsyncComponentWrapper
+ __asyncLoader?: () => Promise
// type-only differentiators for built-in Vnode types
__isFragment?: never
__isPortal?: never
diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts
index aa6c81b751c..37933eca5a4 100644
--- a/packages/runtime-core/src/hydration.ts
+++ b/packages/runtime-core/src/hydration.ts
@@ -24,6 +24,7 @@ import {
SuspenseBoundary,
queueEffectWithSuspense
} from './components/Suspense'
+import { ComponentOptions } from './apiOptions'
export type RootHydrateFunction = (
vnode: VNode,
@@ -154,14 +155,23 @@ export function createHydrationFunctions(
// has .el set, the component will perform hydration instead of mount
// on its sub-tree.
const container = parentNode(node)!
- mountComponent(
- vnode,
- container,
- null,
- parentComponent,
- parentSuspense,
- isSVGContainer(container)
- )
+ const hydrateComponent = () => {
+ mountComponent(
+ vnode,
+ container,
+ null,
+ parentComponent,
+ parentSuspense,
+ isSVGContainer(container)
+ )
+ }
+ // async component
+ const loadAsync = (vnode.type as ComponentOptions).__asyncLoader
+ if (loadAsync) {
+ loadAsync().then(hydrateComponent)
+ } else {
+ hydrateComponent()
+ }
// component may be async, so in the case of fragments we cannot rely
// on component's rendered output to determine the end of the fragment
// instead, we do a lookahead to find the end anchor node.