From c3bb3169f497fc834654d8ae700f18b1a6613127 Mon Sep 17 00:00:00 2001 From: Evan You Date: Sat, 21 Mar 2020 16:01:08 -0400 Subject: [PATCH] feat(runtime-core): async component support --- .../__tests__/apiAsyncComponent.spec.ts | 464 ++++++++++++++++++ .../runtime-core/__tests__/hydration.spec.ts | 3 + .../runtime-core/src/apiAsyncComponent.ts | 155 ++++++ packages/runtime-core/src/errorHandling.ts | 2 + packages/runtime-core/src/index.ts | 5 + 5 files changed, 629 insertions(+) create mode 100644 packages/runtime-core/__tests__/apiAsyncComponent.spec.ts create mode 100644 packages/runtime-core/src/apiAsyncComponent.ts diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts new file mode 100644 index 00000000..14cf58d3 --- /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 67387bc5..0720eef6 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 00000000..20ea83a3 --- /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 c57fd8a9..09566121 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 b05acf09..2f3b6543 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'