diff --git a/src/v3/apiAsyncComponent.ts b/src/v3/apiAsyncComponent.ts new file mode 100644 index 0000000000..aca3a7a0ab --- /dev/null +++ b/src/v3/apiAsyncComponent.ts @@ -0,0 +1,117 @@ +import { warn, isFunction, isObject } from 'core/util' + +interface AsyncComponentOptions { + loader: Function + loadingComponent?: any + errorComponent?: any + delay?: number + timeout?: number + suspensible?: boolean + onError?: ( + error: Error, + retry: () => void, + fail: () => void, + attempts: number + ) => any +} + +type AsyncComponentFactory = () => { + component: Promise + loading?: any + error?: any + delay?: number + timeout?: number +} + +/** + * v3-compatible async component API. + * @internal the type is manually declared in /types/v3-define-async-component.d.ts + * because it relies on existing manual types + */ +export function defineAsyncComponent( + source: (() => any) | AsyncComponentOptions +): AsyncComponentFactory { + if (isFunction(source)) { + source = { loader: source } as AsyncComponentOptions + } + + const { + loader, + loadingComponent, + errorComponent, + delay = 200, + timeout, // undefined = never times out + suspensible = false, // in Vue 3 default is true + onError: userOnError + } = source + + if (__DEV__ && suspensible) { + warn( + `The suspensiblbe option for async components is not supported in Vue2. It is ignored.` + ) + } + + let pendingRequest: Promise | null = null + + 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}`) + } + return comp + })) + ) + } + + return () => { + const component = load() + + return { + component, + delay, + timeout, + error: errorComponent, + loading: loadingComponent + } + } +} diff --git a/src/v3/index.ts b/src/v3/index.ts index 75c21ad32f..ec9d59e544 100644 --- a/src/v3/index.ts +++ b/src/v3/index.ts @@ -87,4 +87,6 @@ export function defineComponent(options: any) { return options } +export { defineAsyncComponent } from './apiAsyncComponent' + export * from './apiLifecycle' diff --git a/test/unit/features/v3/apiAsyncComponent.spec.ts b/test/unit/features/v3/apiAsyncComponent.spec.ts new file mode 100644 index 0000000000..2970738f9d --- /dev/null +++ b/test/unit/features/v3/apiAsyncComponent.spec.ts @@ -0,0 +1,241 @@ +import Vue from 'vue' +import { defineAsyncComponent, h, ref, nextTick, defineComponent } from 'v3' +import { Component } from 'types/component' + +const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n)) + +const loadingComponent = defineComponent({ + template: `
loading
` +}) + +const resolvedComponent = defineComponent({ + template: `
resolved
` +}) + +describe('api: defineAsyncComponent', () => { + afterEach(() => { + Vue.config.errorHandler = undefined + }) + + test('simple usage', async () => { + let resolve: (comp: Component) => void + const Foo = defineAsyncComponent( + () => + new Promise(r => { + resolve = r as any + }) + ) + + const toggle = ref(true) + + const vm = new Vue({ + render: () => (toggle.value ? h(Foo) : null) + }).$mount() + + expect(vm.$el.nodeType).toBe(8) + + resolve!(resolvedComponent) + // first time resolve, wait for macro task since there are multiple + // microtasks / .then() calls + await timeout() + expect(vm.$el.innerHTML).toBe('resolved') + + toggle.value = false + await nextTick() + expect(vm.$el.nodeType).toBe(8) + + // already resolved component should update on nextTick + toggle.value = true + await nextTick() + expect(vm.$el.innerHTML).toBe('resolved') + }) + + test('with loading component', async () => { + let resolve: (comp: Component) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise(r => { + resolve = r as any + }), + loadingComponent, + delay: 1 // defaults to 200 + }) + + const toggle = ref(true) + + const vm = new Vue({ + render: () => (toggle.value ? h(Foo) : null) + }).$mount() + + // due to the delay, initial mount should be empty + expect(vm.$el.nodeType).toBe(8) + + // loading show up after delay + await timeout(1) + expect(vm.$el.innerHTML).toBe('loading') + + resolve!(resolvedComponent) + await timeout() + expect(vm.$el.innerHTML).toBe('resolved') + + toggle.value = false + await nextTick() + expect(vm.$el.nodeType).toBe(8) + + // already resolved component should update on nextTick without loading + // state + toggle.value = true + await nextTick() + expect(vm.$el.innerHTML).toBe('resolved') + }) + + test('error with error component', async () => { + let reject: (e: Error) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve, _reject) => { + reject = _reject + }), + errorComponent: { + template: `
errored
` + } + }) + + const toggle = ref(true) + + const vm = new Vue({ + render: () => (toggle.value ? h(Foo) : null) + }).$mount() + + expect(vm.$el.nodeType).toBe(8) + + const err = new Error('errored') + reject!(err) + await timeout() + expect('Failed to resolve async').toHaveBeenWarned() + expect(vm.$el.innerHTML).toBe('errored') + + toggle.value = false + await nextTick() + expect(vm.$el.nodeType).toBe(8) + }) + + test('retry (success)', async () => { + let loaderCallCount = 0 + let resolve: (comp: Component) => void + let reject: (e: Error) => void + + const Foo = defineAsyncComponent({ + 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 vm = new Vue({ + render: () => h(Foo) + }).$mount() + + expect(vm.$el.nodeType).toBe(8) + expect(loaderCallCount).toBe(1) + + const err = new Error('foo') + reject!(err) + await timeout() + expect(loaderCallCount).toBe(2) + expect(vm.$el.nodeType).toBe(8) + + // should render this time + resolve!(resolvedComponent) + await timeout() + expect(vm.$el.innerHTML).toBe('resolved') + }) + + test('retry (skipped)', async () => { + let loaderCallCount = 0 + let reject: (e: Error) => void + + const Foo = defineAsyncComponent({ + loader: () => { + loaderCallCount++ + return new Promise((_resolve, _reject) => { + reject = _reject + }) + }, + onError(error, retry, fail) { + if (error.message.match(/bar/)) { + retry() + } else { + fail() + } + } + }) + + const vm = new Vue({ + render: () => h(Foo) + }).$mount() + + expect(vm.$el.nodeType).toBe(8) + expect(loaderCallCount).toBe(1) + + const err = new Error('foo') + reject!(err) + await timeout() + // should fail because retryWhen returns false + expect(loaderCallCount).toBe(1) + expect(vm.$el.nodeType).toBe(8) + expect('Failed to resolve async').toHaveBeenWarned() + }) + + test('retry (fail w/ max retry attempts)', async () => { + let loaderCallCount = 0 + let reject: (e: Error) => void + + const Foo = defineAsyncComponent({ + 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 vm = new Vue({ + render: () => h(Foo) + }).$mount() + + expect(vm.$el.nodeType).toBe(8) + expect(loaderCallCount).toBe(1) + + // first retry + const err = new Error('foo') + reject!(err) + await timeout() + expect(loaderCallCount).toBe(2) + expect(vm.$el.nodeType).toBe(8) + + // 2nd retry, should fail due to reaching maxRetries + reject!(err) + await timeout() + expect(loaderCallCount).toBe(2) + expect(vm.$el.nodeType).toBe(8) + expect('Failed to resolve async').toHaveBeenWarned() + }) +}) diff --git a/types/test/v3/define-async-component-test.tsx b/types/test/v3/define-async-component-test.tsx new file mode 100644 index 0000000000..f4fbe4ba45 --- /dev/null +++ b/types/test/v3/define-async-component-test.tsx @@ -0,0 +1,19 @@ +import { defineAsyncComponent } from '../../v3-define-async-component' +import { defineComponent } from '../../v3-define-component' + +defineAsyncComponent(() => Promise.resolve({})) + +// @ts-expect-error +defineAsyncComponent({}) + +defineAsyncComponent({ + loader: () => Promise.resolve({}), + loadingComponent: defineComponent({}), + errorComponent: defineComponent({}), + delay: 123, + timeout: 3000, + onError(err, retry, fail, attempts) { + retry() + fail() + } +}) diff --git a/types/v3-define-async-component.d.ts b/types/v3-define-async-component.d.ts new file mode 100644 index 0000000000..8648ef6229 --- /dev/null +++ b/types/v3-define-async-component.d.ts @@ -0,0 +1,26 @@ +import { AsyncComponent, Component } from './options' + +export type AsyncComponentResolveResult = T | { default: T } // es modules + +export type AsyncComponentLoader = () => Promise< + AsyncComponentResolveResult +> + +export interface AsyncComponentOptions { + loader: AsyncComponentLoader + loadingComponent?: Component + errorComponent?: Component + delay?: number + timeout?: number + // suspensible?: boolean + onError?: ( + error: Error, + retry: () => void, + fail: () => void, + attempts: number + ) => any +} + +export function defineAsyncComponent( + source: AsyncComponentLoader | AsyncComponentOptions +): AsyncComponent diff --git a/types/v3-define-component.d.ts b/types/v3-define-component.d.ts index d6e73914ff..f2d3ab1684 100644 --- a/types/v3-define-component.d.ts +++ b/types/v3-define-component.d.ts @@ -1,4 +1,3 @@ -import { Component } from '..' import { ComponentPropsOptions, ExtractDefaultPropTypes,