]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(asyncComponent): retry support
authorEvan You <yyx990803@gmail.com>
Fri, 27 Mar 2020 00:58:31 +0000 (20:58 -0400)
committerEvan You <yyx990803@gmail.com>
Fri, 27 Mar 2020 00:58:31 +0000 (20:58 -0400)
BREAKING CHANGE: async component `error` and `loading` options have been
renamed to `errorComponent` and `loadingComponent` respectively.

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

index 2ddce743c3e80683da9e363114473b913362a092..fb989296bc15f98ca697042d5f86d6326d24a388 100644 (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('<!---->')
+  })
 })
index 6a6896263aecc222ebb60452480ae23fdc5523e2..62b50a1ecda084288a420ece077b5d7c7c6a498b 100644 (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)