Преглед изворни кода

fix(css-vars): nullish v-bind in style should not lead to unexpected inheritance (#12461)

close #12434
close #12439
close #7474
close #7475
GU Yiling пре 9 месеци
родитељ
комит
c85f1b5a13

+ 3 - 3
packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap

@@ -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)

+ 3 - 3
packages/compiler-sfc/__tests__/compileScript.spec.ts

@@ -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)
     })

+ 6 - 1
packages/compiler-sfc/src/style/cssVars.ts

@@ -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}`
 }

+ 2 - 2
packages/runtime-core/src/component.ts

@@ -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

+ 3 - 4
packages/runtime-core/src/hydration.ts

@@ -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) {

+ 23 - 0
packages/runtime-dom/__tests__/helpers/useCssVars.spec.ts

@@ -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')
+  })
 })

+ 9 - 6
packages/runtime-dom/src/helpers/useCssVars.ts

@@ -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
   }

+ 15 - 0
packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts

@@ -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;`)
+  })
 })

+ 20 - 1
packages/server-renderer/src/helpers/ssrRenderAttrs.ts

@@ -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
+}

+ 27 - 0
packages/shared/__tests__/cssVars.spec.ts

@@ -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)
+  })
+})

+ 24 - 0
packages/shared/src/cssVars.ts

@@ -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)
+}

+ 1 - 0
packages/shared/src/index.ts

@@ -12,3 +12,4 @@ export * from './escapeHtml'
 export * from './looseEqual'
 export * from './toDisplayString'
 export * from './typeUtils'
+export * from './cssVars'