Skip to content

Commit c01930e

Browse files
committed
feat(asyncComponent): retry support
BREAKING CHANGE: async component `error` and `loading` options have been renamed to `errorComponent` and `loadingComponent` respectively.
1 parent ebc5873 commit c01930e

File tree

2 files changed

+176
-42
lines changed

2 files changed

+176
-42
lines changed

packages/runtime-core/__tests__/apiAsyncComponent.spec.ts

+125-22
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ describe('api: defineAsyncComponent', () => {
2323
const toggle = ref(true)
2424
const root = nodeOps.createElement('div')
2525
createApp({
26-
components: { Foo },
2726
render: () => (toggle.value ? h(Foo) : null)
2827
}).mount(root)
2928

@@ -52,14 +51,13 @@ describe('api: defineAsyncComponent', () => {
5251
new Promise(r => {
5352
resolve = r as any
5453
}),
55-
loading: () => 'loading',
54+
loadingComponent: () => 'loading',
5655
delay: 1 // defaults to 200
5756
})
5857

5958
const toggle = ref(true)
6059
const root = nodeOps.createElement('div')
6160
createApp({
62-
components: { Foo },
6361
render: () => (toggle.value ? h(Foo) : null)
6462
}).mount(root)
6563

@@ -92,14 +90,13 @@ describe('api: defineAsyncComponent', () => {
9290
new Promise(r => {
9391
resolve = r as any
9492
}),
95-
loading: () => 'loading',
93+
loadingComponent: () => 'loading',
9694
delay: 0
9795
})
9896

9997
const toggle = ref(true)
10098
const root = nodeOps.createElement('div')
10199
createApp({
102-
components: { Foo },
103100
render: () => (toggle.value ? h(Foo) : null)
104101
}).mount(root)
105102

@@ -135,7 +132,6 @@ describe('api: defineAsyncComponent', () => {
135132
const toggle = ref(true)
136133
const root = nodeOps.createElement('div')
137134
const app = createApp({
138-
components: { Foo },
139135
render: () => (toggle.value ? h(Foo) : null)
140136
})
141137

@@ -175,13 +171,12 @@ describe('api: defineAsyncComponent', () => {
175171
resolve = _resolve as any
176172
reject = _reject
177173
}),
178-
error: (props: { error: Error }) => props.error.message
174+
errorComponent: (props: { error: Error }) => props.error.message
179175
})
180176

181177
const toggle = ref(true)
182178
const root = nodeOps.createElement('div')
183179
const app = createApp({
184-
components: { Foo },
185180
render: () => (toggle.value ? h(Foo) : null)
186181
})
187182

@@ -220,15 +215,14 @@ describe('api: defineAsyncComponent', () => {
220215
resolve = _resolve as any
221216
reject = _reject
222217
}),
223-
error: (props: { error: Error }) => props.error.message,
224-
loading: () => 'loading',
218+
errorComponent: (props: { error: Error }) => props.error.message,
219+
loadingComponent: () => 'loading',
225220
delay: 1
226221
})
227222

228223
const toggle = ref(true)
229224
const root = nodeOps.createElement('div')
230225
const app = createApp({
231-
components: { Foo },
232226
render: () => (toggle.value ? h(Foo) : null)
233227
})
234228

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

281275
const root = nodeOps.createElement('div')
282276
const app = createApp({
283-
components: { Foo },
284277
render: () => h(Foo)
285278
})
286279

@@ -310,12 +303,11 @@ describe('api: defineAsyncComponent', () => {
310303
resolve = _resolve as any
311304
}),
312305
timeout: 1,
313-
error: () => 'timed out'
306+
errorComponent: () => 'timed out'
314307
})
315308

316309
const root = nodeOps.createElement('div')
317310
const app = createApp({
318-
components: { Foo },
319311
render: () => h(Foo)
320312
})
321313

@@ -343,13 +335,12 @@ describe('api: defineAsyncComponent', () => {
343335
}),
344336
delay: 1,
345337
timeout: 16,
346-
error: () => 'timed out',
347-
loading: () => 'loading'
338+
errorComponent: () => 'timed out',
339+
loadingComponent: () => 'loading'
348340
})
349341

350342
const root = nodeOps.createElement('div')
351343
const app = createApp({
352-
components: { Foo },
353344
render: () => h(Foo)
354345
})
355346
const handler = (app.config.errorHandler = jest.fn())
@@ -376,12 +367,11 @@ describe('api: defineAsyncComponent', () => {
376367
}),
377368
delay: 1,
378369
timeout: 16,
379-
loading: () => 'loading'
370+
loadingComponent: () => 'loading'
380371
})
381372

