diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 2552f6c0d..97b09eb0d 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -223,7 +223,7 @@ export function createVNode( if (klass != null && !isString(klass)) { props.class = normalizeClass(klass) } - if (style != null) { + if (isObject(style)) { // reactive state objects need to be cloned since they are likely to be // mutated if (isReactive(style) && !isArray(style)) { diff --git a/packages/server-renderer/__tests__/escape.spec.ts b/packages/server-renderer/__tests__/escape.spec.ts deleted file mode 100644 index 6e4c6a277..000000000 --- a/packages/server-renderer/__tests__/escape.spec.ts +++ /dev/null @@ -1 +0,0 @@ -test('ssr: escape HTML', () => {}) diff --git a/packages/server-renderer/__tests__/renderProps.spec.ts b/packages/server-renderer/__tests__/renderProps.spec.ts index b1bbe453e..f5a6f60da 100644 --- a/packages/server-renderer/__tests__/renderProps.spec.ts +++ b/packages/server-renderer/__tests__/renderProps.spec.ts @@ -1,19 +1,124 @@ -describe('ssr: render props', () => { - test('class', () => {}) +import { renderProps, renderClass, renderStyle } from '../src' - test('style', () => { - // only render numbers for properties that allow no unit numbers +describe('ssr: renderProps', () => { + test('ignore reserved props', () => { + expect( + renderProps({ + key: 1, + ref: () => {}, + onClick: () => {} + }) + ).toBe('') }) - test('normal attrs', () => {}) + test('normal attrs', () => { + expect( + renderProps({ + id: 'foo', + title: 'bar' + }) + ).toBe(` id="foo" title="bar"`) + }) - test('boolean attrs', () => {}) + test('escape attrs', () => { + expect( + renderProps({ + id: '"> {}) + test('boolean attrs', () => { + expect( + renderProps({ + checked: true, + multiple: false + }) + ).toBe(` checked`) // boolean attr w/ false should be ignored + }) - test('ignore falsy values', () => {}) + test('ignore falsy values', () => { + expect( + renderProps({ + foo: false, + title: null, + baz: undefined + }) + ).toBe(` foo="false"`) // non boolean should render `false` as is + }) - test('props to attrs', () => {}) - - test('ignore non-renderable props', () => {}) + test('props to attrs', () => { + expect( + renderProps({ + readOnly: true, // simple lower case conversion + htmlFor: 'foobar' // special cases + }) + ).toBe(` readonly for="foobar"`) + }) +}) + +describe('ssr: renderClass', () => { + test('via renderProps', () => { + expect( + renderProps({ + class: ['foo', 'bar'] + }) + ).toBe(` class="foo bar"`) + }) + + test('standalone', () => { + expect(renderClass(`foo`)).toBe(`foo`) + expect(renderClass([`foo`, `bar`])).toBe(`foo bar`) + expect(renderClass({ foo: true, bar: false })).toBe(`foo`) + expect(renderClass([{ foo: true, bar: false }, `baz`])).toBe(`foo baz`) + }) + + test('escape class values', () => { + expect(renderClass(`"> { + test('via renderProps', () => { + expect( + renderProps({ + style: { + color: 'red' + } + }) + ).toBe(` style="color:red;"`) + }) + + test('standalone', () => { + expect(renderStyle(`color:red`)).toBe(`color:red`) + expect( + renderStyle({ + color: `red` + }) + ).toBe(`color:red;`) + expect( + renderStyle([ + { color: `red` }, + { fontSize: `15px` } // case conversion + ]) + ).toBe(`color:red;font-size:15px;`) + }) + + test('number handling', () => { + expect( + renderStyle({ + fontSize: 15, // should be ignored since font-size requires unit + opacity: 0.5 + }) + ).toBe(`opacity:0.5;`) + }) + + test('escape inline CSS', () => { + expect(renderStyle(`"> { + expect(escapeHtml(`foo`)).toBe(`foo`) + expect(escapeHtml(true)).toBe(`true`) + expect(escapeHtml(false)).toBe(`false`) + expect(escapeHtml(`a && b`)).toBe(`a && b`) + expect(escapeHtml(`"foo"`)).toBe(`"foo"`) + expect(escapeHtml(`'bar'`)).toBe(`'bar'`) + expect(escapeHtml(`
`)).toBe(`<div>`) +}) + +test('ssr: interpolate', () => { + expect(interpolate(0)).toBe(`0`) + expect(interpolate(`foo`)).toBe(`foo`) + expect(interpolate(`
`)).toBe(`<div>`) + // should escape interpolated values + expect(interpolate([1, 2, 3])).toBe( + escapeHtml(JSON.stringify([1, 2, 3], null, 2)) + ) + expect( + interpolate({ + foo: 1, + bar: `
` + }) + ).toBe( + escapeHtml( + JSON.stringify( + { + foo: 1, + bar: `
` + }, + null, + 2 + ) + ) + ) +}) diff --git a/packages/server-renderer/src/index.ts b/packages/server-renderer/src/index.ts index 22885a582..4a4d0fbf3 100644 --- a/packages/server-renderer/src/index.ts +++ b/packages/server-renderer/src/index.ts @@ -1,11 +1,3 @@ -import { toDisplayString } from 'vue' -import { escape } from './escape' - -export { escape } - -export function interpolate(value: unknown) { - return escape(toDisplayString(value)) -} - export { renderToString, renderComponent, renderSlot } from './renderToString' export { renderClass, renderStyle, renderProps } from './renderProps' +export { escapeHtml, interpolate } from './ssrUtils' diff --git a/packages/server-renderer/src/renderProps.ts b/packages/server-renderer/src/renderProps.ts index ce0abf2b5..112055d89 100644 --- a/packages/server-renderer/src/renderProps.ts +++ b/packages/server-renderer/src/renderProps.ts @@ -1,4 +1,4 @@ -import { escape } from './escape' +import { escapeHtml } from './ssrUtils' import { normalizeClass, normalizeStyle, @@ -9,7 +9,7 @@ import { isOn, isSSRSafeAttrName, isBooleanAttr -} from '@vue/shared/src' +} from '@vue/shared' export function renderProps( props: Record, @@ -34,7 +34,7 @@ export function renderProps( ret += ` ${attrKey}` } } else if (isSSRSafeAttrName(attrKey)) { - ret += ` ${attrKey}="${escape(value)}"` + ret += ` ${attrKey}="${escapeHtml(value)}"` } } } @@ -42,13 +42,16 @@ export function renderProps( } export function renderClass(raw: unknown): string { - return escape(normalizeClass(raw)) + return escapeHtml(normalizeClass(raw)) } export function renderStyle(raw: unknown): string { if (!raw) { return '' } + if (isString(raw)) { + return escapeHtml(raw) + } const styles = normalizeStyle(raw) let ret = '' for (const key in styles) { @@ -62,5 +65,5 @@ export function renderStyle(raw: unknown): string { ret += `${normalizedKey}:${value};` } } - return escape(ret) + return escapeHtml(ret) } diff --git a/packages/server-renderer/src/renderToString.ts b/packages/server-renderer/src/renderToString.ts index 291593aa5..a88c62c35 100644 --- a/packages/server-renderer/src/renderToString.ts +++ b/packages/server-renderer/src/renderToString.ts @@ -22,7 +22,7 @@ import { isVoidTag } from '@vue/shared' import { renderProps } from './renderProps' -import { escape } from './escape' +import { escapeHtml } from './ssrUtils' const { createComponentInstance, @@ -105,7 +105,7 @@ function renderComponentVNode( const instance = createComponentInstance(vnode, parentComponent) const res = setupComponent( instance, - null /* parentSuspense */, + null /* parentSuspense (no need to track for SSR) */, true /* isSSR */ ) if (isPromise(res)) { @@ -225,15 +225,15 @@ function renderElement( push(props.innerHTML) } else if (props.textContent) { hasChildrenOverride = true - push(escape(props.textContent)) + push(escapeHtml(props.textContent)) } else if (tag === 'textarea' && props.value) { hasChildrenOverride = true - push(escape(props.value)) + push(escapeHtml(props.value)) } } if (!hasChildrenOverride) { if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { - push(escape(children as string)) + push(escapeHtml(children as string)) } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { renderVNodeChildren( push, diff --git a/packages/server-renderer/src/escape.ts b/packages/server-renderer/src/ssrUtils.ts similarity index 82% rename from packages/server-renderer/src/escape.ts rename to packages/server-renderer/src/ssrUtils.ts index 6cfe94039..9c71e9020 100644 --- a/packages/server-renderer/src/escape.ts +++ b/packages/server-renderer/src/ssrUtils.ts @@ -1,6 +1,8 @@ +import { toDisplayString } from '@vue/shared' + const escapeRE = /["'&<>]/ -export function escape(string: unknown) { +export function escapeHtml(string: unknown) { const str = '' + string const match = escapeRE.exec(str) @@ -43,3 +45,7 @@ export function escape(string: unknown) { return lastIndex !== index ? html + str.substring(lastIndex, index) : html } + +export function interpolate(value: unknown) { + return escapeHtml(toDisplayString(value)) +} diff --git a/packages/shared/src/domAttrConfig.ts b/packages/shared/src/domAttrConfig.ts index 0187ea289..5ae2ab7a2 100644 --- a/packages/shared/src/domAttrConfig.ts +++ b/packages/shared/src/domAttrConfig.ts @@ -15,8 +15,8 @@ export const isSpecialBooleanAttr = /*#__PURE__*/ makeMap(specialBooleanAttrs) // The full list is needed during SSR to produce the correct initial markup. export const isBooleanAttr = /*#__PURE__*/ makeMap( specialBooleanAttrs + - `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,ismap,` + - `loop,nomodule,open,required,reversed,scoped,seamless,` + + `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` + + `loop,open,required,reversed,scoped,seamless,` + `checked,muted,multiple,selected` )