]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(css-vars): nullish v-bind in style should not lead to unexpected inheritance...
authorGU Yiling <justice360@gmail.com>
Thu, 3 Jul 2025 08:20:28 +0000 (16:20 +0800)
committerGitHub <noreply@github.com>
Thu, 3 Jul 2025 08:20:28 +0000 (16:20 +0800)
close #12434
close #12439
close #7474
close #7475

12 files changed:
packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap
packages/compiler-sfc/__tests__/compileScript.spec.ts
packages/compiler-sfc/src/style/cssVars.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/hydration.ts
packages/runtime-dom/__tests__/helpers/useCssVars.spec.ts
packages/runtime-dom/src/helpers/useCssVars.ts
packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts
packages/server-renderer/src/helpers/ssrRenderAttrs.ts
packages/shared/__tests__/cssVars.spec.ts [new file with mode: 0644]
packages/shared/src/cssVars.ts [new file with mode: 0644]
packages/shared/src/index.ts

index d9511e829df4e3ea43cbf882462c7dcfdec26ae9..2acac64b0fbce9c2e195936e31285e0a90dcd864 100644 (file)
@@ -884,9 +884,9 @@ export default {
         
 return (_ctx, _push, _parent, _attrs) => {
   const _cssVars = { style: {
-  "--xxxxxxxx-count": (count.value),
-  "--xxxxxxxx-style\\\\.color": (style.color),
-  "--xxxxxxxx-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")
+  ":--xxxxxxxx-count": (count.value),
+  ":--xxxxxxxx-style\\\\.color": (style.color),
+  ":--xxxxxxxx-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")
 }}
   _push(\`<!--[--><div\${
     _ssrRenderAttrs(_cssVars)
index 73c6d316a40a77e2cbc95008834fdc96a2fb69a4..134beff9668bc63a8a3b9f40839de56a049738e2 100644 (file)
@@ -652,10 +652,10 @@ describe('SFC compile <script setup>', () => {
       expect(content).toMatch(`return (_ctx, _push`)
       expect(content).toMatch(`ssrInterpolate`)
       expect(content).not.toMatch(`useCssVars`)
-      expect(content).toMatch(`"--${mockId}-count": (count.value)`)
-      expect(content).toMatch(`"--${mockId}-style\\\\.color": (style.color)`)
+      expect(content).toMatch(`":--${mockId}-count": (count.value)`)
+      expect(content).toMatch(`":--${mockId}-style\\\\.color": (style.color)`)
       expect(content).toMatch(
-        `"--${mockId}-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")`,
+        `":--${mockId}-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")`,
       )
       assertCode(content)
     })
index 0397c7d790a4bb40098693ce6061d0e70de8f8a0..c6d1633cf60caacb4392a335646b522eeefb333a 100644 (file)
@@ -23,7 +23,12 @@ export function genCssVarsFromList(
   return `{\n  ${vars
     .map(
       key =>
-        `"${isSSR ? `--` : ``}${genVarName(id, key, isProd, isSSR)}": (${key})`,
+        // The `:` prefix here is used in `ssrRenderStyle` to distinguish whether
+        // a custom property comes from `ssrCssVars`. If it does, we need to reset
+        // its value to `initial` on the component instance to avoid unintentionally
+        // inheriting the same property value from a different instance of the same
+        // component in the outer scope.
+        `"${isSSR ? `:--` : ``}${genVarName(id, key, isProd, isSSR)}": (${key})`,
     )
     .join(',\n  ')}\n}`
 }
index f191c36df1242e8cc505471ed125f73f4172d94c..60552d736d50aa9537c1cc250d09a95671e2cb3f 100644 (file)
@@ -585,13 +585,13 @@ export interface ComponentInternalInstance {
    * For updating css vars on contained teleports
    * @internal
    */
-  ut?: (vars?: Record<string, string>) => void
+  ut?: (vars?: Record<string, unknown>) => void
 
   /**
    * dev only. For style v-bind hydration mismatch checks
    * @internal
    */
-  getCssVars?: () => Record<string, string>
+  getCssVars?: () => Record<string, unknown>
 
   /**
    * v2 compat only, for caching mutated $options
index 12813b598b54d1f5169a44fbfcc363792864050d..bdebed5960284d8bd708c9971eeff2bffe0d1410 100644 (file)
@@ -28,6 +28,7 @@ import {
   isReservedProp,
   isString,
   normalizeClass,
+  normalizeCssVarValue,
   normalizeStyle,
   stringifyStyle,
 } from '@vue/shared'
@@ -945,10 +946,8 @@ function resolveCssVars(
   ) {
     const cssVars = instance.getCssVars()
     for (const key in cssVars) {
-      expectedMap.set(
-        `--${getEscapedCssVarName(key, false)}`,
-        String(cssVars[key]),
-      )
+      const value = normalizeCssVarValue(cssVars[key])
+      expectedMap.set(`--${getEscapedCssVarName(key, false)}`, value)
     }
   }
   if (vnode === root && instance.parent) {
index 1fb4cc65fd0cafa8a2577c04a084179592ac00ab..e2102e0c7b82f1a72974940d7ca55f9db43d114f 100644 (file)
@@ -465,4 +465,27 @@ describe('useCssVars', () => {
     render(h(App), root)
     expect(colorInOnMount).toBe(`red`)
   })
+
+  test('should set vars as `initial` for nullish values', async () => {
+    // `getPropertyValue` cannot reflect the real value for white spaces and JSDOM also
+    // doesn't 100% reflect the real behavior of browsers, so we only keep the test for
+    // `initial` value here.
+    // The value normalization is tested in packages/shared/__tests__/cssVars.spec.ts.
+    const state = reactive<Record<string, unknown>>({
+      foo: undefined,
+      bar: null,
+    })
+    const root = document.createElement('div')
+    const App = {
+      setup() {
+        useCssVars(() => state)
+        return () => h('div')
+      },
+    }
+    render(h(App), root)
+    await nextTick()
+    const style = (root.children[0] as HTMLElement).style
+    expect(style.getPropertyValue('--foo')).toBe('initial')
+    expect(style.getPropertyValue('--bar')).toBe('initial')
+  })
 })
index e2bc6de92781cd046ef087e9ced48d3633be2bbc..3032143d9a7e2e0c4231e2b643787f38309f1e07 100644 (file)
@@ -10,14 +10,16 @@ import {
   warn,
   watch,
 } from '@vue/runtime-core'
-import { NOOP, ShapeFlags } from '@vue/shared'
+import { NOOP, ShapeFlags, normalizeCssVarValue } from '@vue/shared'
 
 export const CSS_VAR_TEXT: unique symbol = Symbol(__DEV__ ? 'CSS_VAR_TEXT' : '')
 /**
  * Runtime helper for SFC's CSS variable injection feature.
  * @private
  */
-export function useCssVars(getter: (ctx: any) => Record<string, string>): void {
+export function useCssVars(
+  getter: (ctx: any) => Record<string, unknown>,
+): void {
   if (!__BROWSER__ && !__TEST__) return
 
   const instance = getCurrentInstance()
@@ -64,7 +66,7 @@ export function useCssVars(getter: (ctx: any) => Record<string, string>): void {
   })
 }
 
-function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) {
+function setVarsOnVNode(vnode: VNode, vars: Record<string, unknown>) {
   if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
     const suspense = vnode.suspense!
     vnode = suspense.activeBranch!
@@ -94,13 +96,14 @@ function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) {
   }
 }
 
-function setVarsOnNode(el: Node, vars: Record<string, string>) {
+function setVarsOnNode(el: Node, vars: Record<string, unknown>) {
   if (el.nodeType === 1) {
     const style = (el as HTMLElement).style
     let cssText = ''
     for (const key in vars) {
-      style.setProperty(`--${key}`, vars[key])
-      cssText += `--${key}: ${vars[key]};`
+      const value = normalizeCssVarValue(vars[key])
+      style.setProperty(`--${key}`, value)
+      cssText += `--${key}: ${value};`
     }
     ;(style as any)[CSS_VAR_TEXT] = cssText
   }
index 9f33866e5a87e4b87af29deb711bcb858a0b65c6..984387bb864a02f45031490c39169a769d91989b 100644 (file)
@@ -203,4 +203,19 @@ describe('ssr: renderStyle', () => {
       }),
     ).toBe(`color:&quot;&gt;&lt;script;`)
   })
+
+  test('useCssVars handling', () => {
+    expect(
+      ssrRenderStyle({
+        fontSize: null,
+        ':--v1': undefined,
+        ':--v2': null,
+        ':--v3': '',
+        ':--v4': '  ',
+        ':--v5': 'foo',
+        ':--v6': 0,
+        '--foo': 1,
+      }),
+    ).toBe(`--v1:initial;--v2:initial;--v3: ;--v4:  ;--v5:foo;--v6:0;--foo:1;`)
+  })
 })
index 9689b4185c6b3cee76218fffdc3ecde12ec5299c..b082da03fe8053b46c558e496b9afebf79c84238 100644 (file)
@@ -1,5 +1,7 @@
 import {
   escapeHtml,
+  isArray,
+  isObject,
   isRenderableAttrValue,
   isSVGTag,
   stringifyStyle,
@@ -12,6 +14,7 @@ import {
   isString,
   makeMap,
   normalizeClass,
+  normalizeCssVarValue,
   normalizeStyle,
   propsToAttrMap,
 } from '@vue/shared'
@@ -93,6 +96,22 @@ export function ssrRenderStyle(raw: unknown): string {
   if (isString(raw)) {
     return escapeHtml(raw)
   }
-  const styles = normalizeStyle(raw)
+  const styles = normalizeStyle(ssrResetCssVars(raw))
   return escapeHtml(stringifyStyle(styles))
 }
+
+function ssrResetCssVars(raw: unknown) {
+  if (!isArray(raw) && isObject(raw)) {
+    const res: Record<string, unknown> = {}
+    for (const key in raw) {
+      // `:` prefixed keys are coming from `ssrCssVars`
+      if (key.startsWith(':--')) {
+        res[key.slice(1)] = normalizeCssVarValue(raw[key])
+      } else {
+        res[key] = raw[key]
+      }
+    }
+    return res
+  }
+  return raw
+}
diff --git a/packages/shared/__tests__/cssVars.spec.ts b/packages/shared/__tests__/cssVars.spec.ts
new file mode 100644 (file)
index 0000000..747ab06
--- /dev/null
@@ -0,0 +1,27 @@
+import { normalizeCssVarValue } from '../src'
+
+describe('utils/cssVars', () => {
+  test('should normalize css binding values correctly', () => {
+    expect(normalizeCssVarValue(null)).toBe('initial')
+    expect(normalizeCssVarValue(undefined)).toBe('initial')
+    expect(normalizeCssVarValue('')).toBe(' ')
+    expect(normalizeCssVarValue('  ')).toBe('  ')
+    expect(normalizeCssVarValue('foo')).toBe('foo')
+    expect(normalizeCssVarValue(0)).toBe('0')
+  })
+
+  test('should warn on invalid css binding values', () => {
+    const warning =
+      '[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:'
+    expect(normalizeCssVarValue(NaN)).toBe('NaN')
+    expect(warning).toHaveBeenWarnedTimes(1)
+    expect(normalizeCssVarValue(Infinity)).toBe('Infinity')
+    expect(warning).toHaveBeenWarnedTimes(2)
+    expect(normalizeCssVarValue(-Infinity)).toBe('-Infinity')
+    expect(warning).toHaveBeenWarnedTimes(3)
+    expect(normalizeCssVarValue({})).toBe('[object Object]')
+    expect(warning).toHaveBeenWarnedTimes(4)
+    expect(normalizeCssVarValue([])).toBe('')
+    expect(warning).toHaveBeenWarnedTimes(5)
+  })
+})
diff --git a/packages/shared/src/cssVars.ts b/packages/shared/src/cssVars.ts
new file mode 100644 (file)
index 0000000..0c69b60
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Normalize CSS var value created by `v-bind` in `<style>` block
+ * See https://github.com/vuejs/core/pull/12461#issuecomment-2495804664
+ */
+export function normalizeCssVarValue(value: unknown): string {
+  if (value == null) {
+    return 'initial'
+  }
+
+  if (typeof value === 'string') {
+    return value === '' ? ' ' : value
+  }
+
+  if (typeof value !== 'number' || !Number.isFinite(value)) {
+    if (__DEV__) {
+      console.warn(
+        '[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:',
+        value,
+      )
+    }
+  }
+
+  return String(value)
+}
index 11580a064352eb01a1fd9013914119d19515c74a..08d4b07af8e56e50a0670f51072d0c3854e2bde4 100644 (file)
@@ -12,3 +12,4 @@ export * from './escapeHtml'
 export * from './looseEqual'
 export * from './toDisplayString'
 export * from './typeUtils'
+export * from './cssVars'