From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:50:07 +0000 (+0800) Subject: fix(ssr): normalize hidden states during hydration (#13125) X-Git-Tag: v3.5.41~1 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=4e467d7ae2cd7d0af031fac8865dcff73cb1cf16;p=thirdparty%2Fvuejs%2Fcore.git fix(ssr): normalize hidden states during hydration (#13125) --- diff --git a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts index f4f29f6cdc..e507372473 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.each(['false', '0'])('should remove `hidden` with value `%s`', value => { + 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 ba05499a91..68e27b0ec8 100644 --- a/packages/compiler-dom/src/transforms/stringifyStatic.ts +++ b/packages/compiler-dom/src/transforms/stringifyStatic.ts @@ -22,6 +22,7 @@ import { } from '@vue/compiler-core' import { escapeHtml, + includeBooleanAttr, isArray, isBooleanAttr, isKnownHtmlAttr, @@ -347,7 +348,8 @@ function stringifyElement( } // #6568 if ( - isBooleanAttr((p.arg as SimpleExpressionNode).content) && + (isBooleanAttr((p.arg as SimpleExpressionNode).content) || + (p.arg as SimpleExpressionNode).content === 'hidden') && exp.content === 'false' ) { continue @@ -356,6 +358,13 @@ function stringifyElement( let evaluated = evaluateConstant(exp) if (evaluated != null) { const arg = p.arg && (p.arg as SimpleExpressionNode).content + if ( + arg === 'hidden' && + typeof evaluated === 'number' && + !includeBooleanAttr(evaluated) + ) { + continue + } if (arg === 'class') { evaluated = normalizeClass(evaluated) } else if (arg === 'style') { diff --git a/packages/compiler-ssr/__tests__/ssrElement.spec.ts b/packages/compiler-ssr/__tests__/ssrElement.spec.ts index f1d509acfb..504093f353 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 (hidden)', () => { + 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 3f001e3847..409f8bc716 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformElement.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformElement.ts @@ -300,6 +300,13 @@ export const ssrTransformElement: NodeTransform = (node, context) => { false /* no newline */, ), ) + } else if (attrName === 'hidden') { + 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 49c39bdd6c..bae8134202 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -2487,6 +2487,60 @@ describe('SSR hydration', () => { expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() }) + test('hidden enumerated 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: 'hidden' }), + ) + 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: 'until-found' }), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + + test('hidden numeric values', () => { + mountWithHydration(`
`, () => h('div', { hidden: 0 })) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + mountWithHydration(`
`, () => h('div', { hidden: NaN })) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + mountWithHydration(``, () => h('div', { hidden: 1 })) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + + mountWithHydration(``, () => + h('div', { hidden: '0' }), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + + test('hidden state mismatch', () => { + mountWithHydration(``, () => + h('div', { hidden: true }), + ) + expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(1) + + mountWithHydration(``, () => h('div', { hidden: 0 })) + expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2) + }) + 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 aaf41064f0..2018817eb4 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -882,7 +882,10 @@ function propHasMismatch( (el instanceof SVGElement && isKnownSvgAttr(key)) || (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key))) ) { - if (isBooleanAttr(key)) { + if (key === 'hidden') { + actual = normalizeHiddenValue(el.getAttribute(key)) + expected = normalizeHiddenValue(clientValue) + } else if (isBooleanAttr(key)) { actual = el.hasAttribute(key) expected = includeBooleanAttr(clientValue) } else if (clientValue == null) { @@ -929,6 +932,18 @@ function propHasMismatch( return false } +function normalizeHiddenValue(value: unknown): false | '' | 'until-found' { + if (!isRenderableAttrValue(value)) { + return false + } + if (isString(value)) { + // Attribute values from the DOM are strings, while numeric client values + // follow the `hidden` property setter, where 0 and NaN remove the attribute. + return value.toLowerCase() === 'until-found' ? 'until-found' : '' + } + return includeBooleanAttr(value) ? '' : false +} + function toClassSet(str: string): Set { return new Set(str.trim().split(/\s+/)) } diff --git a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts index 984387bb86..979e3a4b3b 100644 --- a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts +++ b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts @@ -55,6 +55,19 @@ describe('ssr: renderAttrs', () => { ).toBe(` checked disabled`) // boolean attr w/ false should be ignored }) + test('hidden enumerated 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`) + expect(ssrRenderAttrs({ hidden: 0 })).toBe(``) + expect(ssrRenderAttrs({ hidden: NaN })).toBe(``) + expect(ssrRenderAttrs({ hidden: 1 })).toBe(` hidden`) + expect(ssrRenderAttrs({ hidden: '0' })).toBe(` hidden="0"`) + }) + 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 73dcf8344f..743a5e6566 100644 --- a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts +++ b/packages/server-renderer/src/helpers/ssrRenderAttrs.ts @@ -72,7 +72,11 @@ 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) || + (attrKey === 'hidden' && + (typeof value === 'boolean' || typeof value === 'number')) + ) { 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 b5f0166327..797cc1126c 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`, )