Skip to content

Commit 9d12106

Browse files
committed
feat: defineAsyncComponent
close #12608
1 parent 26ff4bc commit 9d12106

6 files changed

+405
-1
lines changed

src/v3/apiAsyncComponent.ts

+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { warn, isFunction, isObject } from 'core/util'
2+
3+
interface AsyncComponentOptions {
4+
loader: Function
5+
loadingComponent?: any
6+
errorComponent?: any
7+
delay?: number
8+
timeout?: number
9+
suspensible?: boolean
10+
onError?: (
11+
error: Error,
12+
retry: () => void,
13+
fail: () => void,
14+
attempts: number
15+
) => any
16+
}
17+
18+
type AsyncComponentFactory = () => {
19+
component: Promise<any>
20+
loading?: any
21+
error?: any
22+
delay?: number
23+
timeout?: number
24+
}
25+
26+
/**
27+
* v3-compatible async component API.
28+
* @internal the type is manually declared in <root>/types/v3-define-async-component.d.ts
29+
* because it relies on existing manual types
30+
*/
31+
export function defineAsyncComponent(
32+
source: (() => any) | AsyncComponentOptions
33+
): AsyncComponentFactory {
34+
if (isFunction(source)) {
35+
source = { loader: source } as AsyncComponentOptions
36+
}
37+
38+
const {
39+
loader,
40+
loadingComponent,
41+
errorComponent,
42+
delay = 200,
43+
timeout, // undefined = never times out
44+
suspensible = false, // in Vue 3 default is true
45+
onError: userOnError
46+
} = source
47+
48+
if (__DEV__ && suspensible) {
49+
warn(
50+
`The suspensiblbe option for async components is not supported in Vue2. It is ignored.`
51+
)
52+
}
53+
54+
let pendingRequest: Promise<any> | null = null
55+
56+
let retries = 0
57+
const retry = () => {
58+
retries++
59+
pendingRequest = null
60+
return load()
61+
}
62+
63+
const load = (): Promise<any> => {
64+
let thisRequest: Promise<any>
65+
return (
66+
pendingRequest ||
67+
(thisRequest = pendingRequest =
68+
loader()
69+
.catch(err => {
70+
err = err instanceof Error ? err : new Error(String(err))
71+
if (userOnError) {
72+
return new Promise((resolve, reject) => {
73+
const userRetry = () => resolve(retry())
74+
const userFail = () => reject(err)
75+
userOnError(err, userRetry, userFail, retries + 1)
76+
})
77+
} else {
78+
throw err
79+
}
80+
})
81+
.then((comp: any) => {
82+
if (thisRequest !== pendingRequest && pendingRequest) {
83+
return pendingRequest
84+
}
85+
if (__DEV__ && !comp) {
86+
warn(
87+
`Async component loader resolved to undefined. ` +
88+
`If you are using retry(), make sure to return its return value.`
89+
)
90+
}
91+
// interop module default
92+
if (
93+
comp &&
94+
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
95+
) {
96+
comp = comp.default
97+
}
98+
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
99+
throw new Error(`Invalid async component load result: ${comp}`)
100+
}
101+
return comp
102+
}))
103+
)
104+
}
105+
106+
return () => {
107+
const component = load()
108+
109+
return {
110+
component,
111+
delay,
112+
timeout,
113+
error: errorComponent,
114+
loading: loadingComponent
115+
}
116+
}
117+
}

src/v3/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,6 @@ export function defineComponent(options: any) {
8787
return options
8888
}
8989

