]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(ssr): renderToStream (#1197)
authorStanislav Lashmanov <stasvarenkin@gmail.com>
Fri, 26 Jun 2020 15:09:47 +0000 (18:09 +0300)
committerGitHub <noreply@github.com>
Fri, 26 Jun 2020 15:09:47 +0000 (11:09 -0400)
13 files changed:
packages/runtime-core/__tests__/hydration.spec.ts
packages/server-renderer/__tests__/renderToStream.spec.ts [new file with mode: 0644]
packages/server-renderer/__tests__/renderToString.spec.ts
packages/server-renderer/__tests__/ssrTeleport.spec.ts
packages/server-renderer/src/helpers/ssrCompile.ts [new file with mode: 0644]
packages/server-renderer/src/helpers/ssrRenderComponent.ts [new file with mode: 0644]
packages/server-renderer/src/helpers/ssrRenderSlot.ts
packages/server-renderer/src/helpers/ssrRenderSuspense.ts
packages/server-renderer/src/helpers/ssrRenderTeleport.ts
packages/server-renderer/src/index.ts
packages/server-renderer/src/render.ts [new file with mode: 0644]
packages/server-renderer/src/renderToStream.ts [new file with mode: 0644]
packages/server-renderer/src/renderToString.ts

index e54063fdb3796670de0913eaecd08c7affcc2df5..007ceec925ad433db9ff76a8e9cdd8cf97aa3004 100644 (file)
@@ -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 (file)
index 0000000..8d8e454
--- /dev/null
@@ -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(`<div>foo</div>`)
+  })
+
+  describe('components', () => {
+    test('vnode components', async () => {
+      expect(
+        await renderToStream(
+          createApp({
+            data() {
+              return { msg: 'hello' }
+            },
+            render(this: any) {
+              return h('div', this.msg)
+            }
+          })
+        )
+      ).toBe(`<div>hello</div>`)
+    })
+
+    test('option components returning render from setup', async () => {
+      expect(
+        await renderToStream(
+          createApp({
+            setup() {
+              const msg = ref('hello')
+              return () => h('div', msg.value)
+            }
+          })
+        )
+      ).toBe(`<div>hello</div>`)
+    })
+
+    test('setup components returning render from setup', async () => {
+      expect(
+        await renderToStream(
+          createApp(
+            defineComponent((props: {}) => {
+              const msg = ref('hello')
+              return () => h('div', msg.value)
+            })
+          )
+        )
+      ).toBe(`<div>hello</div>`)
+    })
+
+    test('optimized components', async () => {
+      expect(
+        await renderToStream(
+          createApp({
+            data() {
+              return { msg: 'hello' }
+            },
+            ssrRender(ctx, push) {
+              push(`<div>${ctx.msg}</div>`)
+            }
+          })
+        )
+      ).toBe(`<div>hello</div>`)
+    })
+
+    describe('template components', () => {
+      test('render', async () => {
+        expect(
+          await renderToStream(
+            createApp({
+              data() {
+                return { msg: 'hello' }
+              },
+              template: `<div>{{ msg }}</div>`
+            })
+          )
+        ).toBe(`<div>hello</div>`)
+      })
+
+      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(`<div>parent<div>hello</div></div>`)
+    })
+
+    test('nested optimized components', async () => {
+      const Child = {
+        props: ['msg'],
+        ssrRender(ctx: any, push: any) {
+          push(`<div>${ctx.msg}</div>`)
+        }
+      }
+
+      expect(
+        await renderToStream(
+          createApp({
+            ssrRender(_ctx, push, parent) {
+              push(`<div>parent`)
+              push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(`<div>parent<div>hello</div></div>`)
+    })
+
+    test('nested template components', async () => {
+      const Child = {
+        props: ['msg'],
+        template: `<div>{{ msg }}</div>`
+      }
+      const app = createApp({
+        template: `<div>parent<Child msg="hello" /></div>`
+      })
+      app.component('Child', Child)
+
+      expect(await renderToStream(app)).toBe(
+        `<div>parent<div>hello</div></div>`
+      )
+    })
+
+    test('mixing optimized / vnode / template components', async () => {
+      const OptimizedChild = {
+        props: ['msg'],
+        ssrRender(ctx: any, push: any) {
+          push(`<div>${ctx.msg}</div>`)
+        }
+      }
+
+      const VNodeChild = {
+        props: ['msg'],
+        render(this: any) {
+          return h('div', this.msg)
+        }
+      }
+
+      const TemplateChild = {
+        props: ['msg'],
+        template: `<div>{{ msg }}</div>`
+      }
+
+      expect(
+        await renderToStream(
+          createApp({
+            ssrRender(_ctx, push, parent) {
+              push(`<div>parent`)
+              push(
+                ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+              )
+              push(
+                ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
+              )
+              push(
+                ssrRenderComponent(
+                  TemplateChild,
+                  { msg: 'template' },
+                  null,
+                  parent
+                )
+              )
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(
+        `<div>parent<div>opt</div><div>vnode</div><div>template</div></div>`
+      )
+    })
+
+    test('nested components with optimized slots', async () => {
+      const Child = {
+        props: ['msg'],
+        ssrRender(ctx: any, push: any, parent: any) {
+          push(`<div class="child">`)
+          ssrRenderSlot(
+            ctx.$slots,
+            'default',
+            { msg: 'from slot' },
+            () => {
+              push(`fallback`)
+            },
+            push,
+            parent
+          )
+          push(`</div>`)
+        }
+      }
+
+      expect(
+        await renderToStream(
+          createApp({
+            ssrRender(_ctx, push, parent) {
+              push(`<div>parent`)
+              push(
+                ssrRenderComponent(
+                  Child,
+                  { msg: 'hello' },
+                  {
+                    // optimized slot using string push
+                    default: ({ msg }: any, push: any, p: any) => {
+                      push(`<span>${msg}</span>`)
+                    },
+                    // important to avoid slots being normalized
+                    _: 1 as any
+                  },
+                  parent
+                )
+              )
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(
+        `<div>parent<div class="child">` +
+          `<!--[--><span>from slot</span><!--]-->` +
+          `</div></div>`
+      )
+
+      // test fallback
+      expect(
+        await renderToStream(
+          createApp({
+            ssrRender(_ctx, push, parent) {
+              push(`<div>parent`)
+              push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(
+        `<div>parent<div class="child"><!--[-->fallback<!--]--></div></div>`
+      )
+    })
+
+    test('nested components with vnode slots', async () => {
+      const Child = {
+        props: ['msg'],
+        ssrRender(ctx: any, push: any, parent: any) {
+          push(`<div class="child">`)
+          ssrRenderSlot(
+            ctx.$slots,
+            'default',
+            { msg: 'from slot' },
+            null,
+            push,
+            parent
+          )
+          push(`</div>`)
+        }
+      }
+
+      expect(
+        await renderToStream(
+          createApp({
+            ssrRender(_ctx, push, parent) {
+              push(`<div>parent`)
+              push(
+                ssrRenderComponent(
+                  Child,
+                  { msg: 'hello' },
+                  {
+                    // bailed slots returning raw vnodes
+                    default: ({ msg }: any) => {
+                      return h('span', msg)
+                    }
+                  },
+                  parent
+                )
+              )
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(
+        `<div>parent<div class="child">` +
+          `<!--[--><span>from slot</span><!--]-->` +
+          `</div></div>`
+      )
+    })
+
+    test('nested components with template slots', async () => {
+      const Child = {
+        props: ['msg'],
+        template: `<div class="child"><slot msg="from slot"></slot></div>`
+      }
+
+      const app = createApp({
+        components: { Child },
+        template: `<div>parent<Child v-slot="{ msg }"><span>{{ msg }}</span></Child></div>`
+      })
+
+      expect(await renderToStream(app)).toBe(
+        `<div>parent<div class="child">` +
+          `<!--[--><span>from slot</span><!--]-->` +
+          `</div></div>`
+      )
+    })
+
+    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: `<div>parent<Child v-slot="{ msg }"><span>{{ msg }}</span></Child></div>`
+      })
+      app.component('Child', Child)
+
+      expect(await renderToStream(app)).toBe(
+        `<div>parent<div class="child">` +
+          // no comment anchors because slot is used directly as element children
+          `<span>from slot</span>` +
+          `</div></div>`
+      )
+    })
+
+    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(`<div>${ctx.msg}</div>`)
+        }
+      }
+
+      expect(
+        await renderToStream(
+          createApp({
+            ssrRender(_ctx, push, parent) {
+              push(`<div>parent`)
+              push(ssrRenderComponent(Child, null, null, parent))
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(`<div>parent<div>hello</div></div>`)
+    })
+
+    test('parallel async components', async () => {
+      const OptimizedChild = {
+        props: ['msg'],
+        async setup(props: any) {
+          return {
+            localMsg: props.msg + '!'
+          }
+        },
+        ssrRender(ctx: any, push: any) {
+          push(`<div>${ctx.localMsg}</div>`)
+        }
+      }
+
+      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(`<div>parent`)
+              push(
+                ssrRenderComponent(OptimizedChild, { msg: 'opt' }, null, parent)
+              )
+              push(
+                ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent)
+              )
+              push(`</div>`)
+            }
+          })
+        )
+      ).toBe(`<div>parent<div>opt!</div><div>vnode!</div></div>`)
+    })
+  })
+
+  describe('vnode element', () => {
+    test('props', async () => {
+      expect(
+        await renderToStream(
+          h('div', { id: 'foo&', class: ['bar', 'baz'] }, 'hello')
+        )
+      ).toBe(`<div id="foo&amp;" class="bar baz">hello</div>`)
+    })
+
+    test('text children', async () => {
+      expect(await renderToStream(h('div', 'hello'))).toBe(`<div>hello</div>`)
+    })
+
+    test('array children', async () => {
+      expect(
+        await renderToStream(
+          h('div', [
+            'foo',
+            h('span', 'bar'),
+            [h('span', 'baz')],
+            createCommentVNode('qux')
+          ])
+        )
+      ).toBe(
+        `<div>foo<span>bar</span><!--[--><span>baz</span><!--]--><!--qux--></div>`
+      )
+    })
+
+    test('void elements', async () => {
+      expect(await renderToStream(h('input'))).toBe(`<input>`)
+    })
+
+    test('innerHTML', async () => {
+      expect(
+        await renderToStream(
+          h(
+            'div',
+            {
+              innerHTML: `<span>hello</span>`
+            },
+            'ignored'
+          )
+        )
+      ).toBe(`<div><span>hello</span></div>`)
+    })
+
+    test('textContent', async () => {
+      expect(
+        await renderToStream(
+          h(
+            'div',
+            {
+              textContent: `<span>hello</span>`
+            },
+            'ignored'
+          )
+        )
+      ).toBe(`<div>${escapeHtml(`<span>hello</span>`)}</div>`)
+    })
+
+    test('textarea value', async () => {
+      expect(
+        await renderToStream(
+          h(
+            'textarea',
+            {
+              value: `<span>hello</span>`
+            },
+            'ignored'
+          )
+        )
+      ).toBe(`<textarea>${escapeHtml(`<span>hello</span>`)}</textarea>`)
+    })
+  })
+
+  describe('raw vnode types', () => {
+    test('Text', async () => {
+      expect(await renderToStream(createTextVNode('hello <div>'))).toBe(
+        `hello &lt;div&gt;`
+      )
+    })
+
+    test('Comment', async () => {
+      // https://www.w3.org/TR/html52/syntax.html#comments
+      expect(
+        await renderToStream(
+          h('div', [
+            createCommentVNode('>foo'),
+            createCommentVNode('->foo'),
+            createCommentVNode('<!--foo-->'),
+            createCommentVNode('--!>foo<!-')
+          ])
+        )
+      ).toBe(`<div><!--foo--><!--foo--><!--foo--><!--foo--></div>`)
+    })
+
+    test('Static', async () => {
+      const content = `<div id="ok">hello<span>world</span></div>`
+      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(`<div data-v-test></div>`)
+    })
+
+    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(
+        `<div data-v-test data-v-child><span data-v-test data-v-child-s>slot</span></div>`
+      )
+    })
+  })
+})
index b034312c30b8f1162e4146b00ee7bb74cce19893..f628d99183438b6dc2d8685b4f02412aa1b820dc 100644 (file)
@@ -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(`<div>parent`)
-              push(renderComponent(Child, { msg: 'hello' }, null, parent))
+              push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
               push(`</div>`)
             }
           })
@@ -194,11 +195,13 @@ describe('ssr: renderToString', () => {
             ssrRender(_ctx, push, parent) {
               push(`<div>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(`<div>parent`)
               push(
-                renderComponent(
+                ssrRenderComponent(
                   Child,
                   { msg: 'hello' },
                   {
@@ -269,7 +272,7 @@ describe('ssr: renderToString', () => {
           createApp({
             ssrRender(_ctx, push, parent) {
               push(`<div>parent`)
-              push(renderComponent(Child, { msg: 'hello' }, null, parent))
+              push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent))
               push(`</div>`)
             }
           })
@@ -302,7 +305,7 @@ describe('ssr: renderToString', () => {
             ssrRender(_ctx, push, parent) {
               push(`<div>parent`)
               push(
-                renderComponent(
+                ssrRenderComponent(
                   Child,
                   { msg: 'hello' },
                   {
@@ -388,7 +391,7 @@ describe('ssr: renderToString', () => {
           createApp({
             ssrRender(_ctx, push, parent) {
               push(`<div>parent`)
-              push(renderComponent(Child, null, null, parent))
+              push(ssrRenderComponent(Child, null, null, parent))
               push(`</div>`)
             }
           })
@@ -427,9 +430,11 @@ describe('ssr: renderToString', () => {
             ssrRender(_ctx, push, parent) {
               push(`<div>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(`</div>`)
             }
           })
index c990e600643dc3a143e60e1de65df17ef7ee5fb2..1dd4aa65a6362097f06619fd7a764a732af2c7a0 100644 (file)
@@ -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 (file)
index 0000000..4f9d506
--- /dev/null
@@ -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<string, SSRRenderFunction> = 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 (file)
index 0000000..000f2b4
--- /dev/null
@@ -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<SSRBuffer> {
+  return renderComponentVNode(
+    createVNode(comp, props, children),
+    parentComponent
+  )
+}
index 64d321827cf349e7a6ac548205a31d4c8bb95cff..8c96322ab0f1b817f44c5f824d0ea6bc9545feba 100644 (file)
@@ -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<string, SSRSlot>
-
 export type SSRSlot = (
   props: Props,
   push: PushFn,
index 97586988c7b0b01815538ef9900c78d7012a8410..3d6df47fef771e6797cb8d4512982365ca53e284 100644 (file)
@@ -1,4 +1,4 @@
-import { PushFn } from '../renderToString'
+import { PushFn } from '../render'
 
 export async function ssrRenderSuspense(
   push: PushFn,
index 66772265d68233bf34ca1afb8c15b2ebb8d5af4d..77331b7bddddf3a47725d5bda96c825f59647d28 100644 (file)
@@ -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,
index 372fa08e587e0c7d9f2aa103bd97db8ae7c22980..723f964ed1d67f369c5aebc9ea5485d877bd9b64 100644 (file)
@@ -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 (file)
index 0000000..15f0522
--- /dev/null
@@ -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<SSRBuffer>
+export type PushFn = (item: SSRBufferItem) => void
+export type Props = Record<string, unknown>
+
+export type SSRContext = {
+  [key: string]: any
+  teleports?: Record<string, string>
+  __teleportBuffers?: Record<string, SSRBuffer>
+}
+
+// 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<SSRBuffer> {
+  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<SSRBuffer> {
+  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 ? `<!--${escapeHtmlComment(children as string)}-->` : `<!---->`
+      )
+      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 (file)
index 0000000..63b3895
--- /dev/null
@@ -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<void> {
+  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
+}
index 8720929b0e1b8436a91671b25ee91a86d9ae8cc3..68051da82e835133b62932f2091f3ef4781496b5 100644 (file)
 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<ResolvedSSRBuffer>
-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<string, unknown>
+const { isVNode } = ssrUtils
 
-export type SSRContext = {
-  [key: string]: any
-  teleports?: Record<string, string>
-  __teleportBuffers?: Record<string, SSRBuffer>
-}
-
-export function createBuffer() {
-  let appendable = false
-  let hasAsync = false
-  const buffer: SSRBuffer = []
-  return {
-    getBuffer(): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
-      // 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<string> {
   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<ResolvedSSRBuffer> {
-  return renderComponentVNode(
-    createVNode(comp, props, children),
-    parentComponent
-  )
-}
-
-function renderComponentVNode(
-  vnode: VNode,
-  parentComponent: ComponentInternalInstance | null = null
-): ResolvedSSRBuffer | Promise<ResolvedSSRBuffer> {
-  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<ResolvedSSRBuffer> {
-  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<string, SSRRenderFunction> = 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 ? `<!--${escapeHtmlComment(children as string)}-->` : `<!---->`
-      )
-      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)
     }
   }
 }