382373
const root = nodeOps.createElement('div')
383374
const app = createApp({
384-
components: { Foo },
385375
render: () => h(Foo)
386376
})
387377
const handler = (app.config.errorHandler = jest.fn())
@@ -414,7 +404,6 @@ describe('api: defineAsyncComponent', () => {
414404

415405
const root = nodeOps.createElement('div')
416406
const app = createApp({
417-
components: { Foo },
418407
render: () =>
419408
h(Suspense, null, {
420409
default: () => [h(Foo), ' & ', h(Foo)],
@@ -442,7 +431,6 @@ describe('api: defineAsyncComponent', () => {
442431

443432
const root = nodeOps.createElement('div')
444433
const app = createApp({
445-
components: { Foo },
446434
render: () =>
447435
h(Suspense, null, {
448436
default: () => [h(Foo), ' & ', h(Foo)],
@@ -470,7 +458,6 @@ describe('api: defineAsyncComponent', () => {
470458

471459
const root = nodeOps.createElement('div')
472460
const app = createApp({
473-
components: { Foo },
474461
render: () =>
475462
h(Suspense, null, {
476463
default: () => [h(Foo), ' & ', h(Foo)],
@@ -487,4 +474,120 @@ describe('api: defineAsyncComponent', () => {
487474
expect(handler).toHaveBeenCalled()
488475
expect(serializeInner(root)).toBe('<!----> & <!---->')
489476
})
477+
478+
test('retry (success)', async () => {
479+
let loaderCallCount = 0
480+
let resolve: (comp: Component) => void
481+
let reject: (e: Error) => void
482+
483+
const Foo = defineAsyncComponent({
484+
loader: () => {
485+
loaderCallCount++
486+
return new Promise((_resolve, _reject) => {
487+
resolve = _resolve as any
488+
reject = _reject
489+
})
490+
},
491+
retryWhen: error => error.message.match(/foo/)
492+
})
493+
494+
const root = nodeOps.createElement('div')
495+
const app = createApp({
496+
render: () => h(Foo)
497+
})
498+
499+
const handler = (app.config.errorHandler = jest.fn())
500+
app.mount(root)
501+
expect(serializeInner(root)).toBe('<!---->')
502+
expect(loaderCallCount).toBe(1)
503+
504+
const err = new Error('foo')
505+
reject!(err)
506+
await timeout()
507+
expect(handler).not.toHaveBeenCalled()
508+
expect(loaderCallCount).toBe(2)
509+
expect(serializeInner(root)).toBe('<!---->')
510+
511+
// should render this time
512+
resolve!(() => 'resolved')
513+
await timeout()
514+
expect(handler).not.toHaveBeenCalled()
515+
expect(serializeInner(root)).toBe('resolved')
516+
})
517+
518+
test('retry (skipped)', async () => {
519+
let loaderCallCount = 0
520+
let reject: (e: Error) => void
521+
522+
const Foo = defineAsyncComponent({
523+
loader: () => {
524+
loaderCallCount++
525+
return new Promise((_resolve, _reject) => {
526+
reject = _reject
527+
})
528+
},
529+
retryWhen: error => error.message.match(/bar/)
530+
})
531+
532+
const root = nodeOps.createElement('div')
533+
const app = createApp({
534+
render: () => h(Foo)
535+
})
536+
537+
const handler = (app.config.errorHandler = jest.fn())
538+
app.mount(root)
539+
expect(serializeInner(root)).toBe('<!---->')
540+
expect(loaderCallCount).toBe(1)
541+
542+
const err = new Error('foo')
543+
reject!(err)
544+
await timeout()
545+
// should fail because retryWhen returns false
546+
expect(handler).toHaveBeenCalled()
547+
expect(handler.mock.calls[0][0]).toBe(err)
548+
expect(loaderCallCount).toBe(1)
549+
expect(serializeInner(root)).toBe('<!---->')
550+
})
551+
552+
test('retry (fail w/ maxRetries)', async () => {
553+
let loaderCallCount = 0
554+
let reject: (e: Error) => void
555+
556+
const Foo = defineAsyncComponent({
557+
loader: () => {
558+
loaderCallCount++
559+
return new Promise((_resolve, _reject) => {
560+
reject = _reject
561+
})
562+
},
563+
retryWhen: error => error.message.match(/foo/),
564+
maxRetries: 1
565+
})
566+
567+
const root = nodeOps.createElement('div')
568+
const app = createApp({
569+
render: () => h(Foo)
570+
})
571+
572+
const handler = (app.config.errorHandler = jest.fn())
573+
app.mount(root)
574+
expect(serializeInner(root)).toBe('<!---->')
575+
expect(loaderCallCount).toBe(1)
576+
577+
// first retry
578+
const err = new Error('foo')
579+
reject!(err)
580+
await timeout()
581+
expect(handler).not.toHaveBeenCalled()
582+
expect(loaderCallCount).toBe(2)
583+
expect(serializeInner(root)).toBe('<!---->')
584+
585+
// 2nd retry, should fail due to reaching maxRetries
586+
reject!(err)
587+
await timeout()
588+
expect(handler).toHaveBeenCalled()
589+
expect(handler.mock.calls[0][0]).toBe(err)
590+
expect(loaderCallCount).toBe(2)
591+
expect(serializeInner(root)).toBe('<!---->')
592+
})
490593
})

packages/runtime-core/src/apiAsyncComponent.ts

+51-20
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ComponentInternalInstance,
77
isInSSRComponentSetup
88
} from './component'
9-
import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
9+
import { isFunction, isObject, EMPTY_OBJ, NO } from '@vue/shared'
1010
import { ComponentPublicInstance } from './componentProxy'
1111
import { createVNode } from './vnode'
1212
import { defineComponent } from './apiDefineComponent'
@@ -24,10 +24,12 @@ export type AsyncComponentLoader<T = any> = () => Promise<
2424

2525
export interface AsyncComponentOptions<T = any> {
2626
loader: AsyncComponentLoader<T>
27-
loading?: PublicAPIComponent
28-
error?: PublicAPIComponent
27+
loadingComponent?: PublicAPIComponent
28+
errorComponent?: PublicAPIComponent
2929
delay?: number
3030
timeout?: number
31+
retryWhen?: (error: Error) => any
32+
maxRetries?: number
3133
suspensible?: boolean
3234
}
3335

@@ -39,31 +41,62 @@ export function defineAsyncComponent<
3941
}
4042

4143
const {
42-
suspensible = true,
4344
loader,
44-
loading: loadingComponent,
45-
error: errorComponent,
45+
loadingComponent: loadingComponent,
46+
errorComponent: errorComponent,
4647
delay = 200,
47-
timeout // undefined = never times out
48+
timeout, // undefined = never times out
49+
retryWhen = NO,
50+
maxRetries = 3,
51+
suspensible = true
4852
} = source
4953

5054
let pendingRequest: Promise<Component> | null = null
5155
let resolvedComp: Component | undefined
5256

57+
let retries = 0
58+
const retry = (error?: unknown) => {
59+
retries++
60+
pendingRequest = null
61+
return load()
62+
}
63+
5364
const load = (): Promise<Component> => {
65+
let thisRequest: Promise<Component>
5466
return (
5567
pendingRequest ||
56-
(pendingRequest = loader().then((comp: any) => {
57-
// interop module default
58-
if (comp.__esModule || comp[Symbol.toStringTag] === 'Module') {
59-
comp = comp.default
60-
}
61-
if (__DEV__ && !isObject(comp) && !isFunction(comp)) {
62-
warn(`Invalid async component load result: `, comp)
63-
}
64-
resolvedComp = comp
65-
return comp
66-
}))
68+
(thisRequest = pendingRequest = loader()
69+
.catch(err => {
70+
err = err instanceof Error ? err : new Error(String(err))
71+
if (retryWhen(err) && retries < maxRetries) {
72+
return retry(err)
73+
} else {
74+
throw err
75+
}
76+
})
77+
.then((comp: any) => {
78+
if (thisRequest !== pendingRequest && pendingRequest) {
79+
return pendingRequest
80+
}
81+
if (__DEV__ && !comp) {
82+
warn(
83+
`Async component loader resolved to undefined. ` +
84+
`If you are using retry(), make sure to return its return value.`
85+
)
86+
}
87+
// interop module default
88+
if (
89+
comp &&
90+
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
91+
) {
92+
comp = comp.default
93+
}
94+
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
95+
throw new Error(`Invalid async component load result: ${comp}`)
96+
}
97+
resolvedComp = comp
98+
return comp
99+
}))
67100
)
68101
}
69102

@@ -101,8 +134,6 @@ export function defineAsyncComponent<
101134
})
102135
}
103136

104-
// TODO hydration
105-
106137
const loaded = ref(false)
107138
const error = ref()
108139
const delayed = ref(!!delay)

0 commit comments

Comments
 (0)