From cba2f1aadbd0d4ae246040ecd5a91d8dd4e8fd1a Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 23 Mar 2020 16:14:56 -0400 Subject: [PATCH] feat(asyncComponent): SSR/hydration support for async component --- .../__tests__/apiAsyncComponent.spec.ts | 40 +++++++++--- .../runtime-core/__tests__/hydration.spec.ts | 63 ++++++++++++++++++- .../runtime-core/src/apiAsyncComponent.ts | 48 +++++++------- packages/runtime-core/src/apiOptions.ts | 5 +- packages/runtime-core/src/hydration.ts | 26 +++++--- 5 files changed, 142 insertions(+), 40 deletions(-) diff --git a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts index 14cf58d3e11..eb3ff7b283e 100644 --- a/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts +++ b/packages/runtime-core/__tests__/apiAsyncComponent.spec.ts @@ -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(' & ') + }) }) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index fb08d74459d..96686c2ea21 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -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(`23`) }) - // 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( + `"helloworld"` + ) + + // 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', () => { diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 20ea83a3dc9..39862e07def 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -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 () => { diff --git a/packages/runtime-core/src/apiOptions.ts b/packages/runtime-core/src/apiOptions.ts index 9c369d9ab55..aea558107de 100644 --- a/packages/runtime-core/src/apiOptions.ts +++ b/packages/runtime-core/src/apiOptions.ts @@ -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 // type-only differentiators for built-in Vnode types __isFragment?: never __isPortal?: never diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index aa6c81b751c..37933eca5a4 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -24,6 +24,7 @@ import { SuspenseBoundary, queueEffectWithSuspense } from './components/Suspense' +import { ComponentOptions } from './apiOptions' export type RootHydrateFunction = ( vnode: VNode, @@ -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.