diff --git a/packages/compiler-ssr/__tests__/ssrComponent.spec.ts b/packages/compiler-ssr/__tests__/ssrComponent.spec.ts
index 4414ad3a..89e4b1d8 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 00000000..5490649d
--- /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 00000000..7f38f513
--- /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(`
+
+
+
+
+ loading...
+
+ `).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 01875cf0..bfd83708 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 0e3f1457..1acf45ee 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 00000000..c380e672
--- /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 00000000..2a948d6f
--- /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 c4c3b2d5..893c69ef 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')
- })
- }
- }
-
- 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(`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(
- ``
- )
- 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('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')
+ describe('compiled', () => {
+ test('basic', async () => {
+ const app = createApp({
+ ssrRender(_ctx, _push) {
+ _push(
+ ssrRenderSuspense({
+ default: _push => {
+ _push('async
')
+ }
})
- ]),
- fallback: h('div', 'fallback 1')
- })
- }
- }
+ )
+ }
+ })
- expect(await renderToString(createApp(Comp))).toBe(
- ``
- )
- expect(logError).toHaveBeenCalled()
+ 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(app)).toBe(`fallback
`)
+ 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')
- })
+ 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 1
`)
- expect(logError).toHaveBeenCalled()
+ 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(`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(
+ ``
+ )
+ 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('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(
+ ``
+ )
+ 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()
+ })
})
})
diff --git a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts
new file mode 100644
index 00000000..efb4bcd9
--- /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 315b3ae1..48915c63 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 {