]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-core): async component support
authorEvan You <yyx990803@gmail.com>
Sat, 21 Mar 2020 20:01:08 +0000 (16:01 -0400)
committerEvan You <yyx990803@gmail.com>
Sat, 21 Mar 2020 20:01:08 +0000 (16:01 -0400)
packages/runtime-core/__tests__/apiAsyncComponent.spec.ts [new file with mode: 0644]
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/apiAsyncComponent.ts [new file with mode: 0644]
packages/runtime-core/src/errorHandling.ts
packages/runtime-core/src/index.ts

diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
new file mode 100644 (file)
index 0000000..14cf58d
--- /dev/null
@@ -0,0 +1,464 @@
+import {
+  createAsyncComponent,
+  h,
+  Component,
+  ref,
+  nextTick,
+  Suspense
+} from '../src'
+import { createApp, nodeOps, serializeInner } from '@vue/runtime-test'
+
+const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
+
+describe('api: createAsyncComponent', () => {
+  test('simple usage', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent(
+      () =>
+        new Promise(r => {
+          resolve = r as any
+        })
+    )
+
+    const toggle = ref(true)
+    const root = nodeOps.createElement('div')
+    createApp({
+      components: { Foo },
+      render: () => (toggle.value ? h(Foo) : null)
+    }).mount(root)
+
+    expect(serializeInner(root)).toBe('<!---->')
+
+    resolve!(() => 'resolved')
+    // first time resolve, wait for macro task since there are multiple
+    // microtasks / .then() calls
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+
+    toggle.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // already resolved component should update on nextTick
+    toggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('with loading component', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+      loading: () => '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)
+
+    // due to the delay, initial mount should be empty
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // loading show up after delay
+    await timeout(1)
+    expect(serializeInner(root)).toBe('loading')
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+
+    toggle.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // already resolved component should update on nextTick without loading
+    // state
+    toggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('with loading component + explicit delay (0)', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(r => {
+          resolve = r as any
+        }),
+      loading: () => 'loading',
+      delay: 0
+    })
+
+    const toggle = ref(true)
+    const root = nodeOps.createElement('div')
+    createApp({
+      components: { Foo },
+      render: () => (toggle.value ? h(Foo) : null)
+    }).mount(root)
+
+    // with delay: 0, should show loading immediately
+    expect(serializeInner(root)).toBe('loading')
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+
+    toggle.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // already resolved component should update on nextTick without loading
+    // state
+    toggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('error without error component', async () => {
+    let resolve: (comp: Component) => void
+    let reject: (e: Error) => void
+    const Foo = createAsyncComponent(
+      () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        })
+    )
+
+    const toggle = ref(true)
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () => (toggle.value ? h(Foo) : null)
+    })
+
+    const handler = (app.config.errorHandler = jest.fn())
+
+    app.mount(root)
+    expect(serializeInner(root)).toBe('<!---->')
+
+    const err = new Error('foo')
+    reject!(err)
+    await timeout()
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0]).toBe(err)
+    expect(serializeInner(root)).toBe('<!---->')
+
+    toggle.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // should render this time
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('error with error component', async () => {
+    let resolve: (comp: Component) => void
+    let reject: (e: Error) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        }),
+      error: (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)
+    })
+
+    const handler = (app.config.errorHandler = jest.fn())
+
+    app.mount(root)
+    expect(serializeInner(root)).toBe('<!---->')
+
+    const err = new Error('errored out')
+    reject!(err)
+    await timeout()
+    // error handler will not be called if error component is present
+    expect(handler).not.toHaveBeenCalled()
+    expect(serializeInner(root)).toBe('errored out')
+
+    toggle.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // should render this time
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('error with error + loading components', async () => {
+    let resolve: (comp: Component) => void
+    let reject: (e: Error) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise((_resolve, _reject) => {
+          resolve = _resolve as any
+          reject = _reject
+        }),
+      error: (props: { error: Error }) => props.error.message,
+      loading: () => 'loading',
+      delay: 1
+    })
+
+    const toggle = ref(true)
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () => (toggle.value ? h(Foo) : null)
+    })
+
+    const handler = (app.config.errorHandler = jest.fn())
+
+    app.mount(root)
+
+    // due to the delay, initial mount should be empty
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // loading show up after delay
+    await timeout(1)
+    expect(serializeInner(root)).toBe('loading')
+
+    const err = new Error('errored out')
+    reject!(err)
+    await timeout()
+    // error handler will not be called if error component is present
+    expect(handler).not.toHaveBeenCalled()
+    expect(serializeInner(root)).toBe('errored out')
+
+    toggle.value = false
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // errored out on previous load, toggle and mock success this time
+    toggle.value = true
+    await nextTick()
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // loading show up after delay
+    await timeout(1)
+    expect(serializeInner(root)).toBe('loading')
+
+    // should render this time
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('timeout without error component', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      timeout: 1
+    })
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () => h(Foo)
+    })
+
+    const handler = (app.config.errorHandler = jest.fn())
+
+    app.mount(root)
+    expect(serializeInner(root)).toBe('<!---->')
+
+    await timeout(1)
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0].message).toMatch(
+      `Async component timed out after 1ms.`
+    )
+    expect(serializeInner(root)).toBe('<!---->')
+
+    // if it resolved after timeout, should still work
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('timeout with error component', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      timeout: 1,
+      error: () => 'timed out'
+    })
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () => h(Foo)
+    })
+
+    const handler = (app.config.errorHandler = jest.fn())
+
+    app.mount(root)
+    expect(serializeInner(root)).toBe('<!---->')
+
+    await timeout(1)
+    expect(handler).not.toHaveBeenCalled()
+    expect(serializeInner(root)).toBe('timed out')
+
+    // if it resolved after timeout, should still work
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('timeout with error + loading components', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      delay: 1,
+      timeout: 16,
+      error: () => 'timed out',
+      loading: () => 'loading'
+    })
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () => h(Foo)
+    })
+    app.mount(root)
+    expect(serializeInner(root)).toBe('<!---->')
+    await timeout(1)
+    expect(serializeInner(root)).toBe('loading')
+
+    await timeout(16)
+    expect(serializeInner(root)).toBe('timed out')
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('timeout without error component, but with loading component', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      delay: 1,
+      timeout: 16,
+      loading: () => 'loading'
+    })
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () => h(Foo)
+    })
+    const handler = (app.config.errorHandler = jest.fn())
+    app.mount(root)
+    expect(serializeInner(root)).toBe('<!---->')
+    await timeout(1)
+    expect(serializeInner(root)).toBe('loading')
+
+    await timeout(16)
+    expect(handler).toHaveBeenCalled()
+    expect(handler.mock.calls[0][0].message).toMatch(
+      `Async component timed out after 16ms.`
+    )
+    // should still display loading
+    expect(serializeInner(root)).toBe('loading')
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved')
+  })
+
+  test('with suspense', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent(
+      () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        })
+    )
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () =>
+        h(Suspense, null, {
+          default: () => [h(Foo), ' & ', h(Foo)],
+          fallback: () => 'loading'
+        })
+    })
+
+    app.mount(root)
+    expect(serializeInner(root)).toBe('loading')
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved & resolved')
+  })
+
+  test('suspensible: false', async () => {
+    let resolve: (comp: Component) => void
+    const Foo = createAsyncComponent({
+      loader: () =>
+        new Promise(_resolve => {
+          resolve = _resolve as any
+        }),
+      suspensible: false
+    })
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () =>
+        h(Suspense, null, {
+          default: () => [h(Foo), ' & ', h(Foo)],
+          fallback: () => 'loading'
+        })
+    })
+
+    app.mount(root)
+    // should not show suspense fallback
+    expect(serializeInner(root)).toBe('<!----> & <!---->')
+
+    resolve!(() => 'resolved')
+    await timeout()
+    expect(serializeInner(root)).toBe('resolved & resolved')
+  })
+
+  // TODO
+  test.todo('suspense with error handling')
+})
index 67387bc52be9036e929d91649e1ed007c681a84e..0720eef61c45aae35df36735f28b8d3c292136cc 100644 (file)
@@ -379,6 +379,9 @@ describe('SSR hydration', () => {
     expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`)
   })
 
+  // TODO
+  test.todo('async component')
+
   describe('mismatch handling', () => {
     test('text node', () => {
       const { container } = mountWithHydration(`foo`, () => 'bar')
diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts
new file mode 100644 (file)
index 0000000..20ea83a
--- /dev/null
@@ -0,0 +1,155 @@
+import {
+  PublicAPIComponent,
+  Component,
+  currentSuspense,
+  currentInstance,
+  ComponentInternalInstance
+} from './component'
+import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
+import { ComponentPublicInstance } from './componentProxy'
+import { createVNode } from './vnode'
+import { defineComponent } from './apiDefineComponent'
+import { warn } from './warning'
+import { ref } from '@vue/reactivity'
+import { handleError, ErrorCodes } from './errorHandling'
+
+export type AsyncComponentResolveResult<T = PublicAPIComponent> =
+  | T
+  | { default: T } // es modules
+
+export type AsyncComponentLoader<T = any> = () => Promise<
+  AsyncComponentResolveResult<T>
+>
+
+export interface AsyncComponentOptions<T = any> {
+  loader: AsyncComponentLoader<T>
+  loading?: PublicAPIComponent
+  error?: PublicAPIComponent
+  delay?: number
+  timeout?: number
+  suspensible?: boolean
+}
+
+export function createAsyncComponent<
+  T extends PublicAPIComponent = { new (): ComponentPublicInstance }
+>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
+  if (isFunction(source)) {
+    source = { loader: source }
+  }
+
+  const {
+    suspensible = true,
+    loader,
+    loading: loadingComponent,
+    error: errorComponent,
+    delay = 200,
+    timeout // undefined = never times out
+  } = source
+
+  let pendingRequest: Promise<Component> | null = null
+  let resolvedComp: Component | undefined
+
+  const load = (): 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
+      }))
+    )
+  }
+
+  return defineComponent({
+    name: 'AsyncComponentWrapper',
+    setup() {
+      const instance = currentInstance!
+
+      // already resolved
+      if (resolvedComp) {
+        return () => createInnerComp(resolvedComp!, instance)
+      }
+
+      // suspense-controlled
+      if (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) {
+        return load().then(comp => {
+          return () => createInnerComp(comp, instance)
+        })
+        // TODO suspense error handling
+      }
+
+      // self-controlled
+      if (__NODE_JS__) {
+        // TODO SSR
+      }
+      // TODO hydration
+
+      const loaded = ref(false)
+      const error = ref()
+      const delayed = ref(!!delay)
+
+      if (delay) {
+        setTimeout(() => {
+          delayed.value = false
+        }, delay)
+      }
+
+      if (timeout != null) {
+        setTimeout(() => {
+          if (!loaded.value) {
+            const err = new Error(
+              `Async component timed out after ${timeout}ms.`
+            )
+            if (errorComponent) {
+              error.value = err
+            } else {
+              handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
+            }
+          }
+        }, timeout)
+      }
+
+      load()
+        .then(() => {
+          loaded.value = true
+        })
+        .catch(err => {
+          pendingRequest = null
+          if (errorComponent) {
+            error.value = err
+          } else {
+            handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
+          }
+        })
+
+      return () => {
+        if (loaded.value && resolvedComp) {
+          return createInnerComp(resolvedComp, instance)
+        } else if (error.value && errorComponent) {
+          return createVNode(errorComponent as Component, {
+            error: error.value
+          })
+        } else if (loadingComponent && !delayed.value) {
+          return createVNode(loadingComponent as Component)
+        }
+      }
+    }
+  }) as any
+}
+
+function createInnerComp(
+  comp: Component,
+  { props, slots }: ComponentInternalInstance
+) {
+  return createVNode(
+    comp,
+    props === EMPTY_OBJ ? null : props,
+    slots === EMPTY_OBJ ? null : slots
+  )
+}
index c57fd8a99c4b09a464c36818cfb5209e9dc3c457..095661218c6ca0917e65ffc6a0f3be877c12458c 100644 (file)
@@ -19,6 +19,7 @@ export const enum ErrorCodes {
   APP_ERROR_HANDLER,
   APP_WARN_HANDLER,
   FUNCTION_REF,
+  ASYNC_COMPONENT_LOADER,
   SCHEDULER
 }
 
@@ -49,6 +50,7 @@ export const ErrorTypeStrings: Record<number | string, string> = {
   [ErrorCodes.APP_ERROR_HANDLER]: 'app errorHandler',
   [ErrorCodes.APP_WARN_HANDLER]: 'app warnHandler',
   [ErrorCodes.FUNCTION_REF]: 'ref function',
+  [ErrorCodes.ASYNC_COMPONENT_LOADER]: 'async component loader',
   [ErrorCodes.SCHEDULER]:
     'scheduler flush. This is likely a Vue internals bug. ' +
     'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/vue-next'
index b05acf093c56c7fba030a59bc4ca1ad4d7eca099..2f3b6543800d834a102807966dc07ba2eb650b39 100644 (file)
@@ -34,6 +34,7 @@ export {
 export { provide, inject } from './apiInject'
 export { nextTick } from './scheduler'
 export { defineComponent } from './apiDefineComponent'
+export { createAsyncComponent } from './apiAsyncComponent'
 
 // Advanced API ----------------------------------------------------------------
 
@@ -204,4 +205,8 @@ export {
 } from './directives'
 export { SuspenseBoundary } from './components/Suspense'
 export { TransitionState, TransitionHooks } from './components/BaseTransition'
+export {
+  AsyncComponentOptions,
+  AsyncComponentLoader
+} from './apiAsyncComponent'
 export { HMRRuntime } from './hmr'