Skip to content

Commit

Permalink
feat(asyncComponent): retry support
Browse files Browse the repository at this point in the history
BREAKING CHANGE: async component `error` and `loading` options have been
renamed to `errorComponent` and `loadingComponent` respectively.
  • Loading branch information
yyx990803 committed Mar 27, 2020
1 parent ebc5873 commit c01930e
Show file tree
Hide file tree
Showing 2 changed files with 176 additions and 42 deletions.
147 changes: 125 additions & 22 deletions packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -280,7 +274,6 @@ describe('api: defineAsyncComponent', () => {

const root = nodeOps.createElement('div')
const app = createApp({
components: { Foo },
render: () => h(Foo)
})

Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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())
Expand All @@ -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())
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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)],
Expand Down Expand Up @@ -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)],
Expand All @@ -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('<!---->')
})
})
71 changes: 51 additions & 20 deletions packages/runtime-core/src/apiAsyncComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
}

Expand All @@ -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
}))
)
}

Expand Down Expand Up @@ -101,8 +134,6 @@ export function defineAsyncComponent<
})
}

// TODO hydration

const loaded = ref(false)
const error = ref()
const delayed = ref(!!delay)
Expand Down

0 comments on commit c01930e

Please sign in to comment.