diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts
index e54063fdb37..007ceec925a 100644
--- a/packages/runtime-core/__tests__/hydration.spec.ts
+++ b/packages/runtime-core/__tests__/hydration.spec.ts
@@ -11,9 +11,8 @@ import {
defineAsyncComponent,
defineComponent
} from '@vue/runtime-dom'
-import { renderToString } from '@vue/server-renderer'
+import { renderToString, SSRContext } from '@vue/server-renderer'
import { mockWarn } from '@vue/shared'
-import { SSRContext } from 'packages/server-renderer/src/renderToString'
function mountWithHydration(html: string, render: () => any) {
const container = document.createElement('div')
diff --git a/packages/server-renderer/__tests__/renderToStream.spec.ts b/packages/server-renderer/__tests__/renderToStream.spec.ts
new file mode 100644
index 00000000000..8d8e45409ba
--- /dev/null
+++ b/packages/server-renderer/__tests__/renderToStream.spec.ts
@@ -0,0 +1,605 @@
+import {
+ createApp,
+ h,
+ createCommentVNode,
+ withScopeId,
+ resolveComponent,
+ ComponentOptions,
+ ref,
+ defineComponent,
+ createTextVNode,
+ createStaticVNode
+} from 'vue'
+import { escapeHtml, mockWarn } from '@vue/shared'
+import { renderToStream as _renderToStream } from '../src/renderToStream'
+import { Readable } from 'stream'
+import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot'
+import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent'
+
+mockWarn()
+
+const promisifyStream = (stream: Readable) => {
+ return new Promise((resolve, reject) => {
+ let result = ''
+ stream.on('data', data => {
+ result += data
+ })
+ stream.on('error', () => {
+ reject(result)
+ })
+ stream.on('end', () => {
+ resolve(result)
+ })
+ })
+}
+
+const renderToStream = (app: any, context?: any) =>
+ promisifyStream(_renderToStream(app, context))
+
+describe('ssr: renderToStream', () => {
+ test('should apply app context', async () => {
+ const app = createApp({
+ render() {
+ const Foo = resolveComponent('foo') as ComponentOptions
+ return h(Foo)
+ }
+ })
+ app.component('foo', {
+ render: () => h('div', 'foo')
+ })
+ const html = await renderToStream(app)
+ expect(html).toBe(`
foo
`)
+ })
+
+ describe('components', () => {
+ test('vnode components', async () => {
+ expect(
+ await renderToStream(
+ createApp({
+ data() {
+ return { msg: 'hello' }
+ },
+ render(this: any) {
+ return h('div', this.msg)
+ }
+ })
+ )
+ ).toBe(`hello
`)
+ })
+
+ test('option components returning render from setup', async () => {
+ expect(
+ await renderToStream(
+ createApp({
+ setup() {
+ const msg = ref('hello')
+ return () => h('div', msg.value)
+ }
+ })
+ )
+ ).toBe(`hello
`)
+ })
+
+ test('setup components returning render from setup', async () => {
+ expect(
+ await renderToStream(
+ createApp(
+ defineComponent((props: {}) => {
+ const msg = ref('hello')
+ return () => h('div', msg.value)
+ })
+ )
+ )
+ ).toBe(`hello
`)
+ })
+
+ test('optimized components', async () => {
+ expect(
+ await renderToStream(
+ createApp({
+ data() {
+ return { msg: 'hello' }
+ },
+ ssrRender(ctx, push) {
+ push(`${ctx.msg}
`)
+ }
+ })
+ )
+ ).toBe(`hello
`)
+ })
+
+ describe('template components', () => {
+ test('render', async () => {
+ expect(
+ await renderToStream(
+ createApp({
+ data() {
+ return { msg: 'hello' }
+ },
+ template: `{{ msg }}
`
+ })
+ )
+ ).toBe(`hello
`)
+ })
+
+ test('handle compiler errors', async () => {
+ await renderToStream(createApp({ template: `<` }))
+
+ expect(
+ 'Template compilation error: Unexpected EOF in tag.\n' +
+ '1 | <\n' +
+ ' | ^'
+ ).toHaveBeenWarned()
+ })
+ })
+
+ test('nested vnode components', async () => {
+ const Child = {
+ props: ['msg'],
+ render(this: any) {
+ return h('div', this.msg)
+ }
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ render() {
+ return h('div', ['parent', h(Child, { msg: 'hello' })])
+ }
+ })
+ )
+ ).toBe(``)
+ })
+
+ test('nested optimized components', async () => {
+ const Child = {
+ props: ['msg'],
+ ssrRender(ctx: any, push: any) {
+ push(`${ctx.msg}
`)
+ }
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(``)
+ })
+
+ test('nested template components', async () => {
+ const Child = {
+ props: ['msg'],
+ template: `{{ msg }}
`
+ }
+ const app = createApp({
+ template: `parent
`
+ })
+ app.component('Child', Child)
+
+ expect(await renderToStream(app)).toBe(
+ ``
+ )
+ })
+
+ test('mixing optimized / vnode / template components', async () => {
+ const OptimizedChild = {
+ props: ['msg'],
+ ssrRender(ctx: any, push: any) {
+ push(`${ctx.msg}
`)
+ }
+ }
+
+ const VNodeChild = {
+ props: ['msg'],
+ render(this: any) {
+ return h('div', this.msg)
+ }
+ }
+
+ const TemplateChild = {
+ props: ['msg'],
+ template: `{{ msg }}
`
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(
+ ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+ )
+ push(
+ ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
+ )
+ push(
+ ssrRenderComponent(
+ TemplateChild,
+ { msg: 'template' },
+ null,
+ parent
+ )
+ )
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(
+ ``
+ )
+ })
+
+ test('nested components with optimized slots', async () => {
+ const Child = {
+ props: ['msg'],
+ ssrRender(ctx: any, push: any, parent: any) {
+ push(``)
+ ssrRenderSlot(
+ ctx.$slots,
+ 'default',
+ { msg: 'from slot' },
+ () => {
+ push(`fallback`)
+ },
+ push,
+ parent
+ )
+ push(`
`)
+ }
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(
+ ssrRenderComponent(
+ Child,
+ { msg: 'hello' },
+ {
+ // optimized slot using string push
+ default: ({ msg }: any, push: any, p: any) => {
+ push(`${msg}`)
+ },
+ // important to avoid slots being normalized
+ _: 1 as any
+ },
+ parent
+ )
+ )
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(
+ `parent
` +
+ `from slot` +
+ `
`
+ )
+
+ // test fallback
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(
+ ``
+ )
+ })
+
+ test('nested components with vnode slots', async () => {
+ const Child = {
+ props: ['msg'],
+ ssrRender(ctx: any, push: any, parent: any) {
+ push(``)
+ ssrRenderSlot(
+ ctx.$slots,
+ 'default',
+ { msg: 'from slot' },
+ null,
+ push,
+ parent
+ )
+ push(`
`)
+ }
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(
+ ssrRenderComponent(
+ Child,
+ { msg: 'hello' },
+ {
+ // bailed slots returning raw vnodes
+ default: ({ msg }: any) => {
+ return h('span', msg)
+ }
+ },
+ parent
+ )
+ )
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(
+ `parent
` +
+ `from slot` +
+ `
`
+ )
+ })
+
+ test('nested components with template slots', async () => {
+ const Child = {
+ props: ['msg'],
+ template: `
`
+ }
+
+ const app = createApp({
+ components: { Child },
+ template: `parent{{ msg }}
`
+ })
+
+ expect(await renderToStream(app)).toBe(
+ `parent
` +
+ `from slot` +
+ `
`
+ )
+ })
+
+ test('nested render fn components with template slots', async () => {
+ const Child = {
+ props: ['msg'],
+ render(this: any) {
+ return h(
+ 'div',
+ {
+ class: 'child'
+ },
+ this.$slots.default({ msg: 'from slot' })
+ )
+ }
+ }
+
+ const app = createApp({
+ template: `parent{{ msg }}
`
+ })
+ app.component('Child', Child)
+
+ expect(await renderToStream(app)).toBe(
+ `parent
` +
+ // no comment anchors because slot is used directly as element children
+ `from slot` +
+ `
`
+ )
+ })
+
+ test('async components', async () => {
+ const Child = {
+ // should wait for resolved render context from setup()
+ async setup() {
+ return {
+ msg: 'hello'
+ }
+ },
+ ssrRender(ctx: any, push: any) {
+ push(`${ctx.msg}
`)
+ }
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(ssrRenderComponent(Child, null, null, parent))
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(``)
+ })
+
+ test('parallel async components', async () => {
+ const OptimizedChild = {
+ props: ['msg'],
+ async setup(props: any) {
+ return {
+ localMsg: props.msg + '!'
+ }
+ },
+ ssrRender(ctx: any, push: any) {
+ push(`${ctx.localMsg}
`)
+ }
+ }
+
+ const VNodeChild = {
+ props: ['msg'],
+ async setup(props: any) {
+ return {
+ localMsg: props.msg + '!'
+ }
+ },
+ render(this: any) {
+ return h('div', this.localMsg)
+ }
+ }
+
+ expect(
+ await renderToStream(
+ createApp({
+ ssrRender(_ctx, push, parent) {
+ push(`parent`)
+ push(
+ ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+ )
+ push(
+ ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
+ )
+ push(`
`)
+ }
+ })
+ )
+ ).toBe(``)
+ })
+ })
+
+ describe('vnode element', () => {
+ test('props', async () => {
+ expect(
+ await renderToStream(
+ h('div', { id: 'foo&', class: ['bar', 'baz'] }, 'hello')
+ )
+ ).toBe(`hello
`)
+ })
+
+ test('text children', async () => {
+ expect(await renderToStream(h('div', 'hello'))).toBe(`hello
`)
+ })
+
+ test('array children', async () => {
+ expect(
+ await renderToStream(
+ h('div', [
+ 'foo',
+ h('span', 'bar'),
+ [h('span', 'baz')],
+ createCommentVNode('qux')
+ ])
+ )
+ ).toBe(
+ `foobarbaz
`
+ )
+ })
+
+ test('void elements', async () => {
+ expect(await renderToStream(h('input'))).toBe(``)
+ })
+
+ test('innerHTML', async () => {
+ expect(
+ await renderToStream(
+ h(
+ 'div',
+ {
+ innerHTML: `hello`
+ },
+ 'ignored'
+ )
+ )
+ ).toBe(`hello
`)
+ })
+
+ test('textContent', async () => {
+ expect(
+ await renderToStream(
+ h(
+ 'div',
+ {
+ textContent: `hello`
+ },
+ 'ignored'
+ )
+ )
+ ).toBe(`${escapeHtml(`hello`)}
`)
+ })
+
+ test('textarea value', async () => {
+ expect(
+ await renderToStream(
+ h(
+ 'textarea',
+ {
+ value: `hello`
+ },
+ 'ignored'
+ )
+ )
+ ).toBe(``)
+ })
+ })
+
+ describe('raw vnode types', () => {
+ test('Text', async () => {
+ expect(await renderToStream(createTextVNode('hello '))).toBe(
+ `hello <div>`
+ )
+ })
+
+ test('Comment', async () => {
+ // https://www.w3.org/TR/html52/syntax.html#comments
+ expect(
+ await renderToStream(
+ h('div', [
+ createCommentVNode('>foo'),
+ createCommentVNode('->foo'),
+ createCommentVNode(''),
+ createCommentVNode('--!>foo
`)
+ })
+
+ test('Static', async () => {
+ const content = `helloworld
`
+ expect(await renderToStream(createStaticVNode(content, 1))).toBe(content)
+ })
+ })
+
+ describe('scopeId', () => {
+ // note: here we are only testing scopeId handling for vdom serialization.
+ // compiled srr render functions will include scopeId directly in strings.
+ const withId = withScopeId('data-v-test')
+ const withChildId = withScopeId('data-v-child')
+
+ test('basic', async () => {
+ expect(
+ await renderToStream(
+ withId(() => {
+ return h('div')
+ })()
+ )
+ ).toBe(``)
+ })
+
+ test('with slots', async () => {
+ const Child = {
+ __scopeId: 'data-v-child',
+ render: withChildId(function(this: any) {
+ return h('div', this.$slots.default())
+ })
+ }
+
+ const Parent = {
+ __scopeId: 'data-v-test',
+ render: withId(() => {
+ return h(Child, null, {
+ default: withId(() => h('span', 'slot'))
+ })
+ })
+ }
+
+ expect(await renderToStream(h(Parent))).toBe(
+ `slot
`
+ )
+ })
+ })
+})
diff --git a/packages/server-renderer/__tests__/renderToString.spec.ts b/packages/server-renderer/__tests__/renderToString.spec.ts
index b034312c30b..f628d991834 100644
--- a/packages/server-renderer/__tests__/renderToString.spec.ts
+++ b/packages/server-renderer/__tests__/renderToString.spec.ts
@@ -11,8 +11,9 @@ import {
createStaticVNode
} from 'vue'
import { escapeHtml, mockWarn } from '@vue/shared'
-import { renderToString, renderComponent } from '../src/renderToString'
+import { renderToString } from '../src/renderToString'
import { ssrRenderSlot } from '../src/helpers/ssrRenderSlot'
+import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent'
mockWarn()
@@ -145,7 +146,7 @@ describe('ssr: renderToString', () => {
createApp({
ssrRender(_ctx, push, parent) {
push(`parent`)
- push(renderComponent(Child, { msg: 'hello' }, null, parent))
+ push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
push(`
`)
}
})
@@ -194,11 +195,13 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`parent`)
push(
- renderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+ ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
)
- push(renderComponent(VNodeChild, { msg: 'vnode' }, null, parent))
push(
- renderComponent(
+ ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
+ )
+ push(
+ ssrRenderComponent(
TemplateChild,
{ msg: 'template' },
null,
@@ -239,7 +242,7 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`
parent`)
push(
- renderComponent(
+ ssrRenderComponent(
Child,
{ msg: 'hello' },
{
@@ -269,7 +272,7 @@ describe('ssr: renderToString', () => {
createApp({
ssrRender(_ctx, push, parent) {
push(`
parent`)
- push(renderComponent(Child, { msg: 'hello' }, null, parent))
+ push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
push(`
`)
}
})
@@ -302,7 +305,7 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`
parent`)
push(
- renderComponent(
+ ssrRenderComponent(
Child,
{ msg: 'hello' },
{
@@ -388,7 +391,7 @@ describe('ssr: renderToString', () => {
createApp({
ssrRender(_ctx, push, parent) {
push(`
parent`)
- push(renderComponent(Child, null, null, parent))
+ push(ssrRenderComponent(Child, null, null, parent))
push(`
`)
}
})
@@ -427,9 +430,11 @@ describe('ssr: renderToString', () => {
ssrRender(_ctx, push, parent) {
push(`
parent`)
push(
- renderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+ ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+ )
+ push(
+ ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
)
- push(renderComponent(VNodeChild, { msg: 'vnode' }, null, parent))
push(`
`)
}
})
diff --git a/packages/server-renderer/__tests__/ssrTeleport.spec.ts b/packages/server-renderer/__tests__/ssrTeleport.spec.ts
index c990e600643..1dd4aa65a63 100644
--- a/packages/server-renderer/__tests__/ssrTeleport.spec.ts
+++ b/packages/server-renderer/__tests__/ssrTeleport.spec.ts
@@ -1,5 +1,6 @@
import { createApp, h, Teleport } from 'vue'
-import { renderToString, SSRContext } from '../src/renderToString'
+import { renderToString } from '../src/renderToString'
+import { SSRContext } from '../src/render'
import { ssrRenderTeleport } from '../src/helpers/ssrRenderTeleport'
describe('ssrRenderTeleport', () => {
diff --git a/packages/server-renderer/src/helpers/ssrCompile.ts b/packages/server-renderer/src/helpers/ssrCompile.ts
new file mode 100644
index 00000000000..4f9d50625da
--- /dev/null
+++ b/packages/server-renderer/src/helpers/ssrCompile.ts
@@ -0,0 +1,46 @@
+import { ComponentInternalInstance, warn } from 'vue'
+import { compile } from '@vue/compiler-ssr'
+import { generateCodeFrame, NO } from '@vue/shared'
+import { CompilerError } from '@vue/compiler-core'
+import { PushFn } from '../render'
+
+type SSRRenderFunction = (
+ context: any,
+ push: PushFn,
+ parentInstance: ComponentInternalInstance
+) => void
+
+const compileCache: Record
= Object.create(null)
+
+export function ssrCompile(
+ template: string,
+ instance: ComponentInternalInstance
+): SSRRenderFunction {
+ const cached = compileCache[template]
+ if (cached) {
+ return cached
+ }
+
+ const { code } = compile(template, {
+ isCustomElement: instance.appContext.config.isCustomElement || NO,
+ isNativeTag: instance.appContext.config.isNativeTag || NO,
+ onError(err: CompilerError) {
+ if (__DEV__) {
+ const message = `[@vue/server-renderer] Template compilation error: ${
+ err.message
+ }`
+ const codeFrame =
+ err.loc &&
+ generateCodeFrame(
+ template as string,
+ err.loc.start.offset,
+ err.loc.end.offset
+ )
+ warn(codeFrame ? `${message}\n${codeFrame}` : message)
+ } else {
+ throw err
+ }
+ }
+ })
+ return (compileCache[template] = Function('require', code)(require))
+}
diff --git a/packages/server-renderer/src/helpers/ssrRenderComponent.ts b/packages/server-renderer/src/helpers/ssrRenderComponent.ts
new file mode 100644
index 00000000000..000f2b482ed
--- /dev/null
+++ b/packages/server-renderer/src/helpers/ssrRenderComponent.ts
@@ -0,0 +1,15 @@
+import { Component, ComponentInternalInstance, createVNode, Slots } from 'vue'
+import { Props, renderComponentVNode, SSRBuffer } from '../render'
+import { SSRSlots } from './ssrRenderSlot'
+
+export function ssrRenderComponent(
+ comp: Component,
+ props: Props | null = null,
+ children: Slots | SSRSlots | null = null,
+ parentComponent: ComponentInternalInstance | null = null
+): SSRBuffer | Promise {
+ return renderComponentVNode(
+ createVNode(comp, props, children),
+ parentComponent
+ )
+}
diff --git a/packages/server-renderer/src/helpers/ssrRenderSlot.ts b/packages/server-renderer/src/helpers/ssrRenderSlot.ts
index 64d321827cf..8c96322ab0f 100644
--- a/packages/server-renderer/src/helpers/ssrRenderSlot.ts
+++ b/packages/server-renderer/src/helpers/ssrRenderSlot.ts
@@ -1,8 +1,7 @@
-import { Props, PushFn, renderVNodeChildren } from '../renderToString'
import { ComponentInternalInstance, Slot, Slots } from 'vue'
+import { Props, PushFn, renderVNodeChildren } from '../render'
export type SSRSlots = Record
-
export type SSRSlot = (
props: Props,
push: PushFn,
diff --git a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts
index 97586988c7b..3d6df47fef7 100644
--- a/packages/server-renderer/src/helpers/ssrRenderSuspense.ts
+++ b/packages/server-renderer/src/helpers/ssrRenderSuspense.ts
@@ -1,4 +1,4 @@
-import { PushFn } from '../renderToString'
+import { PushFn } from '../render'
export async function ssrRenderSuspense(
push: PushFn,
diff --git a/packages/server-renderer/src/helpers/ssrRenderTeleport.ts b/packages/server-renderer/src/helpers/ssrRenderTeleport.ts
index 66772265d68..77331b7bddd 100644
--- a/packages/server-renderer/src/helpers/ssrRenderTeleport.ts
+++ b/packages/server-renderer/src/helpers/ssrRenderTeleport.ts
@@ -1,10 +1,5 @@
import { ComponentInternalInstance, ssrContextKey } from 'vue'
-import {
- SSRContext,
- createBuffer,
- PushFn,
- SSRBufferItem
-} from '../renderToString'
+import { createBuffer, PushFn, SSRBufferItem, SSRContext } from '../render'
export function ssrRenderTeleport(
parentPush: PushFn,
diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts
index 372fa08e587..723f964ed1d 100644
--- a/packages/server-renderer/src/index.ts
+++ b/packages/server-renderer/src/index.ts
@@ -1,9 +1,12 @@
// public
-export { renderToString, SSRContext } from './renderToString'
+export { SSRContext } from './render'
+export { renderToString } from './renderToString'
+export { renderToStream } from './renderToStream'
// internal runtime helpers
-export { renderComponent as ssrRenderComponent } from './renderToString'
+export { ssrRenderComponent } from './helpers/ssrRenderComponent'
export { ssrRenderSlot } from './helpers/ssrRenderSlot'
+export { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
export {
ssrRenderClass,
ssrRenderStyle,
@@ -13,7 +16,6 @@ export {
} from './helpers/ssrRenderAttrs'
export { ssrInterpolate } from './helpers/ssrInterpolate'
export { ssrRenderList } from './helpers/ssrRenderList'
-export { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
export { ssrRenderSuspense } from './helpers/ssrRenderSuspense'
// v-model helpers
diff --git a/packages/server-renderer/src/render.ts b/packages/server-renderer/src/render.ts
new file mode 100644
index 00000000000..15f052235b8
--- /dev/null
+++ b/packages/server-renderer/src/render.ts
@@ -0,0 +1,286 @@
+import {
+ Comment,
+ Component,
+ ComponentInternalInstance,
+ DirectiveBinding,
+ Fragment,
+ mergeProps,
+ ssrUtils,
+ Static,
+ Text,
+ VNode,
+ VNodeArrayChildren,
+ VNodeProps,
+ warn
+} from 'vue'
+import {
+ escapeHtml,
+ escapeHtmlComment,
+ isFunction,
+ isPromise,
+ isString,
+ isVoidTag,
+ ShapeFlags
+} from '@vue/shared'
+import { ssrRenderAttrs } from './helpers/ssrRenderAttrs'
+import { ssrCompile } from './helpers/ssrCompile'
+import { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
+
+const {
+ createComponentInstance,
+ setCurrentRenderingInstance,
+ setupComponent,
+ renderComponentRoot,
+ normalizeVNode,
+ normalizeSuspenseChildren
+} = ssrUtils
+
+export type SSRBuffer = SSRBufferItem[]
+export type SSRBufferItem = string | SSRBuffer | Promise
+export type PushFn = (item: SSRBufferItem) => void
+export type Props = Record
+
+export type SSRContext = {
+ [key: string]: any
+ teleports?: Record
+ __teleportBuffers?: Record
+}
+
+// Each component has a buffer array.
+// A buffer array can contain one of the following:
+// - plain string
+// - A resolved buffer (recursive arrays of strings that can be unrolled
+// synchronously)
+// - An async buffer (a Promise that resolves to a resolved buffer)
+export function createBuffer() {
+ let appendable = false
+ const buffer: SSRBuffer = []
+ return {
+ getBuffer(): SSRBuffer {
+ // Return static buffer and await on items during unroll stage
+ return buffer
+ },
+ push(item: SSRBufferItem) {
+ const isStringItem = isString(item)
+ if (appendable && isStringItem) {
+ buffer[buffer.length - 1] += item as string
+ } else {
+ buffer.push(item)
+ }
+ appendable = isStringItem
+ }
+ }
+}
+
+export function renderComponentVNode(
+ vnode: VNode,
+ parentComponent: ComponentInternalInstance | null = null
+): SSRBuffer | Promise {
+ const instance = createComponentInstance(vnode, parentComponent, null)
+ const res = setupComponent(instance, true /* isSSR */)
+ if (isPromise(res)) {
+ return res
+ .catch(err => {
+ warn(`[@vue/server-renderer]: Uncaught error in async setup:\n`, err)
+ })
+ .then(() => renderComponentSubTree(instance))
+ } else {
+ return renderComponentSubTree(instance)
+ }
+}
+
+function renderComponentSubTree(
+ instance: ComponentInternalInstance
+): SSRBuffer | Promise {
+ const comp = instance.type as Component
+ const { getBuffer, push } = createBuffer()
+ if (isFunction(comp)) {
+ renderVNode(push, renderComponentRoot(instance), instance)
+ } else {
+ if (!instance.render && !comp.ssrRender && isString(comp.template)) {
+ comp.ssrRender = ssrCompile(comp.template, instance)
+ }
+
+ if (comp.ssrRender) {
+ // optimized
+ // set current rendering instance for asset resolution
+ setCurrentRenderingInstance(instance)
+ comp.ssrRender(instance.proxy, push, instance)
+ setCurrentRenderingInstance(null)
+ } else if (instance.render) {
+ renderVNode(push, renderComponentRoot(instance), instance)
+ } else {
+ warn(
+ `Component ${
+ comp.name ? `${comp.name} ` : ``
+ } is missing template or render function.`
+ )
+ push(``)
+ }
+ }
+ return getBuffer()
+}
+
+function renderVNode(
+ push: PushFn,
+ vnode: VNode,
+ parentComponent: ComponentInternalInstance
+) {
+ const { type, shapeFlag, children } = vnode
+ switch (type) {
+ case Text:
+ push(escapeHtml(children as string))
+ break
+ case Comment:
+ push(
+ children ? `` : ``
+ )
+ break
+ case Static:
+ push(children as string)
+ break
+ case Fragment:
+ push(``) // open
+ renderVNodeChildren(push, children as VNodeArrayChildren, parentComponent)
+ push(``) // close
+ break
+ default:
+ if (shapeFlag & ShapeFlags.ELEMENT) {
+ renderElementVNode(push, vnode, parentComponent)
+ } else if (shapeFlag & ShapeFlags.COMPONENT) {
+ push(renderComponentVNode(vnode, parentComponent))
+ } else if (shapeFlag & ShapeFlags.TELEPORT) {
+ renderTeleportVNode(push, vnode, parentComponent)
+ } else if (shapeFlag & ShapeFlags.SUSPENSE) {
+ renderVNode(
+ push,
+ normalizeSuspenseChildren(vnode).content,
+ parentComponent
+ )
+ } else {
+ warn(
+ '[@vue/server-renderer] Invalid VNode type:',
+ type,
+ `(${typeof type})`
+ )
+ }
+ }
+}
+
+export function renderVNodeChildren(
+ push: PushFn,
+ children: VNodeArrayChildren,
+ parentComponent: ComponentInternalInstance
+) {
+ for (let i = 0; i < children.length; i++) {
+ renderVNode(push, normalizeVNode(children[i]), parentComponent)
+ }
+}
+
+function renderElementVNode(
+ push: PushFn,
+ vnode: VNode,
+ parentComponent: ComponentInternalInstance
+) {
+ const tag = vnode.type as string
+ let { props, children, shapeFlag, scopeId, dirs } = vnode
+ let openTag = `<${tag}`
+
+ if (dirs) {
+ props = applySSRDirectives(vnode, props, dirs)
+ }
+
+ if (props) {
+ openTag += ssrRenderAttrs(props, tag)
+ }
+
+ if (scopeId) {
+ openTag += ` ${scopeId}`
+ const treeOwnerId = parentComponent && parentComponent.type.__scopeId
+ // vnode's own scopeId and the current rendering component's scopeId is
+ // different - this is a slot content node.
+ if (treeOwnerId && treeOwnerId !== scopeId) {
+ openTag += ` ${treeOwnerId}-s`
+ }
+ }
+
+ push(openTag + `>`)
+ if (!isVoidTag(tag)) {
+ let hasChildrenOverride = false
+ if (props) {
+ if (props.innerHTML) {
+ hasChildrenOverride = true
+ push(props.innerHTML)
+ } else if (props.textContent) {
+ hasChildrenOverride = true
+ push(escapeHtml(props.textContent))
+ } else if (tag === 'textarea' && props.value) {
+ hasChildrenOverride = true
+ push(escapeHtml(props.value))
+ }
+ }
+ if (!hasChildrenOverride) {
+ if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
+ push(escapeHtml(children as string))
+ } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
+ renderVNodeChildren(
+ push,
+ children as VNodeArrayChildren,
+ parentComponent
+ )
+ }
+ }
+ push(`${tag}>`)
+ }
+}
+
+function applySSRDirectives(
+ vnode: VNode,
+ rawProps: VNodeProps | null,
+ dirs: DirectiveBinding[]
+): VNodeProps {
+ const toMerge: VNodeProps[] = []
+ for (let i = 0; i < dirs.length; i++) {
+ const binding = dirs[i]
+ const {
+ dir: { getSSRProps }
+ } = binding
+ if (getSSRProps) {
+ const props = getSSRProps(binding, vnode)
+ if (props) toMerge.push(props)
+ }
+ }
+ return mergeProps(rawProps || {}, ...toMerge)
+}
+
+function renderTeleportVNode(
+ push: PushFn,
+ vnode: VNode,
+ parentComponent: ComponentInternalInstance
+) {
+ const target = vnode.props && vnode.props.to
+ const disabled = vnode.props && vnode.props.disabled
+ if (!target) {
+ warn(`[@vue/server-renderer] Teleport is missing target prop.`)
+ return []
+ }
+ if (!isString(target)) {
+ warn(
+ `[@vue/server-renderer] Teleport target must be a query selector string.`
+ )
+ return []
+ }
+ ssrRenderTeleport(
+ push,
+ push => {
+ renderVNodeChildren(
+ push,
+ vnode.children as VNodeArrayChildren,
+ parentComponent
+ )
+ },
+ target,
+ disabled || disabled === '',
+ parentComponent
+ )
+}
diff --git a/packages/server-renderer/src/renderToStream.ts b/packages/server-renderer/src/renderToStream.ts
new file mode 100644
index 00000000000..63b38952254
--- /dev/null
+++ b/packages/server-renderer/src/renderToStream.ts
@@ -0,0 +1,59 @@
+import {
+ App,
+ VNode,
+ createVNode,
+ ssrUtils,
+ createApp,
+ ssrContextKey
+} from 'vue'
+import { isString, isPromise } from '@vue/shared'
+import { renderComponentVNode, SSRBuffer, SSRContext } from './render'
+import { Readable } from 'stream'
+
+const { isVNode } = ssrUtils
+
+async function unrollBuffer(
+ buffer: SSRBuffer,
+ stream: Readable
+): Promise {
+ for (let i = 0; i < buffer.length; i++) {
+ let item = buffer[i]
+ if (isPromise(item)) {
+ item = await item
+ }
+ if (isString(item)) {
+ stream.push(item)
+ } else {
+ await unrollBuffer(item, stream)
+ }
+ }
+}
+
+export function renderToStream(
+ input: App | VNode,
+ context: SSRContext = {}
+): Readable {
+ if (isVNode(input)) {
+ // raw vnode, wrap with app (for context)
+ return renderToStream(createApp({ render: () => input }), context)
+ }
+
+ // rendering an app
+ const vnode = createVNode(input._component, input._props)
+ vnode.appContext = input._context
+ // provide the ssr context to the tree
+ input.provide(ssrContextKey, context)
+
+ const stream = new Readable()
+
+ Promise.resolve(renderComponentVNode(vnode))
+ .then(buffer => unrollBuffer(buffer, stream))
+ .then(() => {
+ stream.push(null)
+ })
+ .catch(error => {
+ stream.destroy(error)
+ })
+
+ return stream
+}
diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts
index 8720929b0e1..68051da82e8 100644
--- a/packages/server-renderer/src/renderToString.ts
+++ b/packages/server-renderer/src/renderToString.ts
@@ -1,109 +1,27 @@
import {
App,
- Component,
- ComponentInternalInstance,
- VNode,
- VNodeArrayChildren,
- createVNode,
- Text,
- Comment,
- Static,
- Fragment,
- ssrUtils,
- Slots,
createApp,
+ createVNode,
ssrContextKey,
- warn,
- DirectiveBinding,
- VNodeProps,
- mergeProps
+ ssrUtils,
+ VNode
} from 'vue'
-import {
- ShapeFlags,
- isString,
- isPromise,
- isArray,
- isFunction,
- isVoidTag,
- escapeHtml,
- NO,
- generateCodeFrame,
- escapeHtmlComment
-} from '@vue/shared'
-import { compile } from '@vue/compiler-ssr'
-import { ssrRenderAttrs } from './helpers/ssrRenderAttrs'
-import { SSRSlots } from './helpers/ssrRenderSlot'
-import { CompilerError } from '@vue/compiler-dom'
-import { ssrRenderTeleport } from './helpers/ssrRenderTeleport'
-
-const {
- isVNode,
- createComponentInstance,
- setCurrentRenderingInstance,
- setupComponent,
- renderComponentRoot,
- normalizeVNode,
- normalizeSuspenseChildren
-} = ssrUtils
-
-// Each component has a buffer array.
-// A buffer array can contain one of the following:
-// - plain string
-// - A resolved buffer (recursive arrays of strings that can be unrolled
-// synchronously)
-// - An async buffer (a Promise that resolves to a resolved buffer)
-export type SSRBuffer = SSRBufferItem[]
-export type SSRBufferItem =
- | string
- | ResolvedSSRBuffer
- | Promise
-export type ResolvedSSRBuffer = (string | ResolvedSSRBuffer)[]
-
-export type PushFn = (item: SSRBufferItem) => void
+import { isPromise, isString } from '@vue/shared'
+import { SSRContext, renderComponentVNode, SSRBuffer } from './render'
-export type Props = Record
+const { isVNode } = ssrUtils
-export type SSRContext = {
- [key: string]: any
- teleports?: Record
- __teleportBuffers?: Record
-}
-
-export function createBuffer() {
- let appendable = false
- let hasAsync = false
- const buffer: SSRBuffer = []
- return {
- getBuffer(): ResolvedSSRBuffer | Promise {
- // If the current component's buffer contains any Promise from async children,
- // then it must return a Promise too. Otherwise this is a component that
- // contains only sync children so we can avoid the async book-keeping overhead.
- return hasAsync ? Promise.all(buffer) : (buffer as ResolvedSSRBuffer)
- },
- push(item: SSRBufferItem) {
- const isStringItem = isString(item)
- if (appendable && isStringItem) {
- buffer[buffer.length - 1] += item as string
- } else {
- buffer.push(item)
- }
- appendable = isStringItem
- if (!isStringItem && !isArray(item)) {
- // promise
- hasAsync = true
- }
- }
- }
-}
-
-function unrollBuffer(buffer: ResolvedSSRBuffer): string {
+async function unrollBuffer(buffer: SSRBuffer): Promise {
let ret = ''
for (let i = 0; i < buffer.length; i++) {
- const item = buffer[i]
+ let item = buffer[i]
+ if (isPromise(item)) {
+ item = await item
+ }
if (isString(item)) {
ret += item
} else {
- ret += unrollBuffer(item)
+ ret += await unrollBuffer(item as SSRBuffer)
}
}
return ret
@@ -127,272 +45,7 @@ export async function renderToString(
await resolveTeleports(context)
- return unrollBuffer(buffer)
-}
-
-export function renderComponent(
- comp: Component,
- props: Props | null = null,
- children: Slots | SSRSlots | null = null,
- parentComponent: ComponentInternalInstance | null = null
-): ResolvedSSRBuffer | Promise {
- return renderComponentVNode(
- createVNode(comp, props, children),
- parentComponent
- )
-}
-
-function renderComponentVNode(
- vnode: VNode,
- parentComponent: ComponentInternalInstance | null = null
-): ResolvedSSRBuffer | Promise {
- const instance = createComponentInstance(vnode, parentComponent, null)
- const res = setupComponent(instance, true /* isSSR */)
- if (isPromise(res)) {
- return res
- .catch(err => {
- warn(`[@vue/server-renderer]: Uncaught error in async setup:\n`, err)
- })
- .then(() => renderComponentSubTree(instance))
- } else {
- return renderComponentSubTree(instance)
- }
-}
-
-function renderComponentSubTree(
- instance: ComponentInternalInstance
-): ResolvedSSRBuffer | Promise {
- const comp = instance.type as Component
- const { getBuffer, push } = createBuffer()
- if (isFunction(comp)) {
- renderVNode(push, renderComponentRoot(instance), instance)
- } else {
- if (!instance.render && !comp.ssrRender && isString(comp.template)) {
- comp.ssrRender = ssrCompile(comp.template, instance)
- }
-
- if (comp.ssrRender) {
- // optimized
- // set current rendering instance for asset resolution
- setCurrentRenderingInstance(instance)
- comp.ssrRender(instance.proxy, push, instance)
- setCurrentRenderingInstance(null)
- } else if (instance.render) {
- renderVNode(push, renderComponentRoot(instance), instance)
- } else {
- warn(
- `Component ${
- comp.name ? `${comp.name} ` : ``
- } is missing template or render function.`
- )
- push(``)
- }
- }
- return getBuffer()
-}
-
-type SSRRenderFunction = (
- context: any,
- push: (item: any) => void,
- parentInstance: ComponentInternalInstance
-) => void
-const compileCache: Record = Object.create(null)
-
-function ssrCompile(
- template: string,
- instance: ComponentInternalInstance
-): SSRRenderFunction {
- const cached = compileCache[template]
- if (cached) {
- return cached
- }
-
- const { code } = compile(template, {
- isCustomElement: instance.appContext.config.isCustomElement || NO,
- isNativeTag: instance.appContext.config.isNativeTag || NO,
- onError(err: CompilerError) {
- if (__DEV__) {
- const message = `[@vue/server-renderer] Template compilation error: ${
- err.message
- }`
- const codeFrame =
- err.loc &&
- generateCodeFrame(
- template as string,
- err.loc.start.offset,
- err.loc.end.offset
- )
- warn(codeFrame ? `${message}\n${codeFrame}` : message)
- } else {
- throw err
- }
- }
- })
- return (compileCache[template] = Function('require', code)(require))
-}
-
-function renderVNode(
- push: PushFn,
- vnode: VNode,
- parentComponent: ComponentInternalInstance
-) {
- const { type, shapeFlag, children } = vnode
- switch (type) {
- case Text:
- push(escapeHtml(children as string))
- break
- case Comment:
- push(
- children ? `` : ``
- )
- break
- case Static:
- push(children as string)
- break
- case Fragment:
- push(``) // open
- renderVNodeChildren(push, children as VNodeArrayChildren, parentComponent)
- push(``) // close
- break
- default:
- if (shapeFlag & ShapeFlags.ELEMENT) {
- renderElementVNode(push, vnode, parentComponent)
- } else if (shapeFlag & ShapeFlags.COMPONENT) {
- push(renderComponentVNode(vnode, parentComponent))
- } else if (shapeFlag & ShapeFlags.TELEPORT) {
- renderTeleportVNode(push, vnode, parentComponent)
- } else if (shapeFlag & ShapeFlags.SUSPENSE) {
- renderVNode(
- push,
- normalizeSuspenseChildren(vnode).content,
- parentComponent
- )
- } else {
- warn(
- '[@vue/server-renderer] Invalid VNode type:',
- type,
- `(${typeof type})`
- )
- }
- }
-}
-
-export function renderVNodeChildren(
- push: PushFn,
- children: VNodeArrayChildren,
- parentComponent: ComponentInternalInstance
-) {
- for (let i = 0; i < children.length; i++) {
- renderVNode(push, normalizeVNode(children[i]), parentComponent)
- }
-}
-
-function renderElementVNode(
- push: PushFn,
- vnode: VNode,
- parentComponent: ComponentInternalInstance
-) {
- const tag = vnode.type as string
- let { props, children, shapeFlag, scopeId, dirs } = vnode
- let openTag = `<${tag}`
-
- if (dirs) {
- props = applySSRDirectives(vnode, props, dirs)
- }
-
- if (props) {
- openTag += ssrRenderAttrs(props, tag)
- }
-
- if (scopeId) {
- openTag += ` ${scopeId}`
- const treeOwnerId = parentComponent && parentComponent.type.__scopeId
- // vnode's own scopeId and the current rendering component's scopeId is
- // different - this is a slot content node.
- if (treeOwnerId && treeOwnerId !== scopeId) {
- openTag += ` ${treeOwnerId}-s`
- }
- }
-
- push(openTag + `>`)
- if (!isVoidTag(tag)) {
- let hasChildrenOverride = false
- if (props) {
- if (props.innerHTML) {
- hasChildrenOverride = true
- push(props.innerHTML)
- } else if (props.textContent) {
- hasChildrenOverride = true
- push(escapeHtml(props.textContent))
- } else if (tag === 'textarea' && props.value) {
- hasChildrenOverride = true
- push(escapeHtml(props.value))
- }
- }
- if (!hasChildrenOverride) {
- if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
- push(escapeHtml(children as string))
- } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
- renderVNodeChildren(
- push,
- children as VNodeArrayChildren,
- parentComponent
- )
- }
- }
- push(`${tag}>`)
- }
-}
-
-function applySSRDirectives(
- vnode: VNode,
- rawProps: VNodeProps | null,
- dirs: DirectiveBinding[]
-): VNodeProps {
- const toMerge: VNodeProps[] = []
- for (let i = 0; i < dirs.length; i++) {
- const binding = dirs[i]
- const {
- dir: { getSSRProps }
- } = binding
- if (getSSRProps) {
- const props = getSSRProps(binding, vnode)
- if (props) toMerge.push(props)
- }
- }
- return mergeProps(rawProps || {}, ...toMerge)
-}
-
-function renderTeleportVNode(
- push: PushFn,
- vnode: VNode,
- parentComponent: ComponentInternalInstance
-) {
- const target = vnode.props && vnode.props.to
- const disabled = vnode.props && vnode.props.disabled
- if (!target) {
- warn(`[@vue/server-renderer] Teleport is missing target prop.`)
- return []
- }
- if (!isString(target)) {
- warn(
- `[@vue/server-renderer] Teleport target must be a query selector string.`
- )
- return []
- }
- ssrRenderTeleport(
- push,
- push => {
- renderVNodeChildren(
- push,
- vnode.children as VNodeArrayChildren,
- parentComponent
- )
- },
- target,
- disabled || disabled === '',
- parentComponent
- )
+ return unrollBuffer(buffer as SSRBuffer)
}
async function resolveTeleports(context: SSRContext) {
@@ -401,9 +54,9 @@ async function resolveTeleports(context: SSRContext) {
for (const key in context.__teleportBuffers) {
// note: it's OK to await sequentially here because the Promises were
// created eagerly in parallel.
- context.teleports[key] = unrollBuffer(
- await Promise.all(context.__teleportBuffers[key])
- )
+ context.teleports[key] = await unrollBuffer((await Promise.all(
+ context.__teleportBuffers[key]
+ )) as SSRBuffer)
}
}
}