/** * @jest-environment node */ import { createApp, h, createCommentVNode, resolveComponent, ComponentOptions, ref, defineComponent, createTextVNode, createStaticVNode, withCtx, KeepAlive, Transition, watchEffect, createVNode, resolveDynamicComponent, renderSlot, onErrorCaptured, onServerPrefetch } from 'vue' import { escapeHtml } from '@vue/shared' import { renderToString } from '../src/renderToString' import { renderToNodeStream, pipeToNodeWritable } from '../src/renderToStream' import { ssrRenderSlot, SSRSlot } from '../src/helpers/ssrRenderSlot' import { ssrRenderComponent } from '../src/helpers/ssrRenderComponent' import { Readable, Transform } from 'stream' import { ssrRenderVNode } from '../src' 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) => { return promisifyStream(renderToNodeStream(app, context)) } const pipeToWritable = (app: any, context?: any) => { const stream = new Transform({ transform(data, _encoding, cb) { this.push(data) cb() } }) pipeToNodeWritable(app, context, stream) return promisifyStream(stream) } // we run the same tests twice, once for renderToString, once for renderToStream testRender(`renderToString`, renderToString) testRender(`renderToNodeStream`, renderToStream) testRender(`pipeToNodeWritable`, pipeToWritable) function testRender(type: string, render: typeof renderToString) { describe(`ssr: ${type}`, () => { 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 render(app) expect(html).toBe(`
foo
`) }) describe('components', () => { test('vnode components', async () => { expect( await render( 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 render( createApp({ setup() { const msg = ref('hello') return () => h('div', msg.value) } }) ) ).toBe(`
hello
`) }) test('setup components returning render from setup', async () => { expect( await render( createApp( defineComponent(() => { const msg = ref('hello') return () => h('div', msg.value) }) ) ) ).toBe(`
hello
`) }) test('components using defineComponent with extends option', async () => { expect( await render( createApp( defineComponent({ extends: { data() { return { msg: 'hello' } }, render(this: any) { return h('div', this.msg) } } }) ) ) ).toBe(`
hello
`) }) test('components using defineComponent with mixins option', async () => { expect( await render( createApp( defineComponent({ mixins: [ { data() { return { msg: 'hello' } }, render(this: any) { return h('div', this.msg) } } ] }) ) ) ).toBe(`
hello
`) }) test('optimized components', async () => { expect( await render( createApp({ data() { return { msg: 'hello' } }, ssrRender(ctx, push) { push(`
${ctx.msg}
`) } }) ) ).toBe(`
hello
`) }) test('nested vnode components', async () => { const Child = { props: ['msg'], render(this: any) { return h('div', this.msg) } } expect( await render( createApp({ render() { return h('div', ['parent', h(Child, { msg: 'hello' })]) } }) ) ).toBe(`
parent
hello
`) }) test('nested optimized components', async () => { const Child = { props: ['msg'], ssrRender(ctx: any, push: any) { push(`
${ctx.msg}
`) } } expect( await render( createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent)) push(`
`) } }) ) ).toBe(`
parent
hello
`) }) test('nested template components', async () => { const Child = { props: ['msg'], template: `
{{ msg }}
` } const app = createApp({ template: `
parent
` }) app.component('Child', Child) expect(await render(app)).toBe(`
parent
hello
`) }) test('template components with dynamic class attribute after static', async () => { const app = createApp({ template: `
` }) expect(await render(app)).toBe( `
` ) }) test('template components with dynamic class attribute before static', async () => { const app = createApp({ template: `
` }) expect(await render(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 render( 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( `
parent
opt
vnode
template
` ) }) 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 render( createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) push(ssrRenderComponent(Child, null, null, parent)) push(`
`) } }) ) ).toBe(`
parent
hello
`) }) 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 render( createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) push( ssrRenderComponent( OptimizedChild, { msg: 'opt' }, null, parent ) ) push( ssrRenderComponent(VNodeChild, { msg: 'vnode' }, null, parent) ) push(`
`) } }) ) ).toBe(`
parent
opt!
vnode!
`) }) }) describe('slots', () => { 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 render( createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) push( ssrRenderComponent( Child, { msg: 'hello' }, { // optimized slot using string push default: (({ msg }, push, _p) => { push(`${msg}`) }) as SSRSlot, // important to avoid slots being normalized _: 1 as any }, parent ) ) push(`
`) } }) ) ).toBe( `
parent
` + `from slot` + `
` ) // test fallback expect( await render( createApp({ ssrRender(_ctx, push, parent) { push(`
parent`) push(ssrRenderComponent(Child, { msg: 'hello' }, null, parent)) push(`
`) } }) ) ).toBe( `
parent
fallback
` ) }) 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 render( 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 render(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 render(app)).toBe( `
parent
` + // no comment anchors because slot is used directly as element children `from slot` + `
` ) }) test('template slots forwarding', async () => { const Child = { template: `
` } const Parent = { components: { Child }, template: `` } const app = createApp({ components: { Parent }, template: `hello` }) expect(await render(app)).toBe( `
hello
` ) }) test('template slots forwarding, empty slot', async () => { const Child = { template: `
` } const Parent = { components: { Child }, template: `` } const app = createApp({ components: { Parent }, template: `` }) expect(await render(app)).toBe( // should only have a single fragment `
` ) }) test('template slots forwarding, empty slot w/ fallback', async () => { const Child = { template: `
fallback
` } const Parent = { components: { Child }, template: `` } const app = createApp({ components: { Parent }, template: `` }) expect(await render(app)).toBe( // should only have a single fragment `
fallback
` ) }) }) describe('vnode element', () => { test('props', async () => { expect( await render(h('div', { id: 'foo&', class: ['bar', 'baz'] }, 'hello')) ).toBe(`
hello
`) }) test('text children', async () => { expect(await render(h('div', 'hello'))).toBe(`
hello
`) }) test('array children', async () => { expect( await render( h('div', [ 'foo', h('span', 'bar'), [h('span', 'baz')], createCommentVNode('qux') ]) ) ).toBe( `
foobarbaz
` ) }) test('void elements', async () => { expect(await render(h('input'))).toBe(``) }) test('innerHTML', async () => { expect( await render( h( 'div', { innerHTML: `hello` }, 'ignored' ) ) ).toBe(`
hello
`) }) test('textContent', async () => { expect( await render( h( 'div', { textContent: `hello` }, 'ignored' ) ) ).toBe(`
${escapeHtml(`hello`)}
`) }) test('textarea value', async () => { expect( await render( h( 'textarea', { value: `hello` }, 'ignored' ) ) ).toBe(``) }) }) describe('vnode component', () => { test('KeepAlive', async () => { const MyComp = { render: () => h('p', 'hello') } expect(await render(h(KeepAlive, () => h(MyComp)))).toBe( `

