fix(runtime-dom): consistently remove boolean attributes for falsy values (#4348)
This commit is contained in:
		
							parent
							
								
									f855ccb2c1
								
							
						
					
					
						commit
						620a69b871
					
				| @ -177,7 +177,7 @@ describe('ssr: element', () => { | |||||||
|       expect(getCompiledString(`<input type="checkbox" :checked="checked">`)) |       expect(getCompiledString(`<input type="checkbox" :checked="checked">`)) | ||||||
|         .toMatchInlineSnapshot(` |         .toMatchInlineSnapshot(` | ||||||
|         "\`<input type=\\"checkbox\\"\${ |         "\`<input type=\\"checkbox\\"\${ | ||||||
|             (_ctx.checked) ? \\" checked\\" : \\"\\" |             (_ssrIncludeBooleanAttr(_ctx.checked)) ? \\" checked\\" : \\"\\" | ||||||
|           }>\`"
 |           }>\`"
 | ||||||
|       `)
 |       `)
 | ||||||
|     }) |     }) | ||||||
|  | |||||||
| @ -37,13 +37,13 @@ describe('ssr: v-model', () => { | |||||||
|     expect( |     expect( | ||||||
|       compileWithWrapper(`<input type="radio" value="foo" v-model="bar">`).code |       compileWithWrapper(`<input type="radio" value="foo" v-model="bar">`).code | ||||||
|     ).toMatchInlineSnapshot(` |     ).toMatchInlineSnapshot(` | ||||||
|       "const { ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") |       "const { ssrLooseEqual: _ssrLooseEqual, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") | ||||||
| 
 | 
 | ||||||
|       return function ssrRender(_ctx, _push, _parent, _attrs) { |       return function ssrRender(_ctx, _push, _parent, _attrs) { | ||||||
|         _push(\`<div\${ |         _push(\`<div\${ | ||||||
|           _ssrRenderAttrs(_attrs) |           _ssrRenderAttrs(_attrs) | ||||||
|         }><input type=\\"radio\\" value=\\"foo\\"\${ |         }><input type=\\"radio\\" value=\\"foo\\"\${ | ||||||
|           (_ssrLooseEqual(_ctx.bar, \\"foo\\")) ? \\" checked\\" : \\"\\" |           (_ssrIncludeBooleanAttr(_ssrLooseEqual(_ctx.bar, \\"foo\\"))) ? \\" checked\\" : \\"\\" | ||||||
|         }></div>\`)
 |         }></div>\`)
 | ||||||
|       }" |       }" | ||||||
|     `)
 |     `)
 | ||||||
| @ -52,15 +52,15 @@ describe('ssr: v-model', () => { | |||||||
|   test('<input type="checkbox">', () => { |   test('<input type="checkbox">', () => { | ||||||
|     expect(compileWithWrapper(`<input type="checkbox" v-model="bar">`).code) |     expect(compileWithWrapper(`<input type="checkbox" v-model="bar">`).code) | ||||||
|       .toMatchInlineSnapshot(` |       .toMatchInlineSnapshot(` | ||||||
|       "const { ssrLooseContain: _ssrLooseContain, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") |       "const { ssrLooseContain: _ssrLooseContain, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") | ||||||
| 
 | 
 | ||||||
|       return function ssrRender(_ctx, _push, _parent, _attrs) { |       return function ssrRender(_ctx, _push, _parent, _attrs) { | ||||||
|         _push(\`<div\${ |         _push(\`<div\${ | ||||||
|           _ssrRenderAttrs(_attrs) |           _ssrRenderAttrs(_attrs) | ||||||
|         }><input type=\\"checkbox\\"\${ |         }><input type=\\"checkbox\\"\${ | ||||||
|           ((Array.isArray(_ctx.bar)) |           (_ssrIncludeBooleanAttr((Array.isArray(_ctx.bar)) | ||||||
|             ? _ssrLooseContain(_ctx.bar, null) |             ? _ssrLooseContain(_ctx.bar, null) | ||||||
|             : _ctx.bar) ? \\" checked\\" : \\"\\" |             : _ctx.bar)) ? \\" checked\\" : \\"\\" | ||||||
|         }></div>\`)
 |         }></div>\`)
 | ||||||
|       }" |       }" | ||||||
|     `)
 |     `)
 | ||||||
| @ -69,15 +69,15 @@ describe('ssr: v-model', () => { | |||||||
|       compileWithWrapper(`<input type="checkbox" value="foo" v-model="bar">`) |       compileWithWrapper(`<input type="checkbox" value="foo" v-model="bar">`) | ||||||
|         .code |         .code | ||||||
|     ).toMatchInlineSnapshot(` |     ).toMatchInlineSnapshot(` | ||||||
|       "const { ssrLooseContain: _ssrLooseContain, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") |       "const { ssrLooseContain: _ssrLooseContain, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") | ||||||
| 
 | 
 | ||||||
|       return function ssrRender(_ctx, _push, _parent, _attrs) { |       return function ssrRender(_ctx, _push, _parent, _attrs) { | ||||||
|         _push(\`<div\${ |         _push(\`<div\${ | ||||||
|           _ssrRenderAttrs(_attrs) |           _ssrRenderAttrs(_attrs) | ||||||
|         }><input type=\\"checkbox\\" value=\\"foo\\"\${ |         }><input type=\\"checkbox\\" value=\\"foo\\"\${ | ||||||
|           ((Array.isArray(_ctx.bar)) |           (_ssrIncludeBooleanAttr((Array.isArray(_ctx.bar)) | ||||||
|             ? _ssrLooseContain(_ctx.bar, \\"foo\\") |             ? _ssrLooseContain(_ctx.bar, \\"foo\\") | ||||||
|             : _ctx.bar) ? \\" checked\\" : \\"\\" |             : _ctx.bar)) ? \\" checked\\" : \\"\\" | ||||||
|         }></div>\`)
 |         }></div>\`)
 | ||||||
|       }" |       }" | ||||||
|     `)
 |     `)
 | ||||||
| @ -87,13 +87,13 @@ describe('ssr: v-model', () => { | |||||||
|         `<input type="checkbox" :true-value="foo" :false-value="bar" v-model="baz">` |         `<input type="checkbox" :true-value="foo" :false-value="bar" v-model="baz">` | ||||||
|       ).code |       ).code | ||||||
|     ).toMatchInlineSnapshot(` |     ).toMatchInlineSnapshot(` | ||||||
|       "const { ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") |       "const { ssrLooseEqual: _ssrLooseEqual, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") | ||||||
| 
 | 
 | ||||||
|       return function ssrRender(_ctx, _push, _parent, _attrs) { |       return function ssrRender(_ctx, _push, _parent, _attrs) { | ||||||
|         _push(\`<div\${ |         _push(\`<div\${ | ||||||
|           _ssrRenderAttrs(_attrs) |           _ssrRenderAttrs(_attrs) | ||||||
|         }><input type=\\"checkbox\\"\${ |         }><input type=\\"checkbox\\"\${ | ||||||
|           (_ssrLooseEqual(_ctx.baz, _ctx.foo)) ? \\" checked\\" : \\"\\" |           (_ssrIncludeBooleanAttr(_ssrLooseEqual(_ctx.baz, _ctx.foo))) ? \\" checked\\" : \\"\\" | ||||||
|         }></div>\`)
 |         }></div>\`)
 | ||||||
|       }" |       }" | ||||||
|     `)
 |     `)
 | ||||||
| @ -103,13 +103,13 @@ describe('ssr: v-model', () => { | |||||||
|         `<input type="checkbox" true-value="foo" false-value="bar" v-model="baz">` |         `<input type="checkbox" true-value="foo" false-value="bar" v-model="baz">` | ||||||
|       ).code |       ).code | ||||||
|     ).toMatchInlineSnapshot(` |     ).toMatchInlineSnapshot(` | ||||||
|       "const { ssrLooseEqual: _ssrLooseEqual, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") |       "const { ssrLooseEqual: _ssrLooseEqual, ssrIncludeBooleanAttr: _ssrIncludeBooleanAttr, ssrRenderAttrs: _ssrRenderAttrs } = require(\\"@vue/server-renderer\\") | ||||||
| 
 | 
 | ||||||
|       return function ssrRender(_ctx, _push, _parent, _attrs) { |       return function ssrRender(_ctx, _push, _parent, _attrs) { | ||||||
|         _push(\`<div\${ |         _push(\`<div\${ | ||||||
|           _ssrRenderAttrs(_attrs) |           _ssrRenderAttrs(_attrs) | ||||||
|         }><input type=\\"checkbox\\"\${ |         }><input type=\\"checkbox\\"\${ | ||||||
|           (_ssrLooseEqual(_ctx.baz, \\"foo\\")) ? \\" checked\\" : \\"\\" |           (_ssrIncludeBooleanAttr(_ssrLooseEqual(_ctx.baz, \\"foo\\"))) ? \\" checked\\" : \\"\\" | ||||||
|         }></div>\`)
 |         }></div>\`)
 | ||||||
|       }" |       }" | ||||||
|     `)
 |     `)
 | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ export const SSR_RENDER_ATTRS = Symbol(`ssrRenderAttrs`) | |||||||
| export const SSR_RENDER_ATTR = Symbol(`ssrRenderAttr`) | export const SSR_RENDER_ATTR = Symbol(`ssrRenderAttr`) | ||||||
| export const SSR_RENDER_DYNAMIC_ATTR = Symbol(`ssrRenderDynamicAttr`) | export const SSR_RENDER_DYNAMIC_ATTR = Symbol(`ssrRenderDynamicAttr`) | ||||||
| export const SSR_RENDER_LIST = Symbol(`ssrRenderList`) | export const SSR_RENDER_LIST = Symbol(`ssrRenderList`) | ||||||
|  | export const SSR_INCLUDE_BOOLEAN_ATTR = Symbol(`ssrIncludeBooleanAttr`) | ||||||
| export const SSR_LOOSE_EQUAL = Symbol(`ssrLooseEqual`) | export const SSR_LOOSE_EQUAL = Symbol(`ssrLooseEqual`) | ||||||
| export const SSR_LOOSE_CONTAIN = Symbol(`ssrLooseContain`) | export const SSR_LOOSE_CONTAIN = Symbol(`ssrLooseContain`) | ||||||
| export const SSR_RENDER_DYNAMIC_MODEL = Symbol(`ssrRenderDynamicModel`) | export const SSR_RENDER_DYNAMIC_MODEL = Symbol(`ssrRenderDynamicModel`) | ||||||
| @ -28,6 +29,7 @@ export const ssrHelpers = { | |||||||
|   [SSR_RENDER_ATTR]: `ssrRenderAttr`, |   [SSR_RENDER_ATTR]: `ssrRenderAttr`, | ||||||
|   [SSR_RENDER_DYNAMIC_ATTR]: `ssrRenderDynamicAttr`, |   [SSR_RENDER_DYNAMIC_ATTR]: `ssrRenderDynamicAttr`, | ||||||
|   [SSR_RENDER_LIST]: `ssrRenderList`, |   [SSR_RENDER_LIST]: `ssrRenderList`, | ||||||
|  |   [SSR_INCLUDE_BOOLEAN_ATTR]: `ssrIncludeBooleanAttr`, | ||||||
|   [SSR_LOOSE_EQUAL]: `ssrLooseEqual`, |   [SSR_LOOSE_EQUAL]: `ssrLooseEqual`, | ||||||
|   [SSR_LOOSE_CONTAIN]: `ssrLooseContain`, |   [SSR_LOOSE_CONTAIN]: `ssrLooseContain`, | ||||||
|   [SSR_RENDER_DYNAMIC_MODEL]: `ssrRenderDynamicModel`, |   [SSR_RENDER_DYNAMIC_MODEL]: `ssrRenderDynamicModel`, | ||||||
|  | |||||||
| @ -43,7 +43,8 @@ import { | |||||||
|   SSR_RENDER_DYNAMIC_ATTR, |   SSR_RENDER_DYNAMIC_ATTR, | ||||||
|   SSR_RENDER_ATTRS, |   SSR_RENDER_ATTRS, | ||||||
|   SSR_INTERPOLATE, |   SSR_INTERPOLATE, | ||||||
|   SSR_GET_DYNAMIC_MODEL_PROPS |   SSR_GET_DYNAMIC_MODEL_PROPS, | ||||||
|  |   SSR_INCLUDE_BOOLEAN_ATTR | ||||||
| } from '../runtimeHelpers' | } from '../runtimeHelpers' | ||||||
| import { SSRTransformContext, processChildren } from '../ssrCodegenTransform' | import { SSRTransformContext, processChildren } from '../ssrCodegenTransform' | ||||||
| 
 | 
 | ||||||
| @ -237,7 +238,10 @@ export const ssrTransformElement: NodeTransform = (node, context) => { | |||||||
|                   if (isBooleanAttr(attrName)) { |                   if (isBooleanAttr(attrName)) { | ||||||
|                     openTag.push( |                     openTag.push( | ||||||
|                       createConditionalExpression( |                       createConditionalExpression( | ||||||
|                         value, |                         createCallExpression( | ||||||
|  |                           context.helper(SSR_INCLUDE_BOOLEAN_ATTR), | ||||||
|  |                           [value] | ||||||
|  |                         ), | ||||||
|                         createSimpleExpression(' ' + attrName, true), |                         createSimpleExpression(' ' + attrName, true), | ||||||
|                         createSimpleExpression('', true), |                         createSimpleExpression('', true), | ||||||
|                         false /* no newline */ |                         false /* no newline */ | ||||||
|  | |||||||
| @ -23,6 +23,18 @@ describe('runtime-dom: attrs patching', () => { | |||||||
|     expect(el.getAttribute('readonly')).toBe('') |     expect(el.getAttribute('readonly')).toBe('') | ||||||
|     patchProp(el, 'readonly', true, false) |     patchProp(el, 'readonly', true, false) | ||||||
|     expect(el.getAttribute('readonly')).toBe(null) |     expect(el.getAttribute('readonly')).toBe(null) | ||||||
|  |     patchProp(el, 'readonly', false, '') | ||||||
|  |     expect(el.getAttribute('readonly')).toBe('') | ||||||
|  |     patchProp(el, 'readonly', '', 0) | ||||||
|  |     expect(el.getAttribute('readonly')).toBe(null) | ||||||
|  |     patchProp(el, 'readonly', 0, '0') | ||||||
|  |     expect(el.getAttribute('readonly')).toBe('') | ||||||
|  |     patchProp(el, 'readonly', '0', false) | ||||||
|  |     expect(el.getAttribute('readonly')).toBe(null) | ||||||
|  |     patchProp(el, 'readonly', false, 1) | ||||||
|  |     expect(el.getAttribute('readonly')).toBe('') | ||||||
|  |     patchProp(el, 'readonly', 1, undefined) | ||||||
|  |     expect(el.getAttribute('readonly')).toBe(null) | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   test('attributes', () => { |   test('attributes', () => { | ||||||
|  | |||||||
| @ -43,6 +43,18 @@ describe('runtime-dom: props patching', () => { | |||||||
|     expect(el.multiple).toBe(true) |     expect(el.multiple).toBe(true) | ||||||
|     patchProp(el, 'multiple', null, null) |     patchProp(el, 'multiple', null, null) | ||||||
|     expect(el.multiple).toBe(false) |     expect(el.multiple).toBe(false) | ||||||
|  |     patchProp(el, 'multiple', null, true) | ||||||
|  |     expect(el.multiple).toBe(true) | ||||||
|  |     patchProp(el, 'multiple', null, 0) | ||||||
|  |     expect(el.multiple).toBe(false) | ||||||
|  |     patchProp(el, 'multiple', null, '0') | ||||||
|  |     expect(el.multiple).toBe(true) | ||||||
|  |     patchProp(el, 'multiple', null, false) | ||||||
|  |     expect(el.multiple).toBe(false) | ||||||
|  |     patchProp(el, 'multiple', null, 1) | ||||||
|  |     expect(el.multiple).toBe(true) | ||||||
|  |     patchProp(el, 'multiple', null, undefined) | ||||||
|  |     expect(el.multiple).toBe(false) | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   test('innerHTML unmount prev children', () => { |   test('innerHTML unmount prev children', () => { | ||||||
|  | |||||||
| @ -1,4 +1,9 @@ | |||||||
| import { isSpecialBooleanAttr, makeMap, NOOP } from '@vue/shared' | import { | ||||||
|  |   includeBooleanAttr, | ||||||
|  |   isSpecialBooleanAttr, | ||||||
|  |   makeMap, | ||||||
|  |   NOOP | ||||||
|  | } from '@vue/shared' | ||||||
| import { | import { | ||||||
|   compatUtils, |   compatUtils, | ||||||
|   ComponentInternalInstance, |   ComponentInternalInstance, | ||||||
| @ -28,7 +33,7 @@ export function patchAttr( | |||||||
|     // note we are only checking boolean attributes that don't have a
 |     // note we are only checking boolean attributes that don't have a
 | ||||||
|     // corresponding dom prop of the same name here.
 |     // corresponding dom prop of the same name here.
 | ||||||
|     const isBoolean = isSpecialBooleanAttr(key) |     const isBoolean = isSpecialBooleanAttr(key) | ||||||
|     if (value == null || (isBoolean && value === false)) { |     if (value == null || (isBoolean && !includeBooleanAttr(value))) { | ||||||
|       el.removeAttribute(key) |       el.removeAttribute(key) | ||||||
|     } else { |     } else { | ||||||
|       el.setAttribute(key, isBoolean ? '' : value) |       el.setAttribute(key, isBoolean ? '' : value) | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ | |||||||
| // This can come from explicit usage of v-html or innerHTML as a prop in render
 | // This can come from explicit usage of v-html or innerHTML as a prop in render
 | ||||||
| 
 | 
 | ||||||
| import { warn, DeprecationTypes, compatUtils } from '@vue/runtime-core' | import { warn, DeprecationTypes, compatUtils } from '@vue/runtime-core' | ||||||
|  | import { includeBooleanAttr } from '@vue/shared' | ||||||
| 
 | 
 | ||||||
| // functions. The user is responsible for using them with only trusted content.
 | // functions. The user is responsible for using them with only trusted content.
 | ||||||
| export function patchDOMProp( | export function patchDOMProp( | ||||||
| @ -41,9 +42,9 @@ export function patchDOMProp( | |||||||
| 
 | 
 | ||||||
|   if (value === '' || value == null) { |   if (value === '' || value == null) { | ||||||
|     const type = typeof el[key] |     const type = typeof el[key] | ||||||
|     if (value === '' && type === 'boolean') { |     if (type === 'boolean') { | ||||||
|       // e.g. <select multiple> compiles to { multiple: '' }
 |       // e.g. <select multiple> compiles to { multiple: '' }
 | ||||||
|       el[key] = true |       el[key] = includeBooleanAttr(value) | ||||||
|       return |       return | ||||||
|     } else if (value == null && type === 'string') { |     } else if (value == null && type === 'string') { | ||||||
|       // e.g. <div :id="null">
 |       // e.g. <div :id="null">
 | ||||||
|  | |||||||
| @ -50,9 +50,11 @@ describe('ssr: renderAttrs', () => { | |||||||
|     expect( |     expect( | ||||||
|       ssrRenderAttrs({ |       ssrRenderAttrs({ | ||||||
|         checked: true, |         checked: true, | ||||||
|         multiple: false |         multiple: false, | ||||||
|  |         readonly: 0, | ||||||
|  |         disabled: '' | ||||||
|       }) |       }) | ||||||
|     ).toBe(` checked`) // boolean attr w/ false should be ignored
 |     ).toBe(` checked disabled`) // boolean attr w/ false should be ignored
 | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   test('ignore falsy values', () => { |   test('ignore falsy values', () => { | ||||||
|  | |||||||
| @ -7,6 +7,7 @@ import { | |||||||
|   isOn, |   isOn, | ||||||
|   isSSRSafeAttrName, |   isSSRSafeAttrName, | ||||||
|   isBooleanAttr, |   isBooleanAttr, | ||||||
|  |   includeBooleanAttr, | ||||||
|   makeMap |   makeMap | ||||||
| } from '@vue/shared' | } from '@vue/shared' | ||||||
| 
 | 
 | ||||||
| @ -52,7 +53,7 @@ export function ssrRenderDynamicAttr( | |||||||
|       ? key // preserve raw name on custom elements
 |       ? key // preserve raw name on custom elements
 | ||||||
|       : propsToAttrMap[key] || key.toLowerCase() |       : propsToAttrMap[key] || key.toLowerCase() | ||||||
|   if (isBooleanAttr(attrKey)) { |   if (isBooleanAttr(attrKey)) { | ||||||
|     return value === false ? `` : ` ${attrKey}` |     return includeBooleanAttr(value) ? ` ${attrKey}` : `` | ||||||
|   } else if (isSSRSafeAttrName(attrKey)) { |   } else if (isSSRSafeAttrName(attrKey)) { | ||||||
|     return value === '' ? ` ${attrKey}` : ` ${attrKey}="${escapeHtml(value)}"` |     return value === '' ? ` ${attrKey}` : ` ${attrKey}="${escapeHtml(value)}"` | ||||||
|   } else { |   } else { | ||||||
|  | |||||||
| @ -27,6 +27,7 @@ export { | |||||||
| export { ssrInterpolate } from './helpers/ssrInterpolate' | export { ssrInterpolate } from './helpers/ssrInterpolate' | ||||||
| export { ssrRenderList } from './helpers/ssrRenderList' | export { ssrRenderList } from './helpers/ssrRenderList' | ||||||
| export { ssrRenderSuspense } from './helpers/ssrRenderSuspense' | export { ssrRenderSuspense } from './helpers/ssrRenderSuspense' | ||||||
|  | export { includeBooleanAttr as ssrIncludeBooleanAttr } from '@vue/shared' | ||||||
| 
 | 
 | ||||||
| // v-model helpers
 | // v-model helpers
 | ||||||
| export { | export { | ||||||
|  | |||||||
| @ -24,6 +24,14 @@ export const isBooleanAttr = /*#__PURE__*/ makeMap( | |||||||
|     `checked,muted,multiple,selected` |     `checked,muted,multiple,selected` | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Boolean attributes should be included if the value is truthy or ''. | ||||||
|  |  * e.g. <select multiple> compiles to { multiple: '' } | ||||||
|  |  */ | ||||||
|  | export function includeBooleanAttr(value: unknown): boolean { | ||||||
|  |   return !!value || value === '' | ||||||
|  | } | ||||||
|  | 
 | ||||||
| const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/ | const unsafeAttrCharRE = /[>/="'\u0009\u000a\u000c\u0020]/ | ||||||
| const attrValidationCache: Record<string, boolean> = {} | const attrValidationCache: Record<string, boolean> = {} | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user