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')
 | 
					    const err = new Error('errored out')
 | 
				
			||||||
    reject!(err)
 | 
					    reject!(err)
 | 
				
			||||||
    await timeout()
 | 
					    await timeout()
 | 
				
			||||||
    // error handler will not be called if error component is present
 | 
					    expect(handler).toHaveBeenCalled()
 | 
				
			||||||
    expect(handler).not.toHaveBeenCalled()
 | 
					 | 
				
			||||||
    expect(serializeInner(root)).toBe('errored out')
 | 
					    expect(serializeInner(root)).toBe('errored out')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    toggle.value = false
 | 
					    toggle.value = false
 | 
				
			||||||
@ -247,8 +246,7 @@ describe('api: createAsyncComponent', () => {
 | 
				
			|||||||
    const err = new Error('errored out')
 | 
					    const err = new Error('errored out')
 | 
				
			||||||
    reject!(err)
 | 
					    reject!(err)
 | 
				
			||||||
    await timeout()
 | 
					    await timeout()
 | 
				
			||||||
    // error handler will not be called if error component is present
 | 
					    expect(handler).toHaveBeenCalled()
 | 
				
			||||||
    expect(handler).not.toHaveBeenCalled()
 | 
					 | 
				
			||||||
    expect(serializeInner(root)).toBe('errored out')
 | 
					    expect(serializeInner(root)).toBe('errored out')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    toggle.value = false
 | 
					    toggle.value = false
 | 
				
			||||||
@ -327,7 +325,7 @@ describe('api: createAsyncComponent', () => {
 | 
				
			|||||||
    expect(serializeInner(root)).toBe('<!---->')
 | 
					    expect(serializeInner(root)).toBe('<!---->')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await timeout(1)
 | 
					    await timeout(1)
 | 
				
			||||||
    expect(handler).not.toHaveBeenCalled()
 | 
					    expect(handler).toHaveBeenCalled()
 | 
				
			||||||
    expect(serializeInner(root)).toBe('timed out')
 | 
					    expect(serializeInner(root)).toBe('timed out')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // if it resolved after timeout, should still work
 | 
					    // if it resolved after timeout, should still work
 | 
				
			||||||
@ -354,6 +352,7 @@ describe('api: createAsyncComponent', () => {
 | 
				
			|||||||
      components: { Foo },
 | 
					      components: { Foo },
 | 
				
			||||||
      render: () => h(Foo)
 | 
					      render: () => h(Foo)
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					    const handler = (app.config.errorHandler = jest.fn())
 | 
				
			||||||
    app.mount(root)
 | 
					    app.mount(root)
 | 
				
			||||||
    expect(serializeInner(root)).toBe('<!---->')
 | 
					    expect(serializeInner(root)).toBe('<!---->')
 | 
				
			||||||
    await timeout(1)
 | 
					    await timeout(1)
 | 
				
			||||||
@ -361,6 +360,7 @@ describe('api: createAsyncComponent', () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    await timeout(16)
 | 
					    await timeout(16)
 | 
				
			||||||
    expect(serializeInner(root)).toBe('timed out')
 | 
					    expect(serializeInner(root)).toBe('timed out')
 | 
				
			||||||
 | 
					    expect(handler).toHaveBeenCalled()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    resolve!(() => 'resolved')
 | 
					    resolve!(() => 'resolved')
 | 
				
			||||||
    await timeout()
 | 
					    await timeout()
 | 
				
			||||||
@ -459,6 +459,32 @@ describe('api: createAsyncComponent', () => {
 | 
				
			|||||||
    expect(serializeInner(root)).toBe('resolved & resolved')
 | 
					    expect(serializeInner(root)).toBe('resolved & resolved')
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // TODO
 | 
					  test('suspense with error handling', async () => {
 | 
				
			||||||
  test.todo('suspense with error handling')
 | 
					    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,
 | 
					  Portal,
 | 
				
			||||||
  createStaticVNode,
 | 
					  createStaticVNode,
 | 
				
			||||||
  Suspense,
 | 
					  Suspense,
 | 
				
			||||||
  onMounted
 | 
					  onMounted,
 | 
				
			||||||
 | 
					  createAsyncComponent
 | 
				
			||||||
} from '@vue/runtime-dom'
 | 
					} from '@vue/runtime-dom'
 | 
				
			||||||
import { renderToString } from '@vue/server-renderer'
 | 
					import { renderToString } from '@vue/server-renderer'
 | 
				
			||||||
import { mockWarn } from '@vue/shared'
 | 
					import { mockWarn } from '@vue/shared'
 | 
				
			||||||
@ -381,8 +382,64 @@ describe('SSR hydration', () => {
 | 
				
			|||||||
    expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`)
 | 
					    expect(container.innerHTML).toMatch(`<span>2</span><span>3</span>`)
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // TODO
 | 
					  test('async component', async () => {
 | 
				
			||||||
  test.todo('async component')
 | 
					    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', () => {
 | 
					  describe('mismatch handling', () => {
 | 
				
			||||||
    test('text node', () => {
 | 
					    test('text node', () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -3,7 +3,8 @@ import {
 | 
				
			|||||||
  Component,
 | 
					  Component,
 | 
				
			||||||
  currentSuspense,
 | 
					  currentSuspense,
 | 
				
			||||||
  currentInstance,
 | 
					  currentInstance,
 | 
				
			||||||
  ComponentInternalInstance
 | 
					  ComponentInternalInstance,
 | 
				
			||||||
 | 
					  isInSSRComponentSetup
 | 
				
			||||||
} from './component'
 | 
					} from './component'
 | 
				
			||||||
import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
 | 
					import { isFunction, isObject, EMPTY_OBJ } from '@vue/shared'
 | 
				
			||||||
import { ComponentPublicInstance } from './componentProxy'
 | 
					import { ComponentPublicInstance } from './componentProxy'
 | 
				
			||||||
@ -67,6 +68,7 @@ export function createAsyncComponent<
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return defineComponent({
 | 
					  return defineComponent({
 | 
				
			||||||
 | 
					    __asyncLoader: load,
 | 
				
			||||||
    name: 'AsyncComponentWrapper',
 | 
					    name: 'AsyncComponentWrapper',
 | 
				
			||||||
    setup() {
 | 
					    setup() {
 | 
				
			||||||
      const instance = currentInstance!
 | 
					      const instance = currentInstance!
 | 
				
			||||||
@ -76,18 +78,29 @@ export function createAsyncComponent<
 | 
				
			|||||||
        return () => createInnerComp(resolvedComp!, instance)
 | 
					        return () => createInnerComp(resolvedComp!, instance)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // suspense-controlled
 | 
					      const onError = (err: Error) => {
 | 
				
			||||||
      if (__FEATURE_SUSPENSE__ && suspensible && currentSuspense) {
 | 
					        pendingRequest = null
 | 
				
			||||||
        return load().then(comp => {
 | 
					        handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
 | 
				
			||||||
          return () => createInnerComp(comp, instance)
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
        // TODO suspense error handling
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // self-controlled
 | 
					      // suspense-controlled or SSR.
 | 
				
			||||||
      if (__NODE_JS__) {
 | 
					      if (
 | 
				
			||||||
        // TODO SSR
 | 
					        (__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
 | 
					      // TODO hydration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const loaded = ref(false)
 | 
					      const loaded = ref(false)
 | 
				
			||||||
@ -106,11 +119,8 @@ export function createAsyncComponent<
 | 
				
			|||||||
            const err = new Error(
 | 
					            const err = new Error(
 | 
				
			||||||
              `Async component timed out after ${timeout}ms.`
 | 
					              `Async component timed out after ${timeout}ms.`
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            if (errorComponent) {
 | 
					            onError(err)
 | 
				
			||||||
              error.value = err
 | 
					            error.value = err
 | 
				
			||||||
            } else {
 | 
					 | 
				
			||||||
              handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }, timeout)
 | 
					        }, timeout)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
@ -120,12 +130,8 @@ export function createAsyncComponent<
 | 
				
			|||||||
          loaded.value = true
 | 
					          loaded.value = true
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        .catch(err => {
 | 
					        .catch(err => {
 | 
				
			||||||
          pendingRequest = null
 | 
					          onError(err)
 | 
				
			||||||
          if (errorComponent) {
 | 
					          error.value = err
 | 
				
			||||||
            error.value = err
 | 
					 | 
				
			||||||
          } else {
 | 
					 | 
				
			||||||
            handleError(err, instance, ErrorCodes.ASYNC_COMPONENT_LOADER)
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return () => {
 | 
					      return () => {
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,8 @@ import {
 | 
				
			|||||||
  SetupContext,
 | 
					  SetupContext,
 | 
				
			||||||
  RenderFunction,
 | 
					  RenderFunction,
 | 
				
			||||||
  SFCInternalOptions,
 | 
					  SFCInternalOptions,
 | 
				
			||||||
  PublicAPIComponent
 | 
					  PublicAPIComponent,
 | 
				
			||||||
 | 
					  Component
 | 
				
			||||||
} from './component'
 | 
					} from './component'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  isFunction,
 | 
					  isFunction,
 | 
				
			||||||
@ -77,6 +78,8 @@ export interface ComponentOptionsBase<
 | 
				
			|||||||
  // type-only differentiator to separate OptionWithoutProps from a constructor
 | 
					  // type-only differentiator to separate OptionWithoutProps from a constructor
 | 
				
			||||||
  // type returned by defineComponent() or FunctionalComponent
 | 
					  // type returned by defineComponent() or FunctionalComponent
 | 
				
			||||||
  call?: never
 | 
					  call?: never
 | 
				
			||||||
 | 
					  // marker for AsyncComponentWrapper
 | 
				
			||||||
 | 
					  __asyncLoader?: () => Promise<Component>
 | 
				
			||||||
  // type-only differentiators for built-in Vnode types
 | 
					  // type-only differentiators for built-in Vnode types
 | 
				
			||||||
  __isFragment?: never
 | 
					  __isFragment?: never
 | 
				
			||||||
  __isPortal?: never
 | 
					  __isPortal?: never
 | 
				
			||||||
 | 
				
			|||||||
@ -24,6 +24,7 @@ import {
 | 
				
			|||||||
  SuspenseBoundary,
 | 
					  SuspenseBoundary,
 | 
				
			||||||
  queueEffectWithSuspense
 | 
					  queueEffectWithSuspense
 | 
				
			||||||
} from './components/Suspense'
 | 
					} from './components/Suspense'
 | 
				
			||||||
 | 
					import { ComponentOptions } from './apiOptions'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export type RootHydrateFunction = (
 | 
					export type RootHydrateFunction = (
 | 
				
			||||||
  vnode: VNode<Node, Element>,
 | 
					  vnode: VNode<Node, Element>,
 | 
				
			||||||
@ -154,14 +155,23 @@ export function createHydrationFunctions(
 | 
				
			|||||||
          // has .el set, the component will perform hydration instead of mount
 | 
					          // has .el set, the component will perform hydration instead of mount
 | 
				
			||||||
          // on its sub-tree.
 | 
					          // on its sub-tree.
 | 
				
			||||||
          const container = parentNode(node)!
 | 
					          const container = parentNode(node)!
 | 
				
			||||||
          mountComponent(
 | 
					          const hydrateComponent = () => {
 | 
				
			||||||
            vnode,
 | 
					            mountComponent(
 | 
				
			||||||
            container,
 | 
					              vnode,
 | 
				
			||||||
            null,
 | 
					              container,
 | 
				
			||||||
            parentComponent,
 | 
					              null,
 | 
				
			||||||
            parentSuspense,
 | 
					              parentComponent,
 | 
				
			||||||
            isSVGContainer(container)
 | 
					              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
 | 
					          // 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
 | 
					          // on component's rendered output to determine the end of the fragment
 | 
				
			||||||
          // instead, we do a lookahead to find the end anchor node.
 | 
					          // instead, we do a lookahead to find the end anchor node.
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user