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 `