From 8409f48216f82497984041513c25452b8fe15cad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= <2pi_r2@gmx.de> Date: Thu, 18 Feb 2021 04:29:54 +0100 Subject: [PATCH] feat: add defineAsyncComponent API (#644) --- src/component/defineAsyncComponent.ts | 126 ++++ src/component/index.ts | 1 + test-dts/defineAsyncComponent.test-d.ts | 74 ++ .../v3/runtime-core/apiAsyncComponent.spec.ts | 655 ++++++++++++++++++ 4 files changed, 856 insertions(+) create mode 100644 src/component/defineAsyncComponent.ts create mode 100644 test-dts/defineAsyncComponent.test-d.ts create mode 100644 test/v3/runtime-core/apiAsyncComponent.spec.ts diff --git a/src/component/defineAsyncComponent.ts b/src/component/defineAsyncComponent.ts new file mode 100644 index 00000000..9662a2c2 --- /dev/null +++ b/src/component/defineAsyncComponent.ts @@ -0,0 +1,126 @@ +import { isFunction, isObject, warn } from '../utils' +import { VueProxy } from './componentProxy' +import { AsyncComponent } from 'vue' + +import { + ComponentOptionsWithoutProps, + ComponentOptionsWithArrayProps, + ComponentOptionsWithProps, +} from './componentOptions' + +type ComponentOptions = + | ComponentOptionsWithoutProps + | ComponentOptionsWithArrayProps + | ComponentOptionsWithProps + +type Component = VueProxy + +type ComponentOrComponentOptions = ComponentOptions | Component + +export type AsyncComponentResolveResult = + | T + | { default: T } // es modules + +export type AsyncComponentLoader = () => Promise + +export interface AsyncComponentOptions { + loader: AsyncComponentLoader + loadingComponent?: ComponentOrComponentOptions + errorComponent?: ComponentOrComponentOptions + delay?: number + timeout?: number + suspensible?: boolean + onError?: ( + error: Error, + retry: () => void, + fail: () => void, + attempts: number + ) => any +} + +export function defineAsyncComponent( + source: AsyncComponentLoader | AsyncComponentOptions +): AsyncComponent { + if (isFunction(source)) { + source = { loader: source } + } + + 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: component as any, // there is a type missmatch between vue2 type and the docs + delay, + timeout, + error: errorComponent, + loading: loadingComponent, + } + } +} diff --git a/src/component/index.ts b/src/component/index.ts index 639069ef..86a79ee3 100644 --- a/src/component/index.ts +++ b/src/component/index.ts @@ -1,4 +1,5 @@ export { defineComponent } from './defineComponent' +export { defineAsyncComponent } from './defineAsyncComponent' export { SetupFunction, SetupContext } from './componentOptions' export { ComponentInstance, ComponentRenderProxy } from './componentProxy' export { Data } from './common' diff --git a/test-dts/defineAsyncComponent.test-d.ts b/test-dts/defineAsyncComponent.test-d.ts new file mode 100644 index 00000000..cceba815 --- /dev/null +++ b/test-dts/defineAsyncComponent.test-d.ts @@ -0,0 +1,74 @@ +import { AsyncComponent } from 'vue' +import { defineAsyncComponent, defineComponent, expectType } from './index' + +function asyncComponent1() { + return Promise.resolve().then(() => { + return defineComponent({}) + }) +} + +function asyncComponent2() { + return Promise.resolve().then(() => { + return { + template: 'ASYNC', + } + }) +} + +const syncComponent1 = defineComponent({ + template: '', +}) + +const syncComponent2 = { + template: '', +} + +defineAsyncComponent(asyncComponent1) +defineAsyncComponent(asyncComponent2) + +defineAsyncComponent({ + loader: asyncComponent1, + delay: 200, + timeout: 3000, + errorComponent: syncComponent1, + loadingComponent: syncComponent1, +}) + +defineAsyncComponent({ + loader: asyncComponent2, + delay: 200, + timeout: 3000, + errorComponent: syncComponent2, + loadingComponent: syncComponent2, +}) + +defineAsyncComponent( + () => + new Promise((resolve, reject) => { + resolve(syncComponent1) + }) +) + +defineAsyncComponent( + () => + new Promise((resolve, reject) => { + resolve(syncComponent2) + }) +) + +const component = defineAsyncComponent({ + loader: asyncComponent1, + loadingComponent: defineComponent({}), + errorComponent: defineComponent({}), + delay: 200, + timeout: 3000, + suspensible: false, + onError(error, retry, fail, attempts) { + expectType<() => void>(retry) + expectType<() => void>(fail) + expectType(attempts) + expectType(error) + }, +}) + +expectType(component) diff --git a/test/v3/runtime-core/apiAsyncComponent.spec.ts b/test/v3/runtime-core/apiAsyncComponent.spec.ts new file mode 100644 index 00000000..97f2941d --- /dev/null +++ b/test/v3/runtime-core/apiAsyncComponent.spec.ts @@ -0,0 +1,655 @@ +import { + h, + createApp, + defineAsyncComponent, + nextTick, + ref, + defineComponent, +} from '../../../src' + +const resolveComponent = { + render() { + return h('p', 'resolved') + }, +} + +const loadingComponent = { + render() { + return h('p', 'loading') + }, +} + +const errorComponent = defineComponent({ + render() { + return h('p', 'errored out') + }, +}) + +const timeout = (n: number = 0) => new Promise((r) => setTimeout(r, n)) + +describe('api: defineAsyncComponent', () => { + test('simple usage', async () => { + const Foo = defineAsyncComponent(() => + Promise.resolve().then(() => resolveComponent) + ) + + const toggle = ref(true) + + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + + const vm = app.mount() + + expect(vm.$el.textContent).toBe('') + + // first time resolve, wait for macro task since there are multiple + // microtasks / .then() calls + await timeout() + expect(vm.$el.textContent).toBe('resolved') + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // already resolved component should update on nextTick + toggle.value = true + await nextTick() + expect(vm.$el.textContent).toBe('resolved') + }) + + test('with loading component', async () => { + let resolve: (cmp: any) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((res) => { + resolve = res + }), + loadingComponent, + delay: 1, // defaults to 200 + }) + + const toggle = ref(true) + + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + + const vm = app.mount() + + // due to the delay, initial mount should be empty + expect(vm.$el.textContent).toBe('') + + // loading show up after delay + await timeout(2) + expect(vm.$el.textContent).toBe('loading') + + resolve!(resolveComponent) + await timeout() + expect(vm.$el.textContent).toBe('resolved') + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // already resolved component should update on nextTick without loading + // state + toggle.value = true + await nextTick() + expect(vm.$el.textContent).toBe('resolved') + }) + + test('with loading component + explicit delay (0)', async () => { + let resolve: (comp: any) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((r) => { + resolve = r as any + }), + loadingComponent, + delay: 0, + }) + + const toggle = ref(true) + + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + const vm = app.mount() + + // with delay: 0, should show loading immediately + expect(vm.$el.textContent).toBe('loading') + + resolve!(resolveComponent) + await timeout() + expect(vm.$el.textContent).toBe('resolved') + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // already resolved component should update on nextTick without loading + // state + toggle.value = true + await nextTick() + expect(vm.$el.textContent).toBe('resolved') + }) + + test('error without error component', async () => { + // let resolve: (comp: any) => void + let reject: (e: Error) => void + const Foo = defineAsyncComponent( + () => + new Promise((_resolve, _reject) => { + // resolve = _resolve as any + reject = _reject + }) + ) + + const toggle = ref(true) + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + + const err = new Error('foo') + reject!(err) + await timeout() + expect(handler).toHaveBeenCalled() + expect(handler.mock.calls[0][0]).toContain(err.message) + expect(vm.$el.textContent).toBe('') + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // This retry method doesn't work in Vue2 + + // errored out on previous load, toggle and mock success this time + // toggle.value = true + // await nextTick() + // expect(vm.$el.textContent).toBe('') + + // // should render this time + // resolve!(resolveComponent) + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + }) + + test('error with error component', async () => { + // let resolve: (comp: any) => void + let reject: (e: Error) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve, _reject) => { + // resolve = _resolve as any + reject = _reject + }), + errorComponent, + }) + + const toggle = ref(true) + + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + + const err = new Error('errored out') + reject!(err) + await timeout(1) + expect(handler).toHaveBeenCalled() + expect(vm.$el.textContent).toBe('errored out') + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // This doesn't work in vue2 + // // errored out on previous load, toggle and mock success this time + // toggle.value = true + // await nextTick() + // expect(vm.$el.textContent).toBe('') + + // // should render this time + // resolve!(resolveComponent) + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + }) + + // #2129 + test('error with error component, without global handler', async () => { + // let resolve: (comp: any) => void + let reject: (e: Error) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve, _reject) => { + // resolve = _resolve as any + reject = _reject + }), + errorComponent, + }) + + const toggle = ref(true) + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + + jest.spyOn(global.console, 'error').mockImplementation(() => null) + + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + + const err = new Error('errored out') + reject!(err) + await timeout() + expect(vm.$el.textContent).toBe('errored out') + // TODO: Warning check? + // expect( + // 'Unhandled error during execution of async component loader' + // ).toHaveBeenWarned() + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // this doesn't work in vue2 + // // errored out on previous load, toggle and mock success this time + // toggle.value = true + // await nextTick() + // expect(vm.$el.textContent).toBe('') + + // // should render this time + // resolve!(resolveComponent) + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + }) + + test('error with error + loading components', async () => { + // let resolve: (comp: any) => void + let reject: (e: Error) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve, _reject) => { + // resolve = _resolve as any + reject = _reject + }), + errorComponent, + loadingComponent, + delay: 1, + }) + + const toggle = ref(true) + const app = createApp({ + render: () => (toggle.value ? h(Foo) : null), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + + const vm = app.mount() + + // due to the delay, initial mount should be empty + expect(vm.$el.textContent).toBe('') + + // loading show up after delay + await timeout(1) + expect(vm.$el.textContent).toBe('loading') + + const err = new Error('errored out') + reject!(err) + await timeout() + expect(handler).toHaveBeenCalled() + expect(vm.$el.textContent).toBe('errored out') + + toggle.value = false + await nextTick() + expect(vm.$el.textContent).toBe('') + + // Not in vue2 + // // errored out on previous load, toggle and mock success this time + // toggle.value = true + // await nextTick() + // expect(vm.$el.textContent).toBe('') + + // // loading show up after delay + // await timeout(1) + // expect(vm.$el.textContent).toBe('loading') + + // // should render this time + // resolve!(resolveComponent) + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + }) + + test('timeout without error component', async () => { + let resolve: (comp: any) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve) => { + resolve = _resolve as any + }), + timeout: 1, + }) + + const app = createApp({ + render: () => h(Foo), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + + await timeout(1) + expect(handler).toHaveBeenCalled() + // expect(handler.mock.calls[0][0].message).toContain( + // `Async component timed out after 1ms.` + // ) + expect(vm.$el.textContent).toBe('') + + // if it resolved after timeout, should still work + resolve!(resolveComponent) + await timeout() + expect(vm.$el.textContent).toBe('resolved') + }) + + test('timeout with error component', async () => { + // let resolve: (comp: any) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve) => { + // resolve = _resolve as any + }), + timeout: 1, + errorComponent, + }) + + const app = createApp({ + render: () => h(Foo), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + + await timeout(1) + expect(handler).toHaveBeenCalled() + expect(vm.$el.textContent).toBe('errored out') + + // Not in vue2 + // // if it resolved after timeout, should still work + // resolve!(resolveComponent) + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + }) + + test('timeout with error + loading components', async () => { + // let resolve: (comp: any) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve) => { + // resolve = _resolve as any + }), + delay: 1, + timeout: 16, + errorComponent, + loadingComponent, + }) + + const app = createApp({ + render: () => h(Foo), + }) + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + const vm = app.mount() + + expect(vm.$el.textContent).toBe('') + await timeout(1) + expect(vm.$el.textContent).toBe('loading') + + await timeout(16) + expect(vm.$el.textContent).toBe('errored out') + expect(handler).toHaveBeenCalled() + + // Not in Vue2 + // resolve!(resolveComponent) + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + }) + + test('timeout without error component, but with loading component', async () => { + let resolve: (comp: any) => void + const Foo = defineAsyncComponent({ + loader: () => + new Promise((_resolve) => { + resolve = _resolve as any + }), + delay: 1, + timeout: 16, + loadingComponent, + }) + + const app = createApp({ + render: () => h(Foo), + }) + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + await timeout(1) + expect(vm.$el.textContent).toBe('loading') + + await timeout(16) + expect(handler).toHaveBeenCalled() + // expect(handler.mock.calls[0][0].message).toContain( + // `Async component timed out after 16ms.` + // ) + // should still display loading + expect(vm.$el.textContent).toBe('loading') + + resolve!(resolveComponent) + await timeout() + expect(vm.$el.textContent).toBe('resolved') + }) + + test('retry (success)', async () => { + let loaderCallCount = 0 + let resolve: (comp: any) => 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 app = createApp({ + render: () => h(Foo), + }) + + jest.spyOn(global.console, 'error').mockImplementation(() => null) + + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + expect(loaderCallCount).toBe(1) + + const err = new Error('foo') + reject!(err) + await timeout() + // expect(handler).toHaveBeenCalled() + expect(loaderCallCount).toBe(2) + expect(vm.$el.textContent).toBe('') + + // should render this time + resolve!(resolveComponent) + await timeout() + // expect(handler).not.toHaveBeenCalled() + expect(vm.$el.textContent).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 app = createApp({ + render: () => h(Foo), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + expect(loaderCallCount).toBe(1) + + const err = new Error('foo') + reject!(err) + await timeout() + // should fail because retryWhen returns false + expect(handler).toHaveBeenCalled() + expect(handler.mock.calls[0][0]).toContain(err.message) + expect(loaderCallCount).toBe(1) + expect(vm.$el.textContent).toBe('') + }) + + 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 app = createApp({ + render: () => h(Foo), + }) + + const handler = jest + .spyOn(global.console, 'error') + .mockImplementation(() => null) + const vm = app.mount() + expect(vm.$el.textContent).toBe('') + expect(loaderCallCount).toBe(1) + + // first retry + const err = new Error('foo') + reject!(err) + await timeout() + // expect(handler).not.toHaveBeenCalled() + expect(loaderCallCount).toBe(2) + expect(vm.$el.textContent).toBe('') + + // 2nd retry, should fail due to reaching maxRetries + reject!(err) + await timeout() + // expect(handler).toHaveBeenCalled() + expect(handler.mock.calls[0][0]).toContain(err.message) + expect(loaderCallCount).toBe(2) + expect(vm.$el.textContent).toBe('') + }) + + // test('template ref forwarding', async () => { + // let resolve: (comp: Component) => void + // const Foo = defineAsyncComponent( + // () => + // new Promise((r) => { + // resolve = r as any + // }) + // ) + + // const fooRef = ref() + // const toggle = ref(true) + // const root = nodeOps.createElement('div') + // createApp({ + // render: () => (toggle.value ? h(Foo, { ref: fooRef } as any) : null), + // }).mount(root) + + // expect(vm.$el.textContent).toBe('') + // expect(fooRef.value).toBe(null) + + // resolve!({ + // data() { + // return { + // id: 'foo', + // } + // }, + // render: resolveComponent.render, + // }) + // // first time resolve, wait for macro task since there are multiple + // // microtasks / .then() calls + // await timeout() + // expect(vm.$el.textContent).toBe('resolved') + // expect(fooRef.value.id).toBe('foo') + + // toggle.value = false + // await nextTick() + // expect(vm.$el.textContent).toBe('') + // expect(fooRef.value).toBe(null) + + // // already resolved component should update on nextTick + // toggle.value = true + // await nextTick() + // expect(vm.$el.textContent).toBe('resolved') + // expect(fooRef.value.id).toBe('foo') + // }) +})