90+
export { defineAsyncComponent } from './apiAsyncComponent'
91+
9092
export * from './apiLifecycle'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import Vue from 'vue'
2+
import { defineAsyncComponent, h, ref, nextTick, defineComponent } from 'v3'
3+
import { Component } from 'types/component'
4+
5+
const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
6+
7+
const loadingComponent = defineComponent({
8+
template: `<div>loading</div>`
9+
})
10+
11+
const resolvedComponent = defineComponent({
12+
template: `<div>resolved</div>`
13+
})
14+
15+
describe('api: defineAsyncComponent', () => {
16+
afterEach(() => {
17+
Vue.config.errorHandler = undefined
18+
})
19+
20+
test('simple usage', async () => {
21+
let resolve: (comp: Component) => void
22+
const Foo = defineAsyncComponent(
23+
() =>
24+
new Promise(r => {
25+
resolve = r as any
26+
})
27+
)
28+
29+
const toggle = ref(true)
30+
31+
const vm = new Vue({
32+
render: () => (toggle.value ? h(Foo) : null)
33+
}).$mount()
34+
35+
expect(vm.$el.nodeType).toBe(8)
36+
37+
resolve!(resolvedComponent)
38+
// first time resolve, wait for macro task since there are multiple
39+
// microtasks / .then() calls
40+
await timeout()
41+
expect(vm.$el.innerHTML).toBe('resolved')
42+
43+
toggle.value = false
44+
await nextTick()
45+
expect(vm.$el.nodeType).toBe(8)
46+
47+
// already resolved component should update on nextTick
48+
toggle.value = true
49+
await nextTick()
50+
expect(vm.$el.innerHTML).toBe('resolved')
51+
})
52+
53+
test('with loading component', async () => {
54+
let resolve: (comp: Component) => void
55+
const Foo = defineAsyncComponent({
56+
loader: () =>
57+
new Promise(r => {
58+
resolve = r as any
59+
}),
60+
loadingComponent,
61+
delay: 1 // defaults to 200
62+
})
63+
64+
const toggle = ref(true)
65+
66+
const vm = new Vue({
67+
render: () => (toggle.value ? h(Foo) : null)
68+
}).$mount()
69+
70+
// due to the delay, initial mount should be empty
71+
expect(vm.$el.nodeType).toBe(8)
72+
73+
// loading show up after delay
74+
await timeout(1)
75+
expect(vm.$el.innerHTML).toBe('loading')
76+
77+
resolve!(resolvedComponent)
78+
await timeout()
79+
expect(vm.$el.innerHTML).toBe('resolved')
80+
81+
toggle.value = false
82+
await nextTick()
83+
expect(vm.$el.nodeType).toBe(8)
84+
85+
// already resolved component should update on nextTick without loading
86+
// state
87+
toggle.value = true
88+
await nextTick()
89+
expect(vm.$el.innerHTML).toBe('resolved')
90+
})
91+
92+
test('error with error component', async () => {
93+
let reject: (e: Error) => void
94+
const Foo = defineAsyncComponent({
95+
loader: () =>
96+
new Promise((_resolve, _reject) => {
97+
reject = _reject
98+
}),
99+
errorComponent: {
100+
template: `<div>errored</div>`
101+
}
102+
})
103+
104+
const toggle = ref(true)
105+
106+
const vm = new Vue({
107+
render: () => (toggle.value ? h(Foo) : null)
108+
}).$mount()
109+
110+
expect(vm.$el.nodeType).toBe(8)
111+
112+
const err = new Error('errored')
113+
reject!(err)
114+
await timeout()
115+
expect('Failed to resolve async').toHaveBeenWarned()
116+
expect(vm.$el.innerHTML).toBe('errored')
117+
118+
toggle.value = false
119+
await nextTick()
120+
expect(vm.$el.nodeType).toBe(8)
121+
})
122+
123+
test('retry (success)', async () => {
124+
let loaderCallCount = 0
125+
let resolve: (comp: Component) => void
126+
let reject: (e: Error) => void
127+
128+
const Foo = defineAsyncComponent({
129+
loader: () => {
130+
loaderCallCount++
131+
return new Promise((_resolve, _reject) => {
132+
resolve = _resolve as any
133+
reject = _reject
134+
})
135+
},
136+
onError(error, retry, fail) {
137+
if (error.message.match(/foo/)) {
138+
retry()
139+
} else {
140+
fail()
141+
}
142+
}
143+
})
144+
145+
const vm = new Vue({
146+
render: () => h(Foo)
147+
}).$mount()
148+
149+
expect(vm.$el.nodeType).toBe(8)
150+
expect(loaderCallCount).toBe(1)
151+
152+
const err = new Error('foo')
153+
reject!(err)
154+
await timeout()
155+
expect(loaderCallCount).toBe(2)
156+
expect(vm.$el.nodeType).toBe(8)
157+
158+
// should render this time
159+
resolve!(resolvedComponent)
160+
await timeout()
161+
expect(vm.$el.innerHTML).toBe('resolved')
162+
})
163+
164+
test('retry (skipped)', async () => {
165+
let loaderCallCount = 0
166+
let reject: (e: Error) => void
167+
168+
const Foo = defineAsyncComponent({
169+
loader: () => {
170+
loaderCallCount++
171+
return new Promise((_resolve, _reject) => {
172+
reject = _reject
173+
})
174+
},
175+
onError(error, retry, fail) {
176+
if (error.message.match(/bar/)) {
177+
retry()
178+
} else {
179+
fail()
180+
}
181+
}
182+
})
183+
184+
const vm = new Vue({
185+
render: () => h(Foo)
186+
}).$mount()
187+
188+
expect(vm.$el.nodeType).toBe(8)
189+
expect(loaderCallCount).toBe(1)
190+
191+
const err = new Error('foo')
192+
reject!(err)
193+
await timeout()
194+
// should fail because retryWhen returns false
195+
expect(loaderCallCount).toBe(1)
196+
expect(vm.$el.nodeType).toBe(8)
197+
expect('Failed to resolve async').toHaveBeenWarned()
198+
})
199+
200+
test('retry (fail w/ max retry attempts)', async () => {
201+
let loaderCallCount = 0
202+
let reject: (e: Error) => void
203+
204+
const Foo = defineAsyncComponent({
205+
loader: () => {
206+
loaderCallCount++
207+
return new Promise((_resolve, _reject) => {
208+
reject = _reject
209+
})
210+
},
211+
onError(error, retry, fail, attempts) {
212+
if (error.message.match(/foo/) && attempts <= 1) {
213+
retry()
214+
} else {
215+
fail()
216+
}
217+
}
218+
})
219+
220+
const vm = new Vue({
221+
render: () => h(Foo)
222+
}).$mount()
223+
224+
expect(vm.$el.nodeType).toBe(8)
225+
expect(loaderCallCount).toBe(1)
226+
227+
// first retry
228+
const err = new Error('foo')
229+
reject!(err)
230+
await timeout()
231+
expect(loaderCallCount).toBe(2)
232+
expect(vm.$el.nodeType).toBe(8)
233+
234+
// 2nd retry, should fail due to reaching maxRetries
235+
reject!(err)
236+
await timeout()
237+
expect(loaderCallCount).toBe(2)
238+
expect(vm.$el.nodeType).toBe(8)
239+
expect('Failed to resolve async').toHaveBeenWarned()
240+
})
241+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineAsyncComponent } from '../../v3-define-async-component'
2+
import { defineComponent } from '../../v3-define-component'
3+
4+
defineAsyncComponent(() => Promise.resolve({}))
5+
6+
// @ts-expect-error
7+
defineAsyncComponent({})
8+
9+
defineAsyncComponent({
10+
loader: () => Promise.resolve({}),
11+
loadingComponent: defineComponent({}),
12+
errorComponent: defineComponent({}),
13+
delay: 123,
14+
timeout: 3000,
15+
onError(err, retry, fail, attempts) {
16+
retry()
17+
fail()
18+
}
19+
})

0 commit comments

Comments
 (0)