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 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('<!---->')
})
})

View File

@ -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<T = any> = () => Promise<
export interface AsyncComponentOptions<T = any> {
loader: AsyncComponentLoader<T>
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<Component> | null = null
let resolvedComp: Component | undefined
let retries = 0
const retry = (error?: unknown) => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<Component> => {
let thisRequest: Promise<Component>
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)