diff --git a/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap b/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap
index 7fcc1d39506..a0866c86d4e 100644
--- a/packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap
+++ b/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(\`
', () => {
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)
})
diff --git a/packages/compiler-sfc/src/style/cssVars.ts b/packages/compiler-sfc/src/style/cssVars.ts
index 0397c7d790a..c6d1633cf60 100644
--- a/packages/compiler-sfc/src/style/cssVars.ts
+++ b/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}`
}
diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts
index 3ed42ed0b55..ebf50e3b5d4 100644
--- a/packages/runtime-core/src/component.ts
+++ b/packages/runtime-core/src/component.ts
@@ -582,13 +582,13 @@ export interface ComponentInternalInstance {
* For updating css vars on contained teleports
* @internal
*/
- ut?: (vars?: Record) => void
+ ut?: (vars?: Record) => void
/**
* dev only. For style v-bind hydration mismatch checks
* @internal
*/
- getCssVars?: () => Record
+ getCssVars?: () => Record
/**
* v2 compat only, for caching mutated $options
diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts
index a94ff356810..f7afaf28260 100644
--- a/packages/runtime-core/src/hydration.ts
+++ b/packages/runtime-core/src/hydration.ts
@@ -28,6 +28,7 @@ import {
isReservedProp,
isString,
normalizeClass,
+ normalizeCssVarValue,
normalizeStyle,
stringifyStyle,
} from '@vue/shared'
@@ -938,10 +939,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) {
diff --git a/packages/runtime-dom/__tests__/helpers/useCssVars.spec.ts b/packages/runtime-dom/__tests__/helpers/useCssVars.spec.ts
index 1fb4cc65fd0..e2102e0c7b8 100644
--- a/packages/runtime-dom/__tests__/helpers/useCssVars.spec.ts
+++ b/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>({
+ 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')
+ })
})
diff --git a/packages/runtime-dom/src/helpers/useCssVars.ts b/packages/runtime-dom/src/helpers/useCssVars.ts
index e2bc6de9278..3032143d9a7 100644
--- a/packages/runtime-dom/src/helpers/useCssVars.ts
+++ b/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): void {
+export function useCssVars(
+ getter: (ctx: any) => Record,
+): void {
if (!__BROWSER__ && !__TEST__) return
const instance = getCurrentInstance()
@@ -64,7 +66,7 @@ export function useCssVars(getter: (ctx: any) => Record): void {
})
}
-function setVarsOnVNode(vnode: VNode, vars: Record) {
+function setVarsOnVNode(vnode: VNode, vars: Record) {
if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
const suspense = vnode.suspense!
vnode = suspense.activeBranch!
@@ -94,13 +96,14 @@ function setVarsOnVNode(vnode: VNode, vars: Record) {
}
}
-function setVarsOnNode(el: Node, vars: Record) {
+function setVarsOnNode(el: Node, vars: Record) {
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
}
diff --git a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts
index 9f33866e5a8..984387bb864 100644
--- a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts
+++ b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts
@@ -203,4 +203,19 @@ describe('ssr: renderStyle', () => {
}),
).toBe(`color:"><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;`)
+ })
})
diff --git a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts b/packages/server-renderer/src/helpers/ssrRenderAttrs.ts
index 9689b4185c6..b082da03fe8 100644
--- a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts
+++ b/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 = {}
+ 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
index 00000000000..747ab067d25
--- /dev/null
+++ b/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)
+ })
+})
diff --git a/packages/shared/src/cssVars.ts b/packages/shared/src/cssVars.ts
new file mode 100644
index 00000000000..0c69b606f5d
--- /dev/null
+++ b/packages/shared/src/cssVars.ts
@@ -0,0 +1,24 @@
+/**
+ * Normalize CSS var value created by `v-bind` in `