From c01930e60b4abf481900cdfcc2ba422890c41656 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 26 Mar 2020 20:58:31 -0400 Subject: [PATCH] feat(asyncComponent): retry support BREAKING CHANGE: async component `error` and `loading` options have been renamed to `errorComponent` and `loadingComponent` respectively. --- .../__tests__/apiAsyncComponent.spec.ts | 147 +++++++++++++++--- .../runtime-core/src/apiAsyncComponent.ts | 71 ++++++--- 2 files changed, 176 insertions(+), 42 deletions(-) diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts index 2ddce743c3e..fb989296bc1 100644 --- a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts +++ b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts @@ -23,7 +23,6 @@ describe('api: defineAsyncComponent', () => { const toggle = ref(true) const root = nodeOps.createElement('div') createApp({ - components: { Foo }, render: () => (toggle.value ? h(Foo) : null) }).mount(root) @@ -52,14 +51,13 @@ describe('api: defineAsyncComponent', () => { new Promise(r => { resolve = r as any }), - loading: () => 'loading', + loadingComponent: () => '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) @@ -92,14 +90,13 @@ describe('api: defineAsyncComponent', () => { new Promise(r => { resolve = r as any }), - loading: () => 'loading', + loadingComponent: () => 'loading', delay: 0 }) const toggle = ref(true) const root = nodeOps.createElement('div') createApp({ - components: { Foo }, render: () => (toggle.value ? h(Foo) : null) }).mount(root) @@ -135,7 +132,6 @@ describe('api: defineAsyncComponent', () => { const toggle = ref(true) const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => (toggle.value ? h(Foo) : null) }) @@ -175,13 +171,12 @@ describe('api: defineAsyncComponent', () => { resolve = _resolve as any reject = _reject }), - error: (props: { error: Error }) => props.error.message + errorComponent: (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) }) @@ -220,15 +215,14 @@ describe('api: defineAsyncComponent', () => { resolve = _resolve as any reject = _reject }), - error: (props: { error: Error }) => props.error.message, - loading: () => 'loading', + errorComponent: (props: { error: Error }) => props.error.message, + loadingComponent: () => 'loading', delay: 1 }) const toggle = ref(true) const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => (toggle.value ? h(Foo) : null) }) @@ -280,7 +274,6 @@ describe('api: defineAsyncComponent', () => { const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Foo) }) @@ -310,12 +303,11 @@ describe('api: defineAsyncComponent', () => { resolve = _resolve as any }), timeout: 1, - error: () => 'timed out' + errorComponent: () => 'timed out' }) const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Foo) }) @@ -343,13 +335,12 @@ describe('api: defineAsyncComponent', () => { }), delay: 1, timeout: 16, - error: () => 'timed out', - loading: () => 'loading' + errorComponent: () => 'timed out', + loadingComponent: () => 'loading' }) const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) @@ -376,12 +367,11 @@ describe('api: defineAsyncComponent', () => { }), delay: 1, timeout: 16, - loading: () => 'loading' + loadingComponent: () => 'loading' }) const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) @@ -414,7 +404,6 @@ describe('api: defineAsyncComponent', () => { const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Suspense, null, { default: () => [h(Foo), ' & ', h(Foo)], @@ -442,7 +431,6 @@ describe('api: defineAsyncComponent', () => { const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Suspense, null, { default: () => [h(Foo), ' & ', h(Foo)], @@ -470,7 +458,6 @@ describe('api: defineAsyncComponent', () => { const root = nodeOps.createElement('div') const app = createApp({ - components: { Foo }, render: () => h(Suspense, null, { default: () => [h(Foo), ' & ', h(Foo)], @@ -487,4 +474,120 @@ describe('api: defineAsyncComponent', () => { expect(handler).toHaveBeenCalled() expect(serializeInner(root)).toBe(' & ') }) + + 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 + }) + }, + retryWhen: error => error.message.match(/foo/) + }) + + const root = nodeOps.createElement('div') + const app = createApp({ + render: () => h(Foo) + }) + + const handler = (app.config.errorHandler = jest.fn()) + app.mount(root) + expect(serializeInner(root)).toBe('') + expect(loaderCallCount).toBe(1) + + const err = new Error('foo') + reject!(err) + await timeout() + expect(handler).not.toHaveBeenCalled() + expect(loaderCallCount).toBe(2) + expect(serializeInner(root)).toBe('') + + // should render this time + resolve!(() => 'resolved') + await timeout() + expect(handler).not.toHaveBeenCalled() + expect(serializeInner(root)).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 + }) + }, + retryWhen: error => error.message.match(/bar/) + }) + + const root = nodeOps.createElement('div') + const app = createApp({ + render: () => h(Foo) + }) + + const handler = (app.config.errorHandler = jest.fn()) + app.mount(root) + expect(serializeInner(root)).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]).toBe(err) + expect(loaderCallCount).toBe(1) + expect(serializeInner(root)).toBe('') + }) + + test('retry (fail w/ maxRetries)', async () => { + let loaderCallCount = 0 + let reject: (e: Error) => void + + const Foo = defineAsyncComponent({ + loader: () => { + loaderCallCount++ + return new Promise((_resolve, _reject) => { + reject = _reject + }) + }, + retryWhen: error => error.message.match(/foo/), + maxRetries: 1 + }) + + const root = nodeOps.createElement('div') + const app = createApp({ + render: () => h(Foo) + }) + + const handler = (app.config.errorHandler = jest.fn()) + app.mount(root) + expect(serializeInner(root)).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(serializeInner(root)).toBe('') + + // 2nd retry, should fail due to reaching maxRetries + reject!(err) + await timeout() + expect(handler).toHaveBeenCalled() + expect(handler.mock.calls[0][0]).toBe(err) + expect(loaderCallCount).toBe(2) + expect(serializeInner(root)).toBe('') + }) }) diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 6a6896263ae..62b50a1ecda 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -6,7 +6,7 @@ import { ComponentInternalInstance, isInSSRComponentSetup } from './component' -import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared' +import { isFunction, isObject, EMPTY_OBJ, NO } from '@vue/shared' import { ComponentPublicInstance } from './componentProxy' import { createVNode } from './vnode' import { defineComponent } from './apiDefineComponent' @@ -24,10 +24,12 @@ export type AsyncComponentLoader = () => Promise< export interface AsyncComponentOptions { loader: AsyncComponentLoader - loading?: PublicAPIComponent - error?: PublicAPIComponent + loadingComponent?: PublicAPIComponent + errorComponent?: PublicAPIComponent delay?: number timeout?: number + retryWhen?: (error: Error) => any + maxRetries?: number suspensible?: boolean } @@ -39,31 +41,62 @@ export function defineAsyncComponent< } const { - suspensible = true, loader, - loading: loadingComponent, - error: errorComponent, + loadingComponent: loadingComponent, + errorComponent: errorComponent, delay = 200, - timeout // undefined = never times out + timeout, // undefined = never times out + retryWhen = NO, + maxRetries = 3, + suspensible = true } = source let pendingRequest: Promise | null = null let resolvedComp: Component | undefined + let retries = 0 + const retry = (error?: unknown) => { + retries++ + pendingRequest = null + return load() + } + const load = (): Promise => { + let thisRequest: 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 - })) + (thisRequest = pendingRequest = loader() + .catch(err => { + err = err instanceof Error ? err : new Error(String(err)) + if (retryWhen(err) && retries < maxRetries) { + return retry(err) + } 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}`) + } + resolvedComp = comp + return comp + })) ) } @@ -101,8 +134,6 @@ export function defineAsyncComponent< }) } - // TODO hydration - const loaded = ref(false) const error = ref() const delayed = ref(!!delay)