This commit is contained in:
chirokas 2025-06-18 16:40:19 +03:00 committed by GitHub
commit 6f16d9de59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 84 additions and 5 deletions

View File

@ -389,6 +389,23 @@ describe('stringify static html', () => {
]) ])
}) })
test('should remove overloaded boolean attribute for `false`', () => {
const { ast } = compileWithStringify(
`<div>
${repeat(
`<span :hidden="false"></span>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT,
)}
</div>`,
)
expect(ast.cached).toMatchObject([
cachedArrayStaticNodeMatcher(
repeat(`<span></span>`, StringifyThresholds.ELEMENT_WITH_BINDING_COUNT),
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT,
),
])
})
test('should stringify svg', () => { test('should stringify svg', () => {
const svg = `<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">` const svg = `<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">`
const repeated = `<rect width="50" height="50" fill="#C4C4C4"></rect>` const repeated = `<rect width="50" height="50" fill="#C4C4C4"></rect>`

View File

@ -26,6 +26,7 @@ import {
isKnownHtmlAttr, isKnownHtmlAttr,
isKnownMathMLAttr, isKnownMathMLAttr,
isKnownSvgAttr, isKnownSvgAttr,
isOverloadedBooleanAttr,
isString, isString,
isSymbol, isSymbol,
isVoidTag, isVoidTag,
@ -341,7 +342,8 @@ function stringifyElement(
} }
// #6568 // #6568
if ( if (
isBooleanAttr((p.arg as SimpleExpressionNode).content) && (isBooleanAttr((p.arg as SimpleExpressionNode).content) ||
isOverloadedBooleanAttr((p.arg as SimpleExpressionNode).content)) &&
exp.content === 'false' exp.content === 'false'
) { ) {
continue continue

View File

@ -2166,6 +2166,24 @@ describe('SSR hydration', () => {
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
}) })
test('combined boolean/string attribute', () => {
mountWithHydration(`<div></div>`, () => h('div', { hidden: false }))
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
mountWithHydration(`<div hidden></div>`, () => h('div', { hidden: true }))
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
mountWithHydration(`<div hidden="until-found"></div>`, () =>
h('div', { hidden: 'until-found' }),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
mountWithHydration(`<div hidden=""></div>`, () =>
h('div', { hidden: true }),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
test('client value is null or undefined', () => { test('client value is null or undefined', () => {
mountWithHydration(`<div></div>`, () => mountWithHydration(`<div></div>`, () =>
h('div', { draggable: undefined }), h('div', { draggable: undefined }),

View File

@ -21,9 +21,11 @@ import {
getEscapedCssVarName, getEscapedCssVarName,
includeBooleanAttr, includeBooleanAttr,
isBooleanAttr, isBooleanAttr,
isBooleanAttrValue,
isKnownHtmlAttr, isKnownHtmlAttr,
isKnownSvgAttr, isKnownSvgAttr,
isOn, isOn,
isOverloadedBooleanAttr,
isRenderableAttrValue, isRenderableAttrValue,
isReservedProp, isReservedProp,
isString, isString,
@ -842,7 +844,10 @@ function propHasMismatch(
(el instanceof SVGElement && isKnownSvgAttr(key)) || (el instanceof SVGElement && isKnownSvgAttr(key)) ||
(el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
) { ) {
if (isBooleanAttr(key)) { if (
isBooleanAttr(key) ||
(isOverloadedBooleanAttr(key) && isBooleanAttrValue(clientValue))
) {
actual = el.hasAttribute(key) actual = el.hasAttribute(key)
expected = includeBooleanAttr(clientValue) expected = includeBooleanAttr(clientValue)
} else if (clientValue == null) { } else if (clientValue == null) {

View File

@ -264,7 +264,7 @@ export interface HTMLAttributes extends AriaAttributes, EventHandlers<Events> {
contextmenu?: string contextmenu?: string
dir?: string dir?: string
draggable?: Booleanish draggable?: Booleanish
hidden?: Booleanish | '' | 'hidden' | 'until-found' hidden?: boolean | '' | 'hidden' | 'until-found'
id?: string id?: string
inert?: Booleanish inert?: Booleanish
lang?: string lang?: string

View File

@ -55,6 +55,15 @@ describe('ssr: renderAttrs', () => {
).toBe(` checked disabled`) // boolean attr w/ false should be ignored ).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', () => { test('ignore falsy values', () => {
expect( expect(
ssrRenderAttrs({ ssrRenderAttrs({
@ -122,6 +131,13 @@ describe('ssr: renderAttr', () => {
` foo="${escapeHtml(`<script>`)}"`, ` foo="${escapeHtml(`<script>`)}"`,
) )
}) })
test('combined boolean/string attribute', () => {
expect(ssrRenderAttr('hidden', true)).toBe(` hidden`)
expect(ssrRenderAttr('hidden', false)).toBe('')
expect(ssrRenderAttr('hidden', 'until-found')).toBe(` hidden="until-found"`)
expect(ssrRenderAttr('hidden', '')).toBe(` hidden`)
})
}) })
describe('ssr: renderClass', () => { describe('ssr: renderClass', () => {

View File

@ -1,5 +1,7 @@
import { import {
escapeHtml, escapeHtml,
isBooleanAttrValue,
isOverloadedBooleanAttr,
isRenderableAttrValue, isRenderableAttrValue,
isSVGTag, isSVGTag,
stringifyStyle, stringifyStyle,
@ -61,7 +63,10 @@ export function ssrRenderDynamicAttr(
tag && (tag.indexOf('-') > 0 || isSVGTag(tag)) tag && (tag.indexOf('-') > 0 || isSVGTag(tag))
? key // preserve raw name on custom elements and svg ? key // preserve raw name on custom elements and svg
: propsToAttrMap[key] || key.toLowerCase() : propsToAttrMap[key] || key.toLowerCase()
if (isBooleanAttr(attrKey)) { if (
isBooleanAttr(attrKey) ||
(isOverloadedBooleanAttr(attrKey) && isBooleanAttrValue(value))
) {
return includeBooleanAttr(value) ? ` ${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)}"`
@ -79,6 +84,9 @@ export function ssrRenderAttr(key: string, value: unknown): string {
if (!isRenderableAttrValue(value)) { if (!isRenderableAttrValue(value)) {
return `` return ``
} }
if (isOverloadedBooleanAttr(key) && isBooleanAttrValue(value)) {
return includeBooleanAttr(value) ? ` ${key}` : ``
}
return ` ${key}="${escapeHtml(value)}"` return ` ${key}="${escapeHtml(value)}"`
} }

View File

@ -20,7 +20,7 @@ export const isSpecialBooleanAttr: (key: string) => boolean =
*/ */
export const isBooleanAttr: (key: string) => boolean = /*@__PURE__*/ makeMap( export const isBooleanAttr: (key: string) => boolean = /*@__PURE__*/ makeMap(
specialBooleanAttrs + specialBooleanAttrs +
`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` + `,async,autofocus,autoplay,controls,default,defer,disabled,` +
`inert,loop,open,required,reversed,scoped,seamless,` + `inert,loop,open,required,reversed,scoped,seamless,` +
`checked,muted,multiple,selected`, `checked,muted,multiple,selected`,
) )
@ -152,3 +152,16 @@ export function isRenderableAttrValue(value: unknown): boolean {
const type = typeof value const type = typeof value
return type === 'string' || type === 'number' || type === 'boolean' 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 === ''
}