From c450ede12d1a93a70271a2fe7fcb6f8efcf1cd4c Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 16 Mar 2020 18:36:19 -0400 Subject: [PATCH] feat(ssr): support getSSRProps for vnode directives --- packages/runtime-core/src/directives.ts | 10 +- packages/runtime-dom/src/directives/vModel.ts | 23 +- packages/runtime-dom/src/directives/vShow.ts | 8 + .../__tests__/ssrDirectives.spec.ts | 393 ++++++++++++++++++ .../server-renderer/src/renderToString.ts | 30 +- 5 files changed, 458 insertions(+), 6 deletions(-) create mode 100644 packages/server-renderer/__tests__/ssrDirectives.spec.ts diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts index d3e638f90..f128fcffc 100644 --- a/packages/runtime-core/src/directives.ts +++ b/packages/runtime-core/src/directives.ts @@ -14,7 +14,7 @@ return withDirectives(h(comp), [ import { VNode } from './vnode' import { isFunction, EMPTY_OBJ, makeMap, EMPTY_ARR } from '@vue/shared' import { warn } from './warning' -import { ComponentInternalInstance } from './component' +import { ComponentInternalInstance, Data } from './component' import { currentRenderingInstance } from './componentRenderUtils' import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling' import { ComponentPublicInstance } from './componentProxy' @@ -35,6 +35,11 @@ export type DirectiveHook = ( prevVNode: VNode | null ) => void +export type SSRDirectiveHook = ( + binding: DirectiveBinding, + vnode: VNode +) => Data | undefined + export interface ObjectDirective { beforeMount?: DirectiveHook mounted?: DirectiveHook @@ -42,6 +47,7 @@ export interface ObjectDirective { updated?: DirectiveHook beforeUnmount?: DirectiveHook unmounted?: DirectiveHook + getSSRProps?: SSRDirectiveHook } export type FunctionDirective = DirectiveHook @@ -81,7 +87,7 @@ const directiveToVnodeHooksMap = /*#__PURE__*/ [ const prevBindings = prevVnode ? prevVnode.dirs! : EMPTY_ARR for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] - const hook = binding.dir[key] + const hook = binding.dir[key] as DirectiveHook if (hook != null) { if (prevVnode != null) { binding.oldValue = prevBindings[i].value diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index 8b1343944..463364895 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -218,7 +218,7 @@ function callModelHook( binding: DirectiveBinding, vnode: VNode, prevVNode: VNode | null, - hook: keyof ObjectDirective + hook: 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated' ) { let modelToUse: ObjectDirective switch (el.tagName) { @@ -243,3 +243,24 @@ function callModelHook( const fn = modelToUse[hook] fn && fn(el, binding, vnode, prevVNode) } + +// SSR vnode transforms +if (__NODE_JS__) { + vModelText.getSSRProps = ({ value }) => ({ value }) + + vModelRadio.getSSRProps = ({ value }, vnode) => { + if (vnode.props && looseEqual(vnode.props.value, value)) { + return { checked: true } + } + } + + vModelCheckbox.getSSRProps = ({ value }, vnode) => { + if (isArray(value)) { + if (vnode.props && looseIndexOf(value, vnode.props.value) > -1) { + return { checked: true } + } + } else if (value) { + return { checked: true } + } + } +} diff --git a/packages/runtime-dom/src/directives/vShow.ts b/packages/runtime-dom/src/directives/vShow.ts index 5f2743a55..2b1d9caef 100644 --- a/packages/runtime-dom/src/directives/vShow.ts +++ b/packages/runtime-dom/src/directives/vShow.ts @@ -40,6 +40,14 @@ export const vShow: ObjectDirective = { } } +if (__NODE_JS__) { + vShow.getSSRProps = ({ value }) => { + if (!value) { + return { style: { display: 'none' } } + } + } +} + function setDisplay(el: VShowElement, value: unknown): void { el.style.display = value ? el._vod : 'none' } diff --git a/packages/server-renderer/__tests__/ssrDirectives.spec.ts b/packages/server-renderer/__tests__/ssrDirectives.spec.ts new file mode 100644 index 000000000..a3ba59c95 --- /dev/null +++ b/packages/server-renderer/__tests__/ssrDirectives.spec.ts @@ -0,0 +1,393 @@ +import { renderToString } from '../src/renderToString' +import { + createApp, + h, + withDirectives, + vShow, + vModelText, + vModelRadio, + vModelCheckbox +} from 'vue' + +describe('ssr: directives', () => { + describe('template v-show', () => { + test('basic', async () => { + expect( + await renderToString( + createApp({ + template: `
` + }) + ) + ).toBe(`
`) + + expect( + await renderToString( + createApp({ + template: `
` + }) + ) + ).toBe(`
`) + }) + + test('with static style', async () => { + expect( + await renderToString( + createApp({ + template: `
` + }) + ) + ).toBe(`
`) + }) + + test('with dynamic style', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ style: { color: 'red' } }), + template: `
` + }) + ) + ).toBe(`
`) + }) + + test('with static + dynamic style', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ style: { color: 'red' } }), + template: `
` + }) + ) + ).toBe(`
`) + }) + }) + + describe('template v-model', () => { + test('text', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ text: 'hello' }), + template: `` + }) + ) + ).toBe(``) + }) + + test('radio', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ selected: 'foo' }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ selected: 'foo' }), + template: `` + }) + ) + ).toBe(``) + + // non-string values + expect( + await renderToString( + createApp({ + data: () => ({ selected: 'foo' }), + template: `` + }) + ) + ).toBe(``) + }) + + test('checkbox', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ checked: true }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ checked: false }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ checked: ['foo'] }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ checked: [] }), + template: `` + }) + ) + ).toBe(``) + }) + + test('textarea', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ foo: 'hello' }), + template: ``) + }) + + test('dynamic type', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ type: 'text', model: 'hello' }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ type: 'checkbox', model: true }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ type: 'checkbox', model: false }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ type: 'checkbox', model: ['hello'] }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ type: 'checkbox', model: [] }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ type: 'radio', model: 'hello' }), + template: `` + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + data: () => ({ type: 'radio', model: 'hello' }), + template: `` + }) + ) + ).toBe(``) + }) + + test('with v-bind', async () => { + expect( + await renderToString( + createApp({ + data: () => ({ + obj: { type: 'radio', value: 'hello' }, + model: 'hello' + }), + template: `` + }) + ) + ).toBe(``) + }) + }) + + describe('vnode v-show', () => { + test('basic', async () => { + expect( + await renderToString( + createApp({ + render() { + return withDirectives(h('div'), [[vShow, true]]) + } + }) + ) + ).toBe(`
`) + + expect( + await renderToString( + createApp({ + render() { + return withDirectives(h('div'), [[vShow, false]]) + } + }) + ) + ).toBe(`
`) + }) + + test('with merge', async () => { + expect( + await renderToString( + createApp({ + render() { + return withDirectives( + h('div', { + style: { + color: 'red' + } + }), + [[vShow, false]] + ) + } + }) + ) + ).toBe(`
`) + }) + }) + + describe('vnode v-model', () => { + test('text', async () => { + expect( + await renderToString( + createApp({ + render() { + return withDirectives(h('input'), [[vModelText, 'hello']]) + } + }) + ) + ).toBe(``) + }) + + test('radio', async () => { + expect( + await renderToString( + createApp({ + render() { + return withDirectives( + h('input', { type: 'radio', value: 'hello' }), + [[vModelRadio, 'hello']] + ) + } + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return withDirectives( + h('input', { type: 'radio', value: 'hello' }), + [[vModelRadio, 'foo']] + ) + } + }) + ) + ).toBe(``) + }) + + test('checkbox', async () => { + expect( + await renderToString( + createApp({ + render() { + return withDirectives(h('input', { type: 'checkbox' }), [ + [vModelCheckbox, true] + ]) + } + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return withDirectives(h('input', { type: 'checkbox' }), [ + [vModelCheckbox, false] + ]) + } + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return withDirectives( + h('input', { type: 'checkbox', value: 'foo' }), + [[vModelCheckbox, ['foo']]] + ) + } + }) + ) + ).toBe(``) + + expect( + await renderToString( + createApp({ + render() { + return withDirectives( + h('input', { type: 'checkbox', value: 'foo' }), + [[vModelCheckbox, []]] + ) + } + }) + ) + ).toBe(``) + }) + }) + + test('custom directive w/ getSSRProps', async () => { + expect( + await renderToString( + createApp({ + render() { + return withDirectives(h('div'), [ + [ + { + getSSRProps({ value }) { + return { id: value } + } + }, + 'foo' + ] + ]) + } + }) + ) + ).toBe(`
`) + }) +}) diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index 88438efb0..6f12f44c4 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -12,7 +12,10 @@ import { Slots, createApp, ssrContextKey, - warn + warn, + DirectiveBinding, + VNodeProps, + mergeProps } from 'vue' import { ShapeFlags, @@ -289,10 +292,12 @@ function renderElementVNode( parentComponent: ComponentInternalInstance ) { const tag = vnode.type as string - const { props, children, shapeFlag, scopeId } = vnode + let { props, children, shapeFlag, scopeId, dirs } = vnode let openTag = `<${tag}` - // TODO directives + if (dirs !== null) { + props = applySSRDirectives(vnode, props, dirs) + } if (props !== null) { openTag += ssrRenderAttrs(props, tag) @@ -338,6 +343,25 @@ function renderElementVNode( } } +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 renderPortalVNode( vnode: VNode, parentComponent: ComponentInternalInstance