feat(asyncComponent): retry support

BREAKING CHANGE: async component `error` and `loading` options have been
renamed to `errorComponent` and `loadingComponent` respectively.
This commit is contained in:
Evan You 2020-03-26 20:58:31 -04:00
parent ebc587376c
commit c01930e60b
2 changed files with 176 additions and 42 deletions

View File

@ -23,7 +23,6 @@ describe('api: defineAsyncComponent', () => {
const toggle = ref(true) const toggle = ref(true)
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
createApp({ createApp({
components: { Foo },
render: () => (toggle.value ? h(Foo) : null) render: () => (toggle.value ? h(Foo) : null)
}).mount(root) }).mount(root)
@ -52,14 +51,13 @@ describe('api: defineAsyncComponent', () => {
new Promise(r => { new Promise(r => {
resolve = r as any resolve = r as any
}), }),
loading: () => 'loading', loadingComponent: () => 'loading',
delay: 1 // defaults to 200 delay: 1 // defaults to 200
}) })
const toggle = ref(true) const toggle = ref(true)
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
createApp({ createApp({
components: { Foo },
render: () => (toggle.value ? h(Foo) : null) render: () => (toggle.value ? h(Foo) : null)
}).mount(root) }).mount(root)
@ -92,14 +90,13 @@ describe('api: defineAsyncComponent', () => {
new Promise(r => { new Promise(r => {
resolve = r as any resolve = r as any
}), }),
loading: () => 'loading', loadingComponent: () => 'loading',
delay: 0 delay: 0
}) })
const toggle = ref(true) const toggle = ref(true)
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
createApp({ createApp({
components: { Foo },
render: () => (toggle.value ? h(Foo) : null) render: () => (toggle.value ? h(Foo) : null)
}).mount(root) }).mount(root)
@ -135,7 +132,6 @@ describe('api: defineAsyncComponent', () => {
const toggle = ref(true) const toggle = ref(true)
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => (toggle.value ? h(Foo) : null) render: () => (toggle.value ? h(Foo) : null)
}) })
@ -175,13 +171,12 @@ describe('api: defineAsyncComponent', () => {
resolve = _resolve as any resolve = _resolve as any
reject = _reject reject = _reject
}), }),
error: (props: { error: Error }) => props.error.message errorComponent: (props: { error: Error }) => props.error.message
}) })
const toggle = ref(true) const toggle = ref(true)
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => (toggle.value ? h(Foo) : null) render: () => (toggle.value ? h(Foo) : null)
}) })
@ -220,15 +215,14 @@ describe('api: defineAsyncComponent', () => {
resolve = _resolve as any resolve = _resolve as any
reject = _reject reject = _reject
}), }),
error: (props: { error: Error }) => props.error.message, errorComponent: (props: { error: Error }) => props.error.message,
loading: () => 'loading', loadingComponent: () => 'loading',
delay: 1 delay: 1
}) })
const toggle = ref(true) const toggle = ref(true)
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => (toggle.value ? h(Foo) : null) render: () => (toggle.value ? h(Foo) : null)
}) })
@ -280,7 +274,6 @@ describe('api: defineAsyncComponent', () => {
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => h(Foo) render: () => h(Foo)
}) })
@ -310,12 +303,11 @@ describe('api: defineAsyncComponent', () => {
resolve = _resolve as any resolve = _resolve as any
}), }),
timeout: 1, timeout: 1,
error: () => 'timed out' errorComponent: () => 'timed out'
}) })
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => h(Foo) render: () => h(Foo)
}) })
@ -343,13 +335,12 @@ describe('api: defineAsyncComponent', () => {
}), }),
delay: 1, delay: 1,
timeout: 16, timeout: 16,
error: () => 'timed out', errorComponent: () => 'timed out',
loading: () => 'loading' loadingComponent: () => 'loading'
}) })
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => h(Foo) render: () => h(Foo)
}) })
const handler = (app.config.errorHandler = jest.fn()) const handler = (app.config.errorHandler = jest.fn())
@ -376,12 +367,11 @@ describe('api: defineAsyncComponent', () => {
}), }),
delay: 1, delay: 1,
timeout: 16, timeout: 16,
loading: () => 'loading' loadingComponent: () => 'loading'
}) })
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => h(Foo) render: () => h(Foo)
}) })
const handler = (app.config.errorHandler = jest.fn()) const handler = (app.config.errorHandler = jest.fn())
@ -414,7 +404,6 @@ describe('api: defineAsyncComponent', () => {
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => render: () =>
h(Suspense, null, { h(Suspense, null, {
default: () => [h(Foo), ' & ', h(Foo)], default: () => [h(Foo), ' & ', h(Foo)],
@ -442,7 +431,6 @@ describe('api: defineAsyncComponent', () => {
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => render: () =>
h(Suspense, null, { h(Suspense, null, {
default: () => [h(Foo), ' & ', h(Foo)], default: () => [h(Foo), ' & ', h(Foo)],
@ -470,7 +458,6 @@ describe('api: defineAsyncComponent', () => {
const root = nodeOps.createElement('div') const root = nodeOps.createElement('div')
const app = createApp({ const app = createApp({
components: { Foo },
render: () => render: () =>
h(Suspense, null, { h(Suspense, null, {
default: () => [h(Foo), ' & ', h(Foo)], default: () => [h(Foo), ' & ', h(Foo)],
@ -487,4 +474,120 @@ describe('api: defineAsyncComponent', () => {
expect(handler).toHaveBeenCalled() expect(handler).toHaveBeenCalled()
expect(serializeInner(root)).toBe('<!----> & <!---->') 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('<!---->')
})
}) })

View File

@ -6,7 +6,7 @@ import {
ComponentInternalInstance, ComponentInternalInstance,
isInSSRComponentSetup isInSSRComponentSetup
} from './component' } from './component'
import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared' import { isFunction, isObject, EMPTY_OBJ, NO } from '@vue/shared'
import { ComponentPublicInstance } from './componentProxy' import { ComponentPublicInstance } from './componentProxy'
import { createVNode } from './vnode' import { createVNode } from './vnode'
import { defineComponent } from './apiDefineComponent' import { defineComponent } from './apiDefineComponent'
@ -24,10 +24,12 @@ export type AsyncComponentLoader<T = any> = () => Promise<
export interface AsyncComponentOptions<T = any> { export interface AsyncComponentOptions<T = any> {
loader: AsyncComponentLoader<T> loader: AsyncComponentLoader<T>
loading?: PublicAPIComponent loadingComponent?: PublicAPIComponent
error?: PublicAPIComponent errorComponent?: PublicAPIComponent
delay?: number delay?: number
timeout?: number timeout?: number
retryWhen?: (error: Error) => any
maxRetries?: number
suspensible?: boolean suspensible?: boolean
} }
@ -39,31 +41,62 @@ export function defineAsyncComponent<
} }
const { const {
suspensible = true,
loader, loader,
loading: loadingComponent, loadingComponent: loadingComponent,
error: errorComponent, errorComponent: errorComponent,
delay = 200, delay = 200,
timeout // undefined = never times out timeout, // undefined = never times out
retryWhen = NO,
maxRetries = 3,
suspensible = true
} = source } = source
let pendingRequest: Promise<Component> | null = null let pendingRequest: Promise<Component> | null = null
let resolvedComp: Component | undefined let resolvedComp: Component | undefined
let retries = 0
const retry = (error?: unknown) => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<Component> => { const load = (): Promise<Component> => {
let thisRequest: Promise<Component>
return ( return (
pendingRequest || pendingRequest ||
(pendingRequest = loader().then((comp: any) => { (thisRequest = pendingRequest = loader()
// interop module default .catch(err => {
if (comp.__esModule || comp[Symbol.toStringTag] === 'Module') { err = err instanceof Error ? err : new Error(String(err))
comp = comp.default if (retryWhen(err) && retries < maxRetries) {
} return retry(err)
if (__DEV__ && !isObject(comp) && !isFunction(comp)) { } else {
warn(`Invalid async component load result: `, comp) throw err
} }
resolvedComp = comp })
return comp .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 loaded = ref(false)
const error = ref() const error = ref()
const delayed = ref(!!delay) const delayed = ref(!!delay)