From 80c625dce33610e53c953e9fb8fde26e3e10e358 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 10 Mar 2020 16:52:08 -0400 Subject: [PATCH] feat(ssr): compiler-ssr support for Suspense --- .../__tests__/ssrComponent.spec.ts | 27 +- .../compiler-ssr/__tests__/ssrPortal.spec.ts | 16 ++ .../__tests__/ssrSuspense.spec.ts | 51 ++++ packages/compiler-ssr/src/runtimeHelpers.ts | 4 +- .../src/transforms/ssrTransformComponent.ts | 68 ++---- .../src/transforms/ssrTransformPortal.ts | 60 +++++ .../src/transforms/ssrTransformSuspense.ts | 78 ++++++ .../__tests__/ssrSuspense.spec.ts | 230 +++++++++++------- .../src/helpers/ssrRenderSuspense.ts | 19 ++ packages/server-renderer/src/index.ts | 1 + 10 files changed, 396 insertions(+), 158 deletions(-) create mode 100644 packages/compiler-ssr/__tests__/ssrPortal.spec.ts create mode 100644 packages/compiler-ssr/__tests__/ssrSuspense.spec.ts create mode 100644 packages/compiler-ssr/src/transforms/ssrTransformPortal.ts create mode 100644 packages/compiler-ssr/src/transforms/ssrTransformSuspense.ts create mode 100644 packages/server-renderer/src/helpers/ssrRenderSuspense.ts diff --git a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts index 4414ad3a891..89e4b1d8d97 100644 --- a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts @@ -34,7 +34,7 @@ describe('ssr: components', () => { .toMatchInlineSnapshot(` "const { resolveDynamicComponent: _resolveDynamicComponent } = require(\\"vue\\") const { ssrRenderComponent: _ssrRenderComponent } = require(\\"@vue/server-renderer\\") - + return function ssrRender(_ctx, _push, _parent) { _push(_ssrRenderComponent(_resolveDynamicComponent(_ctx.foo, _ctx.$), { prop: \\"b\\" }, null, _parent)) }" @@ -269,7 +269,6 @@ describe('ssr: components', () => { }) test('built-in fallthroughs', () => { - // no fragment expect(compile(`
`).code) .toMatchInlineSnapshot(` " @@ -278,7 +277,6 @@ describe('ssr: components', () => { }" `) - // wrap with fragment expect(compile(`
`).code) .toMatchInlineSnapshot(` " @@ -287,7 +285,6 @@ describe('ssr: components', () => { }" `) - // no fragment expect(compile(``).code) .toMatchInlineSnapshot(` "const { resolveComponent: _resolveComponent } = require(\\"vue\\") @@ -299,28 +296,6 @@ describe('ssr: components', () => { _push(_ssrRenderComponent(_component_foo, null, null, _parent)) }" `) - - // wrap with fragment - expect(compile(`
`).code) - .toMatchInlineSnapshot(` - " - return function ssrRender(_ctx, _push, _parent) { - _push(\`
\`) - }" - `) - }) - - test('portal rendering', () => { - expect(compile(`
`).code) - .toMatchInlineSnapshot(` - "const { ssrRenderPortal: _ssrRenderPortal } = require(\\"@vue/server-renderer\\") - - return function ssrRender(_ctx, _push, _parent) { - _ssrRenderPortal((_push) => { - _push(\`
\`) - }, _ctx.target, _parent) - }" - `) }) }) }) diff --git a/packages/compiler-ssr/__tests__/ssrPortal.spec.ts b/packages/compiler-ssr/__tests__/ssrPortal.spec.ts new file mode 100644 index 00000000000..5490649d57f --- /dev/null +++ b/packages/compiler-ssr/__tests__/ssrPortal.spec.ts @@ -0,0 +1,16 @@ +import { compile } from '../src' + +describe('ssr compile: portal', () => { + test('should work', () => { + expect(compile(`
`).code) + .toMatchInlineSnapshot(` + "const { ssrRenderPortal: _ssrRenderPortal } = require(\\"@vue/server-renderer\\") + + return function ssrRender(_ctx, _push, _parent) { + _ssrRenderPortal((_push) => { + _push(\`
\`) + }, _ctx.target, _parent) + }" + `) + }) +}) diff --git a/packages/compiler-ssr/__tests__/ssrSuspense.spec.ts b/packages/compiler-ssr/__tests__/ssrSuspense.spec.ts new file mode 100644 index 00000000000..7f38f513458 --- /dev/null +++ b/packages/compiler-ssr/__tests__/ssrSuspense.spec.ts @@ -0,0 +1,51 @@ +import { compile } from '../src' + +describe('ssr compile: suspense', () => { + test('implicit default', () => { + expect(compile(``).code).toMatchInlineSnapshot(` + "const { resolveComponent: _resolveComponent } = require(\\"vue\\") + const { ssrRenderComponent: _ssrRenderComponent, ssrRenderSuspense: _ssrRenderSuspense } = require(\\"@vue/server-renderer\\") + + return function ssrRender(_ctx, _push, _parent) { + const _component_foo = _resolveComponent(\\"foo\\") + + _push(_ssrRenderSuspense({ + default: (_push) => { + _push(_ssrRenderComponent(_component_foo, null, null, _parent)) + }, + _: 1 + })) + }" + `) + }) + + test('explicit slots', () => { + expect( + compile(` + + + `).code + ).toMatchInlineSnapshot(` + "const { resolveComponent: _resolveComponent } = require(\\"vue\\") + const { ssrRenderComponent: _ssrRenderComponent, ssrRenderSuspense: _ssrRenderSuspense } = require(\\"@vue/server-renderer\\") + + return function ssrRender(_ctx, _push, _parent) { + const _component_foo = _resolveComponent(\\"foo\\") + + _push(_ssrRenderSuspense({ + default: (_push) => { + _push(_ssrRenderComponent(_component_foo, null, null, _parent)) + }, + fallback: (_push) => { + _push(\` loading... \`) + }, + _: 1 + })) + }" + `) + }) +}) diff --git a/packages/compiler-ssr/src/runtimeHelpers.ts b/packages/compiler-ssr/src/runtimeHelpers.ts index 01875cf05ca..bfd8370891a 100644 --- a/packages/compiler-ssr/src/runtimeHelpers.ts +++ b/packages/compiler-ssr/src/runtimeHelpers.ts @@ -14,6 +14,7 @@ export const SSR_LOOSE_CONTAIN = Symbol(`ssrLooseContain`) export const SSR_RENDER_DYNAMIC_MODEL = Symbol(`ssrRenderDynamicModel`) export const SSR_GET_DYNAMIC_MODEL_PROPS = Symbol(`ssrGetDynamicModelProps`) export const SSR_RENDER_PORTAL = Symbol(`ssrRenderPortal`) +export const SSR_RENDER_SUSPENSE = Symbol(`ssrRenderSuspense`) export const ssrHelpers = { [SSR_INTERPOLATE]: `ssrInterpolate`, @@ -29,7 +30,8 @@ export const ssrHelpers = { [SSR_LOOSE_CONTAIN]: `ssrLooseContain`, [SSR_RENDER_DYNAMIC_MODEL]: `ssrRenderDynamicModel`, [SSR_GET_DYNAMIC_MODEL_PROPS]: `ssrGetDynamicModelProps`, - [SSR_RENDER_PORTAL]: `ssrRenderPortal` + [SSR_RENDER_PORTAL]: `ssrRenderPortal`, + [SSR_RENDER_SUSPENSE]: `ssrRenderSuspense` } // Note: these are helpers imported from @vue/server-renderer diff --git a/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts b/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts index 0e3f1457bf4..1acf45ee5b8 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformComponent.ts @@ -30,17 +30,20 @@ import { traverseNode, ExpressionNode, TemplateNode, - findProp, - JSChildNode + SUSPENSE } from '@vue/compiler-dom' -import { SSR_RENDER_COMPONENT, SSR_RENDER_PORTAL } from '../runtimeHelpers' +import { SSR_RENDER_COMPONENT } from '../runtimeHelpers' import { SSRTransformContext, processChildren, processChildrenAsStatement } from '../ssrCodegenTransform' +import { ssrProcessPortal } from './ssrTransformPortal' +import { + ssrProcessSuspense, + ssrTransformSuspense +} from './ssrTransformSuspense' import { isSymbol, isObject, isArray } from '@vue/shared' -import { createSSRCompilerError, SSRErrorCodes } from '../errors' // We need to construct the slot functions in the 1st pass to ensure proper // scope tracking, but the children of each slot cannot be processed until @@ -56,6 +59,12 @@ interface WIPSlotEntry { const componentTypeMap = new WeakMap() +// ssr component transform is done in two phases: +// In phase 1. we use `buildSlot` to analyze the children of the component into +// WIP slot functions (it must be done in phase 1 because `buildSlot` relies on +// the core transform context). +// In phase 2. we convert the WIP slots from phase 1 into ssr-specific codegen +// nodes. export const ssrTransformComponent: NodeTransform = (node, context) => { if ( node.type !== NodeTypes.ELEMENT || @@ -67,6 +76,9 @@ export const ssrTransformComponent: NodeTransform = (node, context) => { const component = resolveComponentType(node, context, true /* ssr */) if (isSymbol(component)) { componentTypeMap.set(node, component) + if (component === SUSPENSE) { + return ssrTransformSuspense(node, context) + } return // built-in component: fallthrough } @@ -132,12 +144,15 @@ export function ssrProcessComponent( ) { if (!node.ssrCodegenNode) { // this is a built-in component that fell-through. - // just render its children. const component = componentTypeMap.get(node)! if (component === PORTAL) { return ssrProcessPortal(node, context) + } else if (component === SUSPENSE) { + return ssrProcessSuspense(node, context) + } else { + // real fall-through (e.g. KeepAlive): just render its children. + processChildren(node.children, context) } - processChildren(node.children, context) } else { // finish up slot function expressions from the 1st pass. const wipEntries = wipMap.get(node) || [] @@ -161,47 +176,6 @@ export function ssrProcessComponent( } } -function ssrProcessPortal(node: ComponentNode, context: SSRTransformContext) { - const targetProp = findProp(node, 'target') - if (!targetProp) { - context.onError( - createSSRCompilerError(SSRErrorCodes.X_SSR_NO_PORTAL_TARGET, node.loc) - ) - return - } - - let target: JSChildNode - if (targetProp.type === NodeTypes.ATTRIBUTE && targetProp.value) { - target = createSimpleExpression(targetProp.value.content, true) - } else if (targetProp.type === NodeTypes.DIRECTIVE && targetProp.exp) { - target = targetProp.exp - } else { - context.onError( - createSSRCompilerError( - SSRErrorCodes.X_SSR_NO_PORTAL_TARGET, - targetProp.loc - ) - ) - return - } - - const contentRenderFn = createFunctionExpression( - [`_push`], - undefined, // Body is added later - true, // newline - false, // isSlot - node.loc - ) - contentRenderFn.body = processChildrenAsStatement(node.children, context) - context.pushStatement( - createCallExpression(context.helper(SSR_RENDER_PORTAL), [ - contentRenderFn, - target, - `_parent` - ]) - ) -} - export const rawOptionsMap = new WeakMap() const [baseNodeTransforms, baseDirectiveTransforms] = getBaseTransformPreset( diff --git a/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts b/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts new file mode 100644 index 00000000000..c380e672aba --- /dev/null +++ b/packages/compiler-ssr/src/transforms/ssrTransformPortal.ts @@ -0,0 +1,60 @@ +import { + ComponentNode, + findProp, + JSChildNode, + NodeTypes, + createSimpleExpression, + createFunctionExpression, + createCallExpression +} from '@vue/compiler-dom' +import { + SSRTransformContext, + processChildrenAsStatement +} from '../ssrCodegenTransform' +import { createSSRCompilerError, SSRErrorCodes } from '../errors' +import { SSR_RENDER_PORTAL } from '../runtimeHelpers' + +// Note: this is a 2nd-pass codegen transform. +export function ssrProcessPortal( + node: ComponentNode, + context: SSRTransformContext +) { + const targetProp = findProp(node, 'target') + if (!targetProp) { + context.onError( + createSSRCompilerError(SSRErrorCodes.X_SSR_NO_PORTAL_TARGET, node.loc) + ) + return + } + + let target: JSChildNode + if (targetProp.type === NodeTypes.ATTRIBUTE && targetProp.value) { + target = createSimpleExpression(targetProp.value.content, true) + } else if (targetProp.type === NodeTypes.DIRECTIVE && targetProp.exp) { + target = targetProp.exp + } else { + context.onError( + createSSRCompilerError( + SSRErrorCodes.X_SSR_NO_PORTAL_TARGET, + targetProp.loc + ) + ) + return + } + + const contentRenderFn = createFunctionExpression( + [`_push`], + undefined, // Body is added later + true, // newline + false, // isSlot + node.loc + ) + contentRenderFn.body = processChildrenAsStatement(node.children, context) + context.pushStatement( + createCallExpression(context.helper(SSR_RENDER_PORTAL), [ + contentRenderFn, + target, + `_parent` + ]) + ) +} diff --git a/packages/compiler-ssr/src/transforms/ssrTransformSuspense.ts b/packages/compiler-ssr/src/transforms/ssrTransformSuspense.ts new file mode 100644 index 00000000000..2a948d6fadc --- /dev/null +++ b/packages/compiler-ssr/src/transforms/ssrTransformSuspense.ts @@ -0,0 +1,78 @@ +import { + ComponentNode, + TransformContext, + buildSlots, + createFunctionExpression, + FunctionExpression, + TemplateChildNode, + createCallExpression, + SlotsExpression +} from '@vue/compiler-dom' +import { + SSRTransformContext, + processChildrenAsStatement +} from '../ssrCodegenTransform' +import { SSR_RENDER_SUSPENSE } from '../runtimeHelpers' + +const wipMap = new WeakMap() + +interface WIPEntry { + slotsExp: SlotsExpression + wipSlots: Array<{ + fn: FunctionExpression + children: TemplateChildNode[] + }> +} + +// phase 1 +export function ssrTransformSuspense( + node: ComponentNode, + context: TransformContext +) { + return () => { + if (node.children.length) { + const wipEntry: WIPEntry = { + slotsExp: null as any, + wipSlots: [] + } + wipMap.set(node, wipEntry) + wipEntry.slotsExp = buildSlots(node, context, (_props, children, loc) => { + const fn = createFunctionExpression( + [`_push`], + undefined, // no return, assign body later + true, // newline + false, // suspense slots are not treated as normal slots + loc + ) + wipEntry.wipSlots.push({ + fn, + children + }) + return fn + }).slots + } + } +} + +// phase 2 +export function ssrProcessSuspense( + node: ComponentNode, + context: SSRTransformContext +) { + // complete wip slots with ssr code + const wipEntry = wipMap.get(node) + if (!wipEntry) { + return + } + const { slotsExp, wipSlots } = wipEntry + for (let i = 0; i < wipSlots.length; i++) { + const { fn, children } = wipSlots[i] + fn.body = processChildrenAsStatement(children, context) + } + // _push(ssrRenderSuspense(slots)) + context.pushStatement( + createCallExpression(`_push`, [ + createCallExpression(context.helper(SSR_RENDER_SUSPENSE), [slotsExp]) + ]) + ) +} diff --git a/packages/server-renderer/__tests__/ssrSuspense.spec.ts b/packages/server-renderer/__tests__/ssrSuspense.spec.ts index c4c3b2d5a9f..893c69efab8 100644 --- a/packages/server-renderer/__tests__/ssrSuspense.spec.ts +++ b/packages/server-renderer/__tests__/ssrSuspense.spec.ts @@ -1,5 +1,7 @@ import { createApp, h, Suspense } from 'vue' import { renderToString } from '../src/renderToString' +import { ssrRenderSuspense } from '../src/helpers/ssrRenderSuspense' +import { ssrRenderComponent } from '../src' describe('SSR Suspense', () => { let logError: jest.SpyInstance @@ -24,103 +26,163 @@ describe('SSR Suspense', () => { } } - test('render', async () => { - const Comp = { - render() { - return h(Suspense, null, { - default: h(ResolvingAsync), - fallback: h('div', 'fallback') - }) - } - } + describe('compiled', () => { + test('basic', async () => { + const app = createApp({ + ssrRender(_ctx, _push) { + _push( + ssrRenderSuspense({ + default: _push => { + _push('
async
') + } + }) + ) + } + }) + + expect(await renderToString(app)).toBe(`
async
`) + expect(logError).not.toHaveBeenCalled() + }) + + test('with async component', async () => { + const app = createApp({ + ssrRender(_ctx, _push) { + _push( + ssrRenderSuspense({ + default: _push => { + _push(ssrRenderComponent(ResolvingAsync)) + } + }) + ) + } + }) + + expect(await renderToString(app)).toBe(`
async
`) + expect(logError).not.toHaveBeenCalled() + }) + + test('fallback', async () => { + const app = createApp({ + ssrRender(_ctx, _push) { + _push( + ssrRenderSuspense({ + default: _push => { + _push(ssrRenderComponent(RejectingAsync)) + }, + fallback: _push => { + _push('
fallback
') + } + }) + ) + } + }) - expect(await renderToString(createApp(Comp))).toBe(`
async
`) - expect(logError).not.toHaveBeenCalled() + expect(await renderToString(app)).toBe(`
fallback
`) + expect(logError).toHaveBeenCalled() + }) }) - test('fallback', async () => { - const Comp = { - render() { - return h(Suspense, null, { - default: h(RejectingAsync), - fallback: h('div', 'fallback') - }) + describe('vnode', () => { + test('content', async () => { + const Comp = { + render() { + return h(Suspense, null, { + default: h(ResolvingAsync), + fallback: h('div', 'fallback') + }) + } } - } - - expect(await renderToString(createApp(Comp))).toBe(`
fallback
`) - expect(logError).toHaveBeenCalled() - }) - test('2 components', async () => { - const Comp = { - render() { - return h(Suspense, null, { - default: h('div', [h(ResolvingAsync), h(ResolvingAsync)]), - fallback: h('div', 'fallback') - }) + expect(await renderToString(createApp(Comp))).toBe(`
async
`) + expect(logError).not.toHaveBeenCalled() + }) + + test('fallback', async () => { + const Comp = { + render() { + return h(Suspense, null, { + default: h(RejectingAsync), + fallback: h('div', 'fallback') + }) + } } - } - - expect(await renderToString(createApp(Comp))).toBe( - `
async
async
` - ) - expect(logError).not.toHaveBeenCalled() - }) - test('resolving component + rejecting component', async () => { - const Comp = { - render() { - return h(Suspense, null, { - default: h('div', [h(ResolvingAsync), h(RejectingAsync)]), - fallback: h('div', 'fallback') - }) + expect(await renderToString(createApp(Comp))).toBe(`
fallback
`) + expect(logError).toHaveBeenCalled() + }) + + test('2 components', async () => { + const Comp = { + render() { + return h(Suspense, null, { + default: h('div', [h(ResolvingAsync), h(ResolvingAsync)]), + fallback: h('div', 'fallback') + }) + } } - } - expect(await renderToString(createApp(Comp))).toBe(`
fallback
`) - expect(logError).toHaveBeenCalled() - }) - - test('failing suspense in passing suspense', async () => { - const Comp = { - render() { - return h(Suspense, null, { - default: h('div', [ - h(ResolvingAsync), - h(Suspense, null, { - default: h('div', [h(RejectingAsync)]), - fallback: h('div', 'fallback 2') - }) - ]), - fallback: h('div', 'fallback 1') - }) + expect(await renderToString(createApp(Comp))).toBe( + `
async
async
` + ) + expect(logError).not.toHaveBeenCalled() + }) + + test('resolving component + rejecting component', async () => { + const Comp = { + render() { + return h(Suspense, null, { + default: h('div', [h(ResolvingAsync), h(RejectingAsync)]), + fallback: h('div', 'fallback') + }) + } } - } - expect(await renderToString(createApp(Comp))).toBe( - `
async
fallback 2
` - ) - expect(logError).toHaveBeenCalled() - }) + expect(await renderToString(createApp(Comp))).toBe(`
fallback
`) + expect(logError).toHaveBeenCalled() + }) + + test('failing suspense in passing suspense', async () => { + const Comp = { + render() { + return h(Suspense, null, { + default: h('div', [ + h(ResolvingAsync), + h(Suspense, null, { + default: h('div', [h(RejectingAsync)]), + fallback: h('div', 'fallback 2') + }) + ]), + fallback: h('div', 'fallback 1') + }) + } + } - test('passing suspense in failing suspense', async () => { - const Comp = { - render() { - return h(Suspense, null, { - default: h('div', [ - h(RejectingAsync), - h(Suspense, null, { - default: h('div', [h(ResolvingAsync)]), - fallback: h('div', 'fallback 2') - }) - ]), - fallback: h('div', 'fallback 1') - }) + expect(await renderToString(createApp(Comp))).toBe( + `
async
fallback 2
` + ) + expect(logError).toHaveBeenCalled() + }) + + test('passing suspense in failing suspense', async () => { + const Comp = { + render() { + return h(Suspense, null, { + default: h('div', [ + h(RejectingAsync), + h(Suspense, null, { + default: h('div', [h(ResolvingAsync)]), + fallback: h('div', 'fallback 2') + }) + ]), + fallback: h('div', 'fallback 1') + }) + } } - } - expect(await renderToString(createApp(Comp))).toBe(`
fallback 1
`) - expect(logError).toHaveBeenCalled() + expect(await renderToString(createApp(Comp))).toBe( + `
fallback 1
` + ) + expect(logError).toHaveBeenCalled() + }) }) }) diff --git a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts new file mode 100644 index 00000000000..efb4bcd9c41 --- /dev/null +++ b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts @@ -0,0 +1,19 @@ +import { PushFn, ResolvedSSRBuffer, createBuffer } from '../renderToString' +import { NOOP } from '@vue/shared' + +type ContentRenderFn = (push: PushFn) => void + +export async function ssrRenderSuspense({ + default: renderContent = NOOP, + fallback: renderFallback = NOOP +}: Record): Promise { + try { + const { push, getBuffer } = createBuffer() + renderContent(push) + return await getBuffer() + } catch { + const { push, getBuffer } = createBuffer() + renderFallback(push) + return getBuffer() + } +} diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts index 315b3ae18c7..48915c63f61 100644 --- a/packages/server-renderer/src/index.ts +++ b/packages/server-renderer/src/index.ts @@ -14,6 +14,7 @@ export { export { ssrInterpolate } from './helpers/ssrInterpolate' export { ssrRenderList } from './helpers/ssrRenderList' export { ssrRenderPortal } from './helpers/ssrRenderPortal' +export { ssrRenderSuspense } from './helpers/ssrRenderSuspense' // v-model helpers export {