]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(asyncComponent): SSR/hydration support for async component
authorEvan You <yyx990803@gmail.com>
Mon, 23 Mar 2020 20:14:56 +0000 (16:14 -0400)
committerEvan You <yyx990803@gmail.com>
Mon, 23 Mar 2020 20:14:56 +0000 (16:14 -0400)
packages/runtime-core/__tests__/apiAsyncComponent.spec.ts
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/apiAsyncComponent.ts
packages/runtime-core/src/apiOptions.ts
packages/runtime-core/src/hydration.ts

index 14cf58d3e11e61e7947ddaf3dfea068a4761fe15..eb3ff7b283eec5c1392c34d1bf5c017f36f78842 100644 (file)
@@ -193,8 +193,7 @@ describe('api: createAsyncComponent', () => {
     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(handler).toHaveBeenCalled()
     expect(serializeInner(root)).toBe('errored out')
 
     toggle.value = false
@@ -247,8 +246,7 @@ describe('api: createAsyncComponent', () => {
     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(handler).toHaveBeenCalled()
     expect(serializeInner(root)).toBe('errored out')
 
     toggle.value = false
@@ -327,7 +325,7 @@ describe('api: createAsyncComponent', () => {
     expect(serializeInner(root)).toBe('<!---->')
 
     await timeout(1)
-    expect(handler).not.toHaveBeenCalled()
+    expect(handler).toHaveBeenCalled()
     expect(serializeInner(root)).toBe('timed out')
 
     // if it resolved after timeout, should still work
@@ -354,6 +352,7 @@ describe('api: createAsyncComponent', () => {
       components: { Foo },
       render: () => h(Foo)
     })
+    const handler = (app.config.errorHandler = jest.fn())
     app.mount(root)
     expect(serializeInner(root)).toBe('<!---->')
     await timeout(1)
@@ -361,6 +360,7 @@ describe('api: createAsyncComponent', () => {
 
     await timeout(16)
     expect(serializeInner(root)).toBe('timed out')
+    expect(handler).toHaveBeenCalled()
 
     resolve!(() => 'resolved')
     await timeout()
@@ -459,6 +459,32 @@ describe('api: createAsyncComponent', () => {
     expect(serializeInner(root)).toBe('resolved & resolved')
   })
 
-  // TODO
-  test.todo('suspense with error handling')
+  test('suspense with error handling', async () => {
+    let reject: (e: Error) => void
+    const Foo = createAsyncComponent(
+      () =>
+        new Promise((_resolve, _reject) => {
+          reject = _reject
+        })
+    )
+
+    const root = nodeOps.createElement('div')
+    const app = createApp({
+      components: { Foo },
+      render: () =>
+        h(Suspense, null, {
+          default: () => [h(Foo), ' & ', h(Foo)],
+          fallback: () => 'loading'
+        })
+    })
+
+    const handler = (app.config.errorHandler = jest.fn())
+    app.mount(root)
+    expect(serializeInner(root)).toBe('loading')
+
+    reject!(new Error('no'))
+    await timeout()
+    expect(handler).toHaveBeenCalled()
+    expect(serializeInner(root)).toBe('<!----> & <!---->')
+  })
 })
index fb08d74459da49040a2b801cbeb1e1b833648c20..96686c2ea216b20b3f30d7ebb2a3a5d871359366 100644 (file)
@@ -7,7 +7,8 @@ import {
   Portal,
   createStaticVNode,
   Suspense,
-  onMounted
+  onMounted,
+  createAsyncComponent
 } from '@vue/runtime-dom'
 import { renderToString } from '@vue/server-renderer'
 import { mockWarn } from '@vue/shared'
@@ -381,8 +382,64 @@ describe('SSR hydration', () => {
     expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`)
   })
 
-  // TODO
-  test.todo('async component')
+  test('async component', async () => {
+    const spy = jest.fn()
+    const Comp = () =>
+      h(
+        'button',
+        {
+          onClick: spy
+        },
+        'hello!'
+      )
+
+    let serverResolve: any
+    let AsyncComp = createAsyncComponent(
+      () =>
+        new Promise(r => {
+          serverResolve = r
+        })
+    )
+
+    const App = {
+      render() {
+        return ['hello', h(AsyncComp), 'world']
+      }
+    }
+
+    // server render
+    const htmlPromise = renderToString(h(App))
+    serverResolve(Comp)
+    const html = await htmlPromise
+    expect(html).toMatchInlineSnapshot(
+      `"<!--[-->hello<button>hello!</button>world<!--]-->"`
+    )
+
+    // hydration
+    let clientResolve: any
+    AsyncComp = createAsyncComponent(
+      () =>
+        new Promise(r => {
+          clientResolve = r
+        })
+    )
+
+    const container = document.createElement('div')
+    container.innerHTML = html
+    createSSRApp(App).mount(container)
+
+    // hydration not complete yet
+    triggerEvent('click', container.querySelector('button')!)
+    expect(spy).not.toHaveBeenCalled()
+
+    // resolve
+    clientResolve(Comp)
+    await new Promise(r => setTimeout(r))
+
+    // should be hydrated now
+    triggerEvent('click', container.querySelector('button')!)
+    expect(spy).toHaveBeenCalled()
+  })
 
   describe('mismatch handling', () => {
     test('text node', () => {
index 20ea83a3dc97575c36966ace67b97b39112570a7..39862e07def8806f2a3e445d9241e7d3e20dc7be 100644 (file)
@@ -3,7 +3,8 @@ import {
   Component,
   currentSuspense,
   currentInstance,
-  ComponentInternalInstance
+  ComponentInternalInstance,
+  isInSSRComponentSetup
 } from './component'
 import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
 import { ComponentPublicInstance } from './componentProxy'
@@ -67,6 +68,7 @@ export function createAsyncComponent<
   }
 
   return defineComponent({
+    __asyncLoader: load,
     name: 'AsyncComponentWrapper',
     setup() {
       const instance = currentInstance!
@@ -76,18 +78,29 @@ export function createAsyncComponent<
         return () => createInnerComp(resolvedComp!, instance)
       }
 
-      // suspense-controlled
-      if (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) {
-        return load().then(comp => {
-          return () => createInnerComp(comp, instance)
-        })
-        // TODO suspense error handling
+      const onError = (err: Error) => {
+        pendingRequest = null
+        handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
       }
 
-      // self-controlled
-      if (__NODE_JS__) {
-        // TODO SSR
+      // suspense-controlled or SSR.
+      if (
+        (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) ||
+        (__NODE_JS__ && isInSSRComponentSetup)
+      ) {
+        return load()
+          .then(comp => {
+            return () => createInnerComp(comp, instance)
+          })
+          .catch(err => {
+            onError(err)
+            return () =>
+              errorComponent
+                ? createVNode(errorComponent as Component, { error: err })
+                : null
+          })
       }
+
       // TODO hydration
 
       const loaded = ref(false)
@@ -106,11 +119,8 @@ export function createAsyncComponent<
             const err = new Error(
               `Async component timed out after ${timeout}ms.`
             )
-            if (errorComponent) {
-              error.value = err
-            } else {
-              handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
-            }
+            onError(err)
+            error.value = err
           }
         }, timeout)
       }
@@ -120,12 +130,8 @@ export function createAsyncComponent<
           loaded.value = true
         })
         .catch(err => {
-          pendingRequest = null
-          if (errorComponent) {
-            error.value = err
-          } else {
-            handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
-          }
+          onError(err)
+          error.value = err
         })
 
       return () => {
index 9c369d9ab55b4efed51ae791958ca7477fe96c37..aea558107de08aeee9d3872223b7fdc1c4e5eb20 100644 (file)
@@ -4,7 +4,8 @@ import {
   SetupContext,
   RenderFunction,
   SFCInternalOptions,
-  PublicAPIComponent
+  PublicAPIComponent,
+  Component
 } from './component'
 import {
   isFunction,
@@ -77,6 +78,8 @@ export interface ComponentOptionsBase<
   // type-only differentiator to separate OptionWithoutProps from a constructor
   // type returned by defineComponent() or FunctionalComponent
   call?: never
+  // marker for AsyncComponentWrapper
+  __asyncLoader?: () => Promise<Component>
   // type-only differentiators for built-in Vnode types
   __isFragment?: never
   __isPortal?: never
index aa6c81b751c63c53146e90ae0febcf6ffc467bec..37933eca5a4a6d3515ab99956d99a48705218728 100644 (file)
@@ -24,6 +24,7 @@ import {
   SuspenseBoundary,
   queueEffectWithSuspense
 } from './components/Suspense'
+import { ComponentOptions } from './apiOptions'
 
 export type RootHydrateFunction = (
   vnode: VNode<Node, Element>,
@@ -154,14 +155,23 @@ export function createHydrationFunctions(
           // has .el set, the component will perform hydration instead of mount
           // on its sub-tree.
           const container = parentNode(node)!
-          mountComponent(
-            vnode,
-            container,
-            null,
-            parentComponent,
-            parentSuspense,
-            isSVGContainer(container)
-          )
+          const hydrateComponent = () => {
+            mountComponent(
+              vnode,
+              container,
+              null,
+              parentComponent,
+              parentSuspense,
+              isSVGContainer(container)
+            )
+          }
+          // async component
+          const loadAsync = (vnode.type as ComponentOptions).__asyncLoader
+          if (loadAsync) {
+            loadAsync().then(hydrateComponent)
+          } else {
+            hydrateComponent()
+          }
           // component may be async, so in the case of fragments we cannot rely
           // on component's rendered output to determine the end of the fragment
           // instead, we do a lookahead to find the end anchor node.