hello

` ) }) test('Transition', async () => { const MyComp = { render: () => h('p', 'hello') } expect(await render(h(Transition, () => h(MyComp)))).toBe( `

hello

` ) }) }) describe('raw vnode types', () => { test('Text', async () => { expect(await render(createTextVNode('hello
'))).toBe( `hello <div>` ) }) test('Comment', async () => { // https://www.w3.org/TR/html52/syntax.html#comments expect( await render( h('div', [ createCommentVNode('>foo'), createCommentVNode('->foo'), createCommentVNode(''), createCommentVNode('--!>foo
`) }) test('Static', async () => { const content = `
helloworld
` expect(await render(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. test('basic', async () => { const Foo = { __scopeId: 'data-v-test', render() { return h('div') } } expect(await render(h(Foo))).toBe(`
`) }) test('with client-compiled vnode slots', async () => { const Child = { __scopeId: 'data-v-child', render: function (this: any) { return h('div', null, [renderSlot(this.$slots, 'default')]) } } const Parent = { __scopeId: 'data-v-test', render: () => { return h(Child, null, { default: withCtx(() => [h('span', 'slot')]) }) } } expect(await render(h(Parent))).toBe( `
` + `slot` + `
` ) }) }) describe('integration w/ compiled template', () => { test('render', async () => { expect( await render( createApp({ data() { return { msg: 'hello' } }, template: `
{{ msg }}
` }) ) ).toBe(`
hello
`) }) test('handle compiler errors', async () => { await render( // render different content since compilation is cached createApp({ template: `
${type} { const msg = Promise.resolve('hello') const app = createApp({ data() { return { msg: '' } }, async serverPrefetch() { this.msg = await msg }, render() { return h('div', this.msg) } }) const html = await render(app) expect(html).toBe(`
hello
`) }) // #2763 test('error handling w/ async setup', async () => { const fn = jest.fn() const fn2 = jest.fn() const asyncChildren = defineComponent({ async setup() { return Promise.reject('async child error') }, template: `
asyncChildren
` }) const app = createApp({ name: 'App', components: { asyncChildren }, template: `
`, errorCaptured(error) { fn(error) } }) app.config.errorHandler = error => { fn2(error) } const html = await renderToString(app) expect(html).toBe(`
asyncChildren
`) expect(fn).toHaveBeenCalledTimes(1) expect(fn).toBeCalledWith('async child error') expect(fn2).toHaveBeenCalledTimes(1) expect(fn2).toBeCalledWith('async child error') }) // https://github.com/vuejs/core/issues/3322 test('effect onInvalidate does not error', async () => { const noop = () => {} const app = createApp({ setup: () => { watchEffect(onInvalidate => onInvalidate(noop)) }, render: noop }) expect(await render(app)).toBe('') }) // #2863 test('assets should be resolved correctly', async () => { expect( await render( createApp({ components: { A: { ssrRender(_ctx, _push) { _push(`
A
`) } }, B: { render: () => h('div', 'B') } }, ssrRender(_ctx, _push, _parent) { const A: any = resolveComponent('A') _push(ssrRenderComponent(A, null, null, _parent)) ssrRenderVNode( _push, createVNode(resolveDynamicComponent('B'), null, null), _parent ) } }) ) ).toBe(`
A
B
`) }) test('onServerPrefetch', async () => { const msg = Promise.resolve('hello') const app = createApp({ setup() { const message = ref('') onServerPrefetch(async () => { message.value = await msg }) return { message } }, render() { return h('div', this.message) } }) const html = await render(app) expect(html).toBe(`
hello
`) }) test('multiple onServerPrefetch', async () => { const msg = Promise.resolve('hello') const msg2 = Promise.resolve('hi') const msg3 = Promise.resolve('bonjour') const app = createApp({ setup() { const message = ref('') const message2 = ref('') const message3 = ref('') onServerPrefetch(async () => { message.value = await msg }) onServerPrefetch(async () => { message2.value = await msg2 }) onServerPrefetch(async () => { message3.value = await msg3 }) return { message, message2, message3 } }, render() { return h('div', `${this.message} ${this.message2} ${this.message3}`) } }) const html = await render(app) expect(html).toBe(`
hello hi bonjour
`) }) test('onServerPrefetch are run in parallel', async () => { const first = jest.fn(() => Promise.resolve()) const second = jest.fn(() => Promise.resolve()) let checkOther = [false, false] let done = [false, false] const app = createApp({ setup() { onServerPrefetch(async () => { checkOther[0] = done[1] await first() done[0] = true }) onServerPrefetch(async () => { checkOther[1] = done[0] await second() done[1] = true }) }, render() { return h('div', '') } }) await render(app) expect(first).toHaveBeenCalled() expect(second).toHaveBeenCalled() expect(checkOther).toEqual([false, false]) expect(done).toEqual([true, true]) }) test('onServerPrefetch with serverPrefetch option', async () => { const msg = Promise.resolve('hello') const msg2 = Promise.resolve('hi') const app = createApp({ data() { return { message: '' } }, async serverPrefetch() { this.message = await msg }, setup() { const message2 = ref('') onServerPrefetch(async () => { message2.value = await msg2 }) return { message2 } }, render() { return h('div', `${this.message} ${this.message2}`) } }) const html = await render(app) expect(html).toBe(`
hello hi
`) }) test('mixed in serverPrefetch', async () => { const msg = Promise.resolve('hello') const app = createApp({ data() { return { msg: '' } }, mixins: [ { async serverPrefetch() { this.msg = await msg } } ], render() { return h('div', this.msg) } }) const html = await render(app) expect(html).toBe(`
hello
`) }) test('many serverPrefetch', async () => { const foo = Promise.resolve('foo') const bar = Promise.resolve('bar') const baz = Promise.resolve('baz') const app = createApp({ data() { return { foo: '', bar: '', baz: '' } }, mixins: [ { async serverPrefetch() { this.foo = await foo } }, { async serverPrefetch() { this.bar = await bar } } ], async serverPrefetch() { this.baz = await baz }, render() { return h('div', `${this.foo}${this.bar}${this.baz}`) } }) const html = await render(app) expect(html).toBe(`
foobarbaz
`) }) test('onServerPrefetch throwing error', async () => { let renderError: Error | null = null let capturedError: Error | null = null const Child = { setup() { onServerPrefetch(async () => { throw new Error('An error') }) }, render() { return h('span') } } const app = createApp({ setup() { onErrorCaptured(e => { capturedError = e return false }) }, render() { return h('div', h(Child)) } }) try { await render(app) } catch (e: any) { renderError = e } expect(renderError).toBe(null) expect((capturedError as unknown as Error).message).toBe('An error') }) }) }