Skip to content

Commit

Permalink
feat: defineAsyncComponent
Browse files Browse the repository at this point in the history
close #12608
  • Loading branch information
yyx990803 committed Jul 8, 2022
1 parent 26ff4bc commit 9d12106
Show file tree
Hide file tree
Showing 6 changed files with 405 additions and 1 deletion.
117 changes: 117 additions & 0 deletions src/v3/apiAsyncComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { warn, isFunction, isObject } from 'core/util'

interface AsyncComponentOptions {
loader: Function
loadingComponent?: any
errorComponent?: any
delay?: number
timeout?: number
suspensible?: boolean
onError?: (
error: Error,
retry: () => void,
fail: () => void,
attempts: number
) => any
}

type AsyncComponentFactory = () => {
component: Promise<any>
loading?: any
error?: any
delay?: number
timeout?: number
}

/**
* v3-compatible async component API.
* @internal the type is manually declared in <root>/types/v3-define-async-component.d.ts
* because it relies on existing manual types
*/
export function defineAsyncComponent(
source: (() => any) | AsyncComponentOptions
): AsyncComponentFactory {
if (isFunction(source)) {
source = { loader: source } as AsyncComponentOptions
}

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<any> | null = null

let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}

const load = (): Promise<any> => {
let thisRequest: Promise<any>
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,
delay,
timeout,
error: errorComponent,
loading: loadingComponent
}
}
}
2 changes: 2 additions & 0 deletions src/v3/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,6 @@ export function defineComponent(options: any) {
return options
}

export { defineAsyncComponent } from './apiAsyncComponent'

export * from './apiLifecycle'
241 changes: 241 additions & 0 deletions test/unit/features/v3/apiAsyncComponent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import Vue from 'vue'
import { defineAsyncComponent, h, ref, nextTick, defineComponent } from 'v3'
import { Component } from 'types/component'

const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))

const loadingComponent = defineComponent({
template: `<div>loading</div>`
})

const resolvedComponent = defineComponent({
template: `<div>resolved</div>`
})

describe('api: defineAsyncComponent', () => {
afterEach(() => {
Vue.config.errorHandler = undefined
})

test('simple usage', async () => {
let resolve: (comp: Component) => void
const Foo = defineAsyncComponent(
() =>
new Promise(r => {
resolve = r as any
})
)

const toggle = ref(true)

const vm = new Vue({
render: () => (toggle.value ? h(Foo) : null)
}).$mount()

expect(vm.$el.nodeType).toBe(8)

resolve!(resolvedComponent)
// first time resolve, wait for macro task since there are multiple
// microtasks / .then() calls
await timeout()
expect(vm.$el.innerHTML).toBe('resolved')

toggle.value = false
await nextTick()
expect(vm.$el.nodeType).toBe(8)

// already resolved component should update on nextTick
toggle.value = true
await nextTick()
expect(vm.$el.innerHTML).toBe('resolved')
})

test('with loading component', async () => {
let resolve: (comp: Component) => void
const Foo = defineAsyncComponent({
loader: () =>
new Promise(r => {
resolve = r as any
}),
loadingComponent,
delay: 1 // defaults to 200
})

const toggle = ref(true)

const vm = new Vue({
render: () => (toggle.value ? h(Foo) : null)
}).$mount()

// due to the delay, initial mount should be empty
expect(vm.$el.nodeType).toBe(8)

// loading show up after delay
await timeout(1)
expect(vm.$el.innerHTML).toBe('loading')

resolve!(resolvedComponent)
await timeout()
expect(vm.$el.innerHTML).toBe('resolved')

toggle.value = false
await nextTick()
expect(vm.$el.nodeType).toBe(8)

// already resolved component should update on nextTick without loading
// state
toggle.value = true
await nextTick()
expect(vm.$el.innerHTML).toBe('resolved')
})

test('error with error component', async () => {
let reject: (e: Error) => void
const Foo = defineAsyncComponent({
loader: () =>
new Promise((_resolve, _reject) => {
reject = _reject
}),
errorComponent: {
template: `<div>errored</div>`
}
})

const toggle = ref(true)

const vm = new Vue({
render: () => (toggle.value ? h(Foo) : null)
}).$mount()

expect(vm.$el.nodeType).toBe(8)

const err = new Error('errored')
reject!(err)
await timeout()
expect('Failed to resolve async').toHaveBeenWarned()
expect(vm.$el.innerHTML).toBe('errored')

toggle.value = false
await nextTick()
expect(vm.$el.nodeType).toBe(8)
})

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
})
},
onError(error, retry, fail) {
if (error.message.match(/foo/)) {
retry()
} else {
fail()
}
}
})

const vm = new Vue({
render: () => h(Foo)
}).$mount()

expect(vm.$el.nodeType).toBe(8)
expect(loaderCallCount).toBe(1)

const err = new Error('foo')
reject!(err)
await timeout()
expect(loaderCallCount).toBe(2)
expect(vm.$el.nodeType).toBe(8)

// should render this time
resolve!(resolvedComponent)
await timeout()
expect(vm.$el.innerHTML).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 vm = new Vue({
render: () => h(Foo)
}).$mount()

expect(vm.$el.nodeType).toBe(8)
expect(loaderCallCount).toBe(1)

const err = new Error('foo')
reject!(err)
await timeout()
// should fail because retryWhen returns false
expect(loaderCallCount).toBe(1)
expect(vm.$el.nodeType).toBe(8)
expect('Failed to resolve async').toHaveBeenWarned()
})

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 vm = new Vue({
render: () => h(Foo)
}).$mount()

expect(vm.$el.nodeType).toBe(8)
expect(loaderCallCount).toBe(1)

// first retry
const err = new Error('foo')
reject!(err)
await timeout()
expect(loaderCallCount).toBe(2)
expect(vm.$el.nodeType).toBe(8)

// 2nd retry, should fail due to reaching maxRetries
reject!(err)
await timeout()
expect(loaderCallCount).toBe(2)
expect(vm.$el.nodeType).toBe(8)
expect('Failed to resolve async').toHaveBeenWarned()
})
})
19 changes: 19 additions & 0 deletions types/test/v3/define-async-component-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineAsyncComponent } from '../../v3-define-async-component'
import { defineComponent } from '../../v3-define-component'

defineAsyncComponent(() => Promise.resolve({}))

// @ts-expect-error
defineAsyncComponent({})

defineAsyncComponent({
loader: () => Promise.resolve({}),
loadingComponent: defineComponent({}),
errorComponent: defineComponent({}),
delay: 123,
timeout: 3000,
onError(err, retry, fail, attempts) {
retry()
fail()
}
})
Loading

0 comments on commit 9d12106

Please sign in to comment.