From d7a533d6f85aae52aed03202fa5ccb774f0cb2ec Mon Sep 17 00:00:00 2001 From: Guillaume Chau Date: Thu, 20 Dec 2018 21:26:12 +0100 Subject: [PATCH] feat(ssr): ssrPrefetch option + context.rendered hook (#9017) --- flow/options.js | 1 + src/server/create-renderer.js | 15 +++ src/server/render-stream.js | 1 + src/server/render.js | 49 +++++++-- src/shared/constants.js | 3 +- test/ssr/ssr-stream.spec.js | 22 ++++ test/ssr/ssr-string.spec.js | 188 ++++++++++++++++++++++++++++++++++ test/ssr/ssr-template.spec.js | 75 ++++++++++++++ types/options.d.ts | 1 + types/test/options-test.ts | 3 + 10 files changed, 349 insertions(+), 9 deletions(-) diff --git a/flow/options.js b/flow/options.js index 2e138a62..ceaf8571 100644 --- a/flow/options.js +++ b/flow/options.js @@ -44,6 +44,7 @@ declare type ComponentOptions = { beforeDestroy?: Function; destroyed?: Function; errorCaptured?: () => boolean | void; + ssrPrefetch?: Function; // assets directives?: { [key: string]: Object }; diff --git a/src/server/create-renderer.js b/src/server/create-renderer.js index c045a426..0d335462 100644 --- a/src/server/create-renderer.js +++ b/src/server/create-renderer.js @@ -79,6 +79,9 @@ export function createRenderer ({ }, cb) try { render(component, write, context, err => { + if (context && context.rendered) { + context.rendered(context) + } if (template) { result = templateRenderer.renderSync(result, context) } @@ -106,6 +109,12 @@ export function createRenderer ({ render(component, write, context, done) }) if (!template) { + if (context && context.rendered) { + const rendered = context.rendered + renderStream.once('beforeEnd', () => { + rendered(context) + }) + } return renderStream } else { const templateStream = templateRenderer.createStream(context) @@ -113,6 +122,12 @@ export function createRenderer ({ templateStream.emit('error', err) }) renderStream.pipe(templateStream) + if (context && context.rendered) { + const rendered = context.rendered + renderStream.once('beforeEnd', () => { + rendered(context) + }) + } return templateStream } } diff --git a/src/server/render-stream.js b/src/server/render-stream.js index d76012af..bc358e20 100644 --- a/src/server/render-stream.js +++ b/src/server/render-stream.js @@ -42,6 +42,7 @@ export default class RenderStream extends stream.Readable { }) this.end = () => { + this.emit('beforeEnd') // the rendering is finished; we should push out the last of the buffer. this.done = true this.push(this.buffer) diff --git a/src/server/render.js b/src/server/render.js index 581a6dfd..e6195e00 100644 --- a/src/server/render.js +++ b/src/server/render.js @@ -19,6 +19,7 @@ let warned = Object.create(null) const warnOnce = msg => { if (!warned[msg]) { warned[msg] = true + // eslint-disable-next-line no-console console.warn(`\n\u001b[31m${msg}\u001b[39m\n`) } } @@ -49,6 +50,27 @@ const normalizeRender = vm => { } } +function waitForSsrPrefetch (vm, resolve, reject) { + let handlers = vm.$options.ssrPrefetch + if (isDef(handlers)) { + if (!Array.isArray(handlers)) handlers = [handlers] + try { + const promises = [] + for (let i = 0, j = handlers.length; i < j; i++) { + const result = handlers[i].call(vm, vm) + if (result && typeof result.then === 'function') { + promises.push(result) + } + } + Promise.all(promises).then(resolve).catch(reject) + return + } catch (e) { + reject(e) + } + } + resolve() +} + function renderNode (node, isRoot, context) { if (node.isString) { renderStringNode(node, context) @@ -166,13 +188,20 @@ function renderComponentInner (node, isRoot, context) { context.activeInstance ) normalizeRender(child) - const childNode = child._render() - childNode.parent = node - context.renderStates.push({ - type: 'Component', - prevActive - }) - renderNode(childNode, isRoot, context) + + const resolve = () => { + const childNode = child._render() + childNode.parent = node + context.renderStates.push({ + type: 'Component', + prevActive + }) + renderNode(childNode, isRoot, context) + } + + const reject = context.done + + waitForSsrPrefetch(child, resolve, reject) } function renderAsyncComponent (node, isRoot, context) { @@ -394,6 +423,10 @@ export function createRenderFunction ( }) installSSRHelpers(component) normalizeRender(component) - renderNode(component._render(), true, context) + + const resolve = () => { + renderNode(component._render(), true, context) + } + waitForSsrPrefetch(component, resolve, done) } } diff --git a/src/shared/constants.js b/src/shared/constants.js index 84d019fb..018d657b 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -17,5 +17,6 @@ export const LIFECYCLE_HOOKS = [ 'destroyed', 'activated', 'deactivated', - 'errorCaptured' + 'errorCaptured', + 'ssrPrefetch' ] diff --git a/test/ssr/ssr-stream.spec.js b/test/ssr/ssr-stream.spec.js index 5973b5ed..04e79658 100644 --- a/test/ssr/ssr-stream.spec.js +++ b/test/ssr/ssr-stream.spec.js @@ -102,4 +102,26 @@ describe('SSR: renderToStream', () => { stream1.read(1) stream2.read(1) }) + + it('should call context.rendered', done => { + let a = 0 + const stream = renderToStream(new Vue({ + template: ` +
Hello
+ ` + }), { + rendered: () => { + a = 42 + } + }) + let res = '' + stream.on('data', chunk => { + res += chunk + }) + stream.on('end', () => { + expect(res).toContain('
Hello
') + expect(a).toBe(42) + done() + }) + }) }) diff --git a/test/ssr/ssr-string.spec.js b/test/ssr/ssr-string.spec.js index 2f98bab8..112b50e1 100644 --- a/test/ssr/ssr-string.spec.js +++ b/test/ssr/ssr-string.spec.js @@ -1311,6 +1311,194 @@ describe('SSR: renderToString', () => { done() }) }) + + it('should support ssrPrefetch option', done => { + renderVmWithOptions({ + template: ` +
{{ count }}
+ `, + data: { + count: 0 + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.count = 42 + resolve() + }, 1) + }) + } + }, result => { + expect(result).toContain('
42
') + done() + }) + }) + + it('should support ssrPrefetch option (nested)', done => { + renderVmWithOptions({ + template: ` +
+ {{ count }} + +
+ `, + data: { + count: 0 + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.count = 42 + resolve() + }, 1) + }) + }, + components: { + nestedPrefetch: { + template: ` +
{{ message }}
+ `, + data () { + return { + message: '' + } + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.message = 'vue.js' + resolve() + }, 1) + }) + } + } + } + }, result => { + expect(result).toContain('
42
vue.js
') + done() + }) + }) + + it('should support ssrPrefetch option (nested async)', done => { + renderVmWithOptions({ + template: ` +
+ {{ count }} + +
+ `, + data: { + count: 0 + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.count = 42 + resolve() + }, 1) + }) + }, + components: { + nestedPrefetch (resolve) { + resolve({ + template: ` +
{{ message }}
+ `, + data () { + return { + message: '' + } + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.message = 'vue.js' + resolve() + }, 1) + }) + } + }) + } + } + }, result => { + expect(result).toContain('
42
vue.js
') + done() + }) + }) + + it('should merge ssrPrefetch option', done => { + const mixin = { + data: { + message: '' + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.message = 'vue.js' + resolve() + }, 1) + }) + } + } + renderVmWithOptions({ + mixins: [mixin], + template: ` +
+ {{ count }} +
{{ message }}
+
+ `, + data: { + count: 0 + }, + ssrPrefetch () { + return new Promise((resolve) => { + setTimeout(() => { + this.count = 42 + resolve() + }, 1) + }) + } + }, result => { + expect(result).toContain('
42
vue.js
') + done() + }) + }) + + it(`should skip ssrPrefetch option that doesn't return a promise`, done => { + renderVmWithOptions({ + template: ` +
{{ count }}
+ `, + data: { + count: 0 + }, + ssrPrefetch () { + setTimeout(() => { + this.count = 42 + }, 1) + } + }, result => { + expect(result).toContain('
0
') + done() + }) + }) + + it('should call context.rendered', done => { + let a = 0 + renderToString(new Vue({ + template: '
Hello
' + }), { + rendered: () => { + a = 42 + } + }, (err, res) => { + expect(err).toBeNull() + expect(res).toContain('
Hello
') + expect(a).toBe(42) + done() + }) + }) }) function renderVmWithOptions (options, cb) { diff --git a/test/ssr/ssr-template.spec.js b/test/ssr/ssr-template.spec.js index cf1ec657..c9cc001b 100644 --- a/test/ssr/ssr-template.spec.js +++ b/test/ssr/ssr-template.spec.js @@ -99,6 +99,41 @@ describe('SSR: template option', () => { }) }) + it('renderToString with interpolation and context.rendered', done => { + const renderer = createRenderer({ + template: interpolateTemplate + }) + + const context = { + title: '', + snippet: '
foo
', + head: '', + styles: '', + state: { a: 0 }, + rendered: context => { + context.state.a = 1 + } + } + + renderer.renderToString(new Vue({ + template: '
hi
' + }), context, (err, res) => { + expect(err).toBeNull() + expect(res).toContain( + `` + + // double mustache should be escaped + `<script>hacks</script>` + + `${context.head}${context.styles}` + + `
hi
` + + `` + + // triple should be raw + `
foo
` + + `` + ) + done() + }) + }) + it('renderToStream', done => { const renderer = createRenderer({ template: defaultTemplate @@ -166,6 +201,46 @@ describe('SSR: template option', () => { }) }) + it('renderToStream with interpolation and context.rendered', done => { + const renderer = createRenderer({ + template: interpolateTemplate + }) + + const context = { + title: '', + snippet: '
foo
', + head: '', + styles: '', + state: { a: 0 }, + rendered: context => { + context.state.a = 1 + } + } + + const stream = renderer.renderToStream(new Vue({ + template: '
hi
' + }), context) + + let res = '' + stream.on('data', chunk => { + res += chunk + }) + stream.on('end', () => { + expect(res).toContain( + `` + + // double mustache should be escaped + `<script>hacks</script>` + + `${context.head}${context.styles}` + + `
hi
` + + `` + + // triple should be raw + `
foo
` + + `` + ) + done() + }) + }) + it('bundleRenderer + renderToString', done => { createBundleRenderer('app.js', { asBundle: true, diff --git a/types/options.d.ts b/types/options.d.ts index d43b58d2..0e98b4a9 100644 --- a/types/options.d.ts +++ b/types/options.d.ts @@ -96,6 +96,7 @@ export interface ComponentOptions< activated?(): void; deactivated?(): void; errorCaptured?(err: Error, vm: Vue, info: string): boolean | void; + ssrPrefetch?(this: V): Promise; directives?: { [key: string]: DirectiveFunction | DirectiveOptions }; components?: { [key: string]: Component | AsyncComponent }; diff --git a/types/test/options-test.ts b/types/test/options-test.ts index ce0e68de..4041aad4 100644 --- a/types/test/options-test.ts +++ b/types/test/options-test.ts @@ -241,6 +241,9 @@ Vue.component('component', { info.toUpperCase() return true }, + ssrPrefetch () { + return Promise.resolve() + }, directives: { a: {