feat(asyncComponent): SSR/hydration support for async component
This commit is contained in:
		
							parent
							
								
									9fc8ade884
								
							
						
					
					
						commit
						cba2f1aadb
					
				@ -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('<!----> & <!---->')
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -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', () => {
 | 
			
		||||
 | 
			
		||||
@ -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 () => {
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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.
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user