]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(ssr): normalize hidden states during hydration (#13125)
authorchirokas <157580465+chirokas@users.noreply.github.com>
Wed, 5 Aug 2026 06:50:07 +0000 (14:50 +0800)
committerGitHub <noreply@github.com>
Wed, 5 Aug 2026 06:50:07 +0000 (14:50 +0800)
packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts
packages/compiler-dom/src/transforms/stringifyStatic.ts
packages/compiler-ssr/__tests__/ssrElement.spec.ts
packages/compiler-ssr/src/transforms/ssrTransformElement.ts
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/hydration.ts
packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts
packages/server-renderer/src/helpers/ssrRenderAttrs.ts
packages/shared/src/domAttrConfig.ts

index f4f29f6cdc17f98c0c1ad8aa871b1016610267aa..e5073724736e98cdf08850a0742b9b06cf5027a9 100644 (file)
@@ -389,6 +389,23 @@ describe('stringify static html', () => {
     ])
   })
 
+  test.each(['false', '0'])('should remove `hidden` with value `%s`', value => {
+    const { ast } = compileWithStringify(
+      `<div>
+      ${repeat(
+        `<span :hidden="${value}"></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', () => {
     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>`
index ba05499a914b29c2e20ccc32afdf93a2f2d32ce4..68e27b0ec884007d5f1f7cd6d89c4921d91ac506 100644 (file)
@@ -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') {
index f1d509acfb011a8b48acb2fd1191cca0a7c8cda1..504093f35340ee7ff950f0907e07fb2d8202eea2 100644 (file)
@@ -191,6 +191,20 @@ describe('ssr: element', () => {
         `)
     })
 
+    test('v-bind:arg (hidden)', () => {
+      expect(
+        getCompiledString(
+          `<div><span :hidden="false"></span><span :hidden="'until-found'"></span></div>`,
+        ),
+      ).toMatchInlineSnapshot(`
+        "\`<div><span\${
+            _ssrRenderDynamicAttr("hidden", false)
+          }></span><span\${
+            _ssrRenderDynamicAttr("hidden", 'until-found')
+          }></span></div>\`"
+      `)
+    })
+
     test('v-bind:[arg]', () => {
       expect(getCompiledString(`<div v-bind:[key]="value"></div>`))
         .toMatchInlineSnapshot(`
index 3f001e3847e55a1af3f1e4d812567ecf1be91ee2..409f8bc716dfcf4065783b1cc5cbb0ecb0248755 100644 (file)
@@ -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), [
index 49c39bdd6c7328cb3de9070cc4d87947da13d7e7..bae8134202aeb74b0764e90cf4daa7ddf4b232c8 100644 (file)
@@ -2487,6 +2487,60 @@ describe('SSR hydration', () => {
       expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
     })
 
+    test('hidden enumerated 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></div>`, () =>
+        h('div', { hidden: 'hidden' }),
+      )
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+      mountWithHydration(`<div hidden="anything"></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="UNTIL-FOUND"></div>`, () =>
+        h('div', { hidden: 'until-found' }),
+      )
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('hidden numeric values', () => {
+      mountWithHydration(`<div></div>`, () => h('div', { hidden: 0 }))
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+      mountWithHydration(`<div></div>`, () => h('div', { hidden: NaN }))
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+      mountWithHydration(`<div hidden></div>`, () => h('div', { hidden: 1 }))
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+      mountWithHydration(`<div hidden="0"></div>`, () =>
+        h('div', { hidden: '0' }),
+      )
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('hidden state mismatch', () => {
+      mountWithHydration(`<div hidden="until-found"></div>`, () =>
+        h('div', { hidden: true }),
+      )
+      expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(1)
+
+      mountWithHydration(`<div hidden></div>`, () => h('div', { hidden: 0 }))
+      expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
+    })
+
     test('client value is null or undefined', () => {
       mountWithHydration(`<div></div>`, () =>
         h('div', { draggable: undefined }),
index aaf41064f085d3bd2c38092f61e9fbd3aa38c893..2018817eb49a9b14ae54e7ed90a7a87dcfc66a05 100644 (file)
@@ -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<string> {
   return new Set(str.trim().split(/\s+/))
 }
index 984387bb864a02f45031490c39169a769d91989b..979e3a4b3bdee77e6f379cda8f7ae08fafe906ef 100644 (file)
@@ -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({
index 73dcf8344f9b5545eee4605469e459bb0d393d72..743a5e65662d4661f5b16d715aeb3eaac2dd1963 100644 (file)
@@ -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)}"`
index b5f0166327fb3133e765fd47ff40a475ab3824de..797cc1126c025c1bd581cbcb753de9afb41efdfe 100644 (file)
@@ -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`,
 )