feat(runtime-core): async component support

This commit is contained in:
Evan You 2020-03-21 16:01:08 -04:00
parent d425818901
commit c3bb3169f4
5 changed files with 629 additions and 0 deletions

View File

@ -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')
})

View File

@ -379,6 +379,9 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`) expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`)
}) })
// TODO
test.todo('async component')
describe('mismatch handling', () => { describe('mismatch handling', () => {
test('text node', () => { test('text node', () => {
const { container } = mountWithHydration(`foo`, () => 'bar') const { container } = mountWithHydration(`foo`, () => 'bar')

View File

@ -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 = PublicAPIComponent> =
| T
| { default: T } // es modules
export type AsyncComponentLoader<T = any> = () => Promise<
AsyncComponentResolveResult<T>
>
export interface AsyncComponentOptions<T = any> {
loader: AsyncComponentLoader<T>
loading?: PublicAPIComponent
error?: PublicAPIComponent
delay?: number
timeout?: number
suspensible?: boolean
}
export function createAsyncComponent<
T extends PublicAPIComponent = { new (): ComponentPublicInstance }
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): 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<Component> | null = null
let resolvedComp: Component | undefined
const load = (): Promise<Component> => {
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
)
}

View File

@ -19,6 +19,7 @@ export const enum ErrorCodes {
APP_ERROR_HANDLER, APP_ERROR_HANDLER,
APP_WARN_HANDLER, APP_WARN_HANDLER,
FUNCTION_REF, FUNCTION_REF,
ASYNC_COMPONENT_LOADER,
SCHEDULER SCHEDULER
} }
@ -49,6 +50,7 @@ export const ErrorTypeStrings: Record<number | string, string> = {
[ErrorCodes.APP_ERROR_HANDLER]: 'app errorHandler', [ErrorCodes.APP_ERROR_HANDLER]: 'app errorHandler',
[ErrorCodes.APP_WARN_HANDLER]: 'app warnHandler', [ErrorCodes.APP_WARN_HANDLER]: 'app warnHandler',
[ErrorCodes.FUNCTION_REF]: 'ref function', [ErrorCodes.FUNCTION_REF]: 'ref function',
[ErrorCodes.ASYNC_COMPONENT_LOADER]: 'async component loader',
[ErrorCodes.SCHEDULER]: [ErrorCodes.SCHEDULER]:
'scheduler flush. This is likely a Vue internals bug. ' + 'scheduler flush. This is likely a Vue internals bug. ' +
'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue-next' 'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue-next'

View File

@ -34,6 +34,7 @@ export {
export { provide, inject } from './apiInject' export { provide, inject } from './apiInject'
export { nextTick } from './scheduler' export { nextTick } from './scheduler'
export { defineComponent } from './apiDefineComponent' export { defineComponent } from './apiDefineComponent'
export { createAsyncComponent } from './apiAsyncComponent'
// Advanced API ---------------------------------------------------------------- // Advanced API ----------------------------------------------------------------
@ -204,4 +205,8 @@ export {
} from './directives' } from './directives'
export { SuspenseBoundary } from './components/Suspense' export { SuspenseBoundary } from './components/Suspense'
export { TransitionState, TransitionHooks } from './components/BaseTransition' export { TransitionState, TransitionHooks } from './components/BaseTransition'
export {
AsyncComponentOptions,
AsyncComponentLoader
} from './apiAsyncComponent'
export { HMRRuntime } from './hmr' export { HMRRuntime } from './hmr'