diff --git a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts index f58e207d6cf..f3b959f638a 100644 --- a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts +++ b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts @@ -389,6 +389,23 @@ describe('stringify static html', () => { ]) }) + test('should remove overloaded boolean attribute for `false`', () => { + const { ast } = compileWithStringify( + `
+ ${repeat( + ``, + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT, + )} +
`, + ) + expect(ast.cached).toMatchObject([ + cachedArrayStaticNodeMatcher( + repeat(``, StringifyThresholds.ELEMENT_WITH_BINDING_COUNT), + StringifyThresholds.ELEMENT_WITH_BINDING_COUNT, + ), + ]) + }) + test('should stringify svg', () => { const svg = `` const repeated = `` diff --git a/packages/compiler-dom/src/transforms/stringifyStatic.ts b/packages/compiler-dom/src/transforms/stringifyStatic.ts index cd8f1a9d184..83f57018338 100644 --- a/packages/compiler-dom/src/transforms/stringifyStatic.ts +++ b/packages/compiler-dom/src/transforms/stringifyStatic.ts @@ -26,6 +26,7 @@ import { isKnownHtmlAttr, isKnownMathMLAttr, isKnownSvgAttr, + isOverloadedBooleanAttr, isString, isSymbol, isVoidTag, @@ -341,7 +342,8 @@ function stringifyElement( } // #6568 if ( - isBooleanAttr((p.arg as SimpleExpressionNode).content) && + (isBooleanAttr((p.arg as SimpleExpressionNode).content) || + isOverloadedBooleanAttr((p.arg as SimpleExpressionNode).content)) && exp.content === 'false' ) { continue diff --git a/packages/compiler-ssr/__tests__/ssrElement.spec.ts b/packages/compiler-ssr/__tests__/ssrElement.spec.ts index f1d509acfb0..5a4fc30ed45 100644 --- a/packages/compiler-ssr/__tests__/ssrElement.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrElement.spec.ts @@ -191,6 +191,20 @@ describe('ssr: element', () => { `) }) + test('v-bind:arg (overloaded boolean)', () => { + expect( + getCompiledString( + `
`, + ), + ).toMatchInlineSnapshot(` + "\`
\`" + `) + }) + test('v-bind:[arg]', () => { expect(getCompiledString(`
`)) .toMatchInlineSnapshot(` diff --git a/packages/compiler-ssr/src/transforms/ssrTransformElement.ts b/packages/compiler-ssr/src/transforms/ssrTransformElement.ts index 4a12b0f7ba7..4609c1b6791 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformElement.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformElement.ts @@ -38,6 +38,7 @@ import { escapeHtml, isBooleanAttr, isBuiltInDirective, + isOverloadedBooleanAttr, isSSRSafeAttrName, propsToAttrMap, } from '@vue/shared' @@ -287,6 +288,13 @@ export const ssrTransformElement: NodeTransform = (node, context) => { false /* no newline */, ), ) + } else if (isOverloadedBooleanAttr(attrName)) { + openTag.push( + createCallExpression( + context.helper(SSR_RENDER_DYNAMIC_ATTR), + [key, value], + ), + ) } else if (isSSRSafeAttrName(attrName)) { openTag.push( createCallExpression(context.helper(SSR_RENDER_ATTR), [ diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 4a9e0fac2b6..441ebd3d986 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -2166,6 +2166,24 @@ describe('SSR hydration', () => { expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() }) + test('combined boolean/string attribute', () => { + mountWithHydration(`
`, () => h('div', { hidden: false })) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + mountWithHydration(``, () => h('div', { hidden: true })) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + mountWithHydration(``, () => + h('div', { hidden: 'until-found' }), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + mountWithHydration(``, () => + h('div', { hidden: true }), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + test('client value is null or undefined', () => { mountWithHydration(`
`, () => h('div', { draggable: undefined }), diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 12813b598b5..3920fafa651 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -21,9 +21,11 @@ import { getEscapedCssVarName, includeBooleanAttr, isBooleanAttr, + isBooleanAttrValue, isKnownHtmlAttr, isKnownSvgAttr, isOn, + isOverloadedBooleanAttr, isRenderableAttrValue, isReservedProp, isString, @@ -842,7 +844,10 @@ function propHasMismatch( (el instanceof SVGElement && isKnownSvgAttr(key)) || (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) ) { - if (isBooleanAttr(key)) { + if ( + isBooleanAttr(key) || + (isOverloadedBooleanAttr(key) && isBooleanAttrValue(clientValue)) + ) { actual = el.hasAttribute(key) expected = includeBooleanAttr(clientValue) } else if (clientValue == null) { diff --git a/packages/runtime-dom/src/jsx.ts b/packages/runtime-dom/src/jsx.ts index 5292441cde9..0240695cdda 100644 --- a/packages/runtime-dom/src/jsx.ts +++ b/packages/runtime-dom/src/jsx.ts @@ -264,7 +264,7 @@ export interface HTMLAttributes extends AriaAttributes, EventHandlers { contextmenu?: string dir?: string draggable?: Booleanish - hidden?: Booleanish | '' | 'hidden' | 'until-found' + hidden?: boolean | '' | 'hidden' | 'until-found' id?: string inert?: Booleanish lang?: string diff --git a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts index 9f33866e5a8..18deac23f4f 100644 --- a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts +++ b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts @@ -55,6 +55,15 @@ describe('ssr: renderAttrs', () => { ).toBe(` checked disabled`) // boolean attr w/ false should be ignored }) + test('combined boolean/string attribute', () => { + expect(ssrRenderAttrs({ hidden: true })).toBe(` hidden`) + expect(ssrRenderAttrs({ disabled: true, hidden: false })).toBe(` disabled`) + expect(ssrRenderAttrs({ hidden: 'until-found' })).toBe( + ` hidden="until-found"`, + ) + expect(ssrRenderAttrs({ hidden: '' })).toBe(` hidden`) + }) + test('ignore falsy values', () => { expect( ssrRenderAttrs({ diff --git a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts b/packages/server-renderer/src/helpers/ssrRenderAttrs.ts index 9689b4185c6..4b7121e864c 100644 --- a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts +++ b/packages/server-renderer/src/helpers/ssrRenderAttrs.ts @@ -1,5 +1,7 @@ import { escapeHtml, + isBooleanAttrValue, + isOverloadedBooleanAttr, isRenderableAttrValue, isSVGTag, stringifyStyle, @@ -61,7 +63,10 @@ export function ssrRenderDynamicAttr( tag && (tag.indexOf('-') > 0 || isSVGTag(tag)) ? key // preserve raw name on custom elements and svg : propsToAttrMap[key] || key.toLowerCase() - if (isBooleanAttr(attrKey)) { + if ( + isBooleanAttr(attrKey) || + (isOverloadedBooleanAttr(attrKey) && isBooleanAttrValue(value)) + ) { return includeBooleanAttr(value) ? ` ${attrKey}` : `` } else if (isSSRSafeAttrName(attrKey)) { return value === '' ? ` ${attrKey}` : ` ${attrKey}="${escapeHtml(value)}"` diff --git a/packages/shared/src/domAttrConfig.ts b/packages/shared/src/domAttrConfig.ts index b5f0166327f..73e48a0e700 100644 --- a/packages/shared/src/domAttrConfig.ts +++ b/packages/shared/src/domAttrConfig.ts @@ -20,7 +20,7 @@ export const isSpecialBooleanAttr: (key: string) => boolean = */ export const isBooleanAttr: (key: string) => boolean = /*@__PURE__*/ makeMap( specialBooleanAttrs + - `,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` + + `,async,autofocus,autoplay,controls,default,defer,disabled,` + `inert,loop,open,required,reversed,scoped,seamless,` + `checked,muted,multiple,selected`, ) @@ -152,3 +152,16 @@ export function isRenderableAttrValue(value: unknown): boolean { const type = typeof value return type === 'string' || type === 'number' || type === 'boolean' } + +/** + * An attribute that can be used as a flag as well as with a value. + * When `true`, it should be present (set either to an empty string or its name). + * When `false`, it should be omitted. + * For any other value, should be present with that value. + */ +export const isOverloadedBooleanAttr: (key: string) => boolean = + /*@__PURE__*/ makeMap('hidden') + +export function isBooleanAttrValue(value: unknown): boolean { + return typeof value === 'boolean' || value === '' +}