Skip to content

Commit c85f1b5

Browse files
authored
fix(css-vars): nullish v-bind in style should not lead to unexpected inheritance (#12461)
close #12434 close #12439 close #7474 close #7475
1 parent 7e133db commit c85f1b5

File tree

12 files changed

+136
-20
lines changed

12 files changed

+136
-20
lines changed

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -884,9 +884,9 @@ export default {
884884
885885
return (_ctx, _push, _parent, _attrs) => {
886886
const _cssVars = { style: {
887-
"--xxxxxxxx-count": (count.value),
888-
"--xxxxxxxx-style\\\\.color": (style.color),
889-
"--xxxxxxxx-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")
887+
":--xxxxxxxx-count": (count.value),
888+
":--xxxxxxxx-style\\\\.color": (style.color),
889+
":--xxxxxxxx-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")
890890
}}
891891
_push(\`<!--[--><div\${
892892
_ssrRenderAttrs(_cssVars)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -652,10 +652,10 @@ describe('SFC compile <script setup>', () => {
652652
expect(content).toMatch(`return (_ctx, _push`)
653653
expect(content).toMatch(`ssrInterpolate`)
654654
expect(content).not.toMatch(`useCssVars`)
655-
expect(content).toMatch(`"--${mockId}-count": (count.value)`)
656-
expect(content).toMatch(`"--${mockId}-style\\\\.color": (style.color)`)
655+
expect(content).toMatch(`":--${mockId}-count": (count.value)`)
656+
expect(content).toMatch(`":--${mockId}-style\\\\.color": (style.color)`)
657657
expect(content).toMatch(
658-
`"--${mockId}-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")`,
658+
`":--${mockId}-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")`,
659659
)
660660
assertCode(content)
661661
})

packages/compiler-sfc/src/style/cssVars.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ export function genCssVarsFromList(
2323
return `{\n ${vars
2424
.map(
2525
key =>
26-
`"${isSSR ? `--` : ``}${genVarName(id, key, isProd, isSSR)}": (${key})`,
26+
// The `:` prefix here is used in `ssrRenderStyle` to distinguish whether
27+
// a custom property comes from `ssrCssVars`. If it does, we need to reset
28+
// its value to `initial` on the component instance to avoid unintentionally
29+
// inheriting the same property value from a different instance of the same
30+
// component in the outer scope.
31+
`"${isSSR ? `:--` : ``}${genVarName(id, key, isProd, isSSR)}": (${key})`,
2732
)
2833
.join(',\n ')}\n}`
2934
}

packages/runtime-core/src/component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,13 +585,13 @@ export interface ComponentInternalInstance {
585585
* For updating css vars on contained teleports
586586
* @internal
587587
*/
588-
ut?: (vars?: Record<string, string>) => void
588+
ut?: (vars?: Record<string, unknown>) => void
589589

590590
/**
591591
* dev only. For style v-bind hydration mismatch checks
592592
* @internal
593593
*/
594-
getCssVars?: () => Record<string, string>
594+
getCssVars?: () => Record<string, unknown>
595595

596596
/**
597597
* v2 compat only, for caching mutated $options

packages/runtime-core/src/hydration.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
isReservedProp,
2929
isString,
3030
normalizeClass,
31+
normalizeCssVarValue,
3132
normalizeStyle,
3233
stringifyStyle,
3334
} from '@vue/shared'
@@ -945,10 +946,8 @@ function resolveCssVars(
945946
) {
946947
const cssVars = instance.getCssVars()
947948
for (const key in cssVars) {
948-
expectedMap.set(
949-
`--${getEscapedCssVarName(key, false)}`,
950-
String(cssVars[key]),
951-
)
949+
const value = normalizeCssVarValue(cssVars[key])
950+
expectedMap.set(`--${getEscapedCssVarName(key, false)}`, value)
952951
}
953952
}
954953
if (vnode === root && instance.parent) {

packages/runtime-dom/__tests__/helpers/useCssVars.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,4 +465,27 @@ describe('useCssVars', () => {
465465
render(h(App), root)
466466
expect(colorInOnMount).toBe(`red`)
467467
})
468+
469+
test('should set vars as `initial` for nullish values', async () => {
470+
// `getPropertyValue` cannot reflect the real value for white spaces and JSDOM also
471+
// doesn't 100% reflect the real behavior of browsers, so we only keep the test for
472+
// `initial` value here.
473+
// The value normalization is tested in packages/shared/__tests__/cssVars.spec.ts.
474+
const state = reactive<Record<string, unknown>>({
475+
foo: undefined,
476+
bar: null,
477+
})
478+
const root = document.createElement('div')
479+
const App = {
480+
setup() {
481+
useCssVars(() => state)
482+
return () => h('div')
483+
},
484+
}
485+
render(h(App), root)
486+
await nextTick()
487+
const style = (root.children[0] as HTMLElement).style
488+
expect(style.getPropertyValue('--foo')).toBe('initial')
489+
expect(style.getPropertyValue('--bar')).toBe('initial')
490+
})
468491
})

packages/runtime-dom/src/helpers/useCssVars.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ import {
1010
warn,
1111
watch,
1212
} from '@vue/runtime-core'
13-
import { NOOP, ShapeFlags } from '@vue/shared'
13+
import { NOOP, ShapeFlags, normalizeCssVarValue } from '@vue/shared'
1414

1515
export const CSS_VAR_TEXT: unique symbol = Symbol(__DEV__ ? 'CSS_VAR_TEXT' : '')
1616
/**
1717
* Runtime helper for SFC's CSS variable injection feature.
1818
* @private
1919
*/
20-
export function useCssVars(getter: (ctx: any) => Record<string, string>): void {
20+
export function useCssVars(
21+
getter: (ctx: any) => Record<string, unknown>,
22+
): void {
2123
if (!__BROWSER__ && !__TEST__) return
2224

2325
const instance = getCurrentInstance()
@@ -64,7 +66,7 @@ export function useCssVars(getter: (ctx: any) => Record<string, string>): void {
6466
})
6567
}
6668

67-
function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) {
69+
function setVarsOnVNode(vnode: VNode, vars: Record<string, unknown>) {
6870
if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
6971
const suspense = vnode.suspense!
7072
vnode = suspense.activeBranch!
@@ -94,13 +96,14 @@ function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) {
9496
}
9597
}
9698

97-
function setVarsOnNode(el: Node, vars: Record<string, string>) {
99+
function setVarsOnNode(el: Node, vars: Record<string, unknown>) {
98100
if (el.nodeType === 1) {
99101
const style = (el as HTMLElement).style
100102
let cssText = ''
101103
for (const key in vars) {
102-
style.setProperty(`--${key}`, vars[key])
103-
cssText += `--${key}: ${vars[key]};`
104+
const value = normalizeCssVarValue(vars[key])
105+
style.setProperty(`--${key}`, value)
106+
cssText += `--${key}: ${value};`
104107
}
105108
;(style as any)[CSS_VAR_TEXT] = cssText
106109
}

packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,19 @@ describe('ssr: renderStyle', () => {
203203
}),
204204
).toBe(`color:&quot;&gt;&lt;script;`)
205205
})
206+
207+
test('useCssVars handling', () => {
208+
expect(
209+
ssrRenderStyle({
210+
fontSize: null,
211+
':--v1': undefined,
212+
':--v2': null,
213+
':--v3': '',
214+
':--v4': ' ',
215+
':--v5': 'foo',
216+
':--v6': 0,
217+
'--foo': 1,
218+
}),
219+
).toBe(`--v1:initial;--v2:initial;--v3: ;--v4: ;--v5:foo;--v6:0;--foo:1;`)
220+
})
206221
})

packages/server-renderer/src/helpers/ssrRenderAttrs.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
22
escapeHtml,
3+
isArray,
4+
isObject,
35
isRenderableAttrValue,
46
isSVGTag,
57
stringifyStyle,
@@ -12,6 +14,7 @@ import {
1214
isString,
1315
makeMap,
1416
normalizeClass,
17+
normalizeCssVarValue,
1518
normalizeStyle,
1619
propsToAttrMap,
1720
} from '@vue/shared'
@@ -93,6 +96,22 @@ export function ssrRenderStyle(raw: unknown): string {
9396
if (isString(raw)) {
9497
return escapeHtml(raw)
9598
}
96-
const styles = normalizeStyle(raw)
99+
const styles = normalizeStyle(ssrResetCssVars(raw))
97100
return escapeHtml(stringifyStyle(styles))
98101
}
102+
103+
function ssrResetCssVars(raw: unknown) {
104+
if (!isArray(raw) && isObject(raw)) {
105+
const res: Record<string, unknown> = {}
106+
for (const key in raw) {
107+
// `:` prefixed keys are coming from `ssrCssVars`
108+
if (key.startsWith(':--')) {
109+
res[key.slice(1)] = normalizeCssVarValue(raw[key])
110+
} else {
111+
res[key] = raw[key]
112+
}
113+
}
114+
return res
115+
}
116+
return raw
117+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { normalizeCssVarValue } from '../src'
2+
3+
describe('utils/cssVars', () => {
4+
test('should normalize css binding values correctly', () => {
5+
expect(normalizeCssVarValue(null)).toBe('initial')
6+
expect(normalizeCssVarValue(undefined)).toBe('initial')
7+
expect(normalizeCssVarValue('')).toBe(' ')
8+
expect(normalizeCssVarValue(' ')).toBe(' ')
9+
expect(normalizeCssVarValue('foo')).toBe('foo')
10+
expect(normalizeCssVarValue(0)).toBe('0')
11+
})
12+
13+
test('should warn on invalid css binding values', () => {
14+
const warning =
15+
'[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:'
16+
expect(normalizeCssVarValue(NaN)).toBe('NaN')
17+
expect(warning).toHaveBeenWarnedTimes(1)
18+
expect(normalizeCssVarValue(Infinity)).toBe('Infinity')
19+
expect(warning).toHaveBeenWarnedTimes(2)
20+
expect(normalizeCssVarValue(-Infinity)).toBe('-Infinity')
21+
expect(warning).toHaveBeenWarnedTimes(3)
22+
expect(normalizeCssVarValue({})).toBe('[object Object]')
23+
expect(warning).toHaveBeenWarnedTimes(4)
24+
expect(normalizeCssVarValue([])).toBe('')
25+
expect(warning).toHaveBeenWarnedTimes(5)
26+
})
27+
})

packages/shared/src/cssVars.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Normalize CSS var value created by `v-bind` in `<style>` block
3+
* See https://github.com/vuejs/core/pull/12461#issuecomment-2495804664
4+
*/
5+
export function normalizeCssVarValue(value: unknown): string {
6+
if (value == null) {
7+
return 'initial'
8+
}
9+
10+
if (typeof value === 'string') {
11+
return value === '' ? ' ' : value
12+
}
13+
14+
if (typeof value !== 'number' || !Number.isFinite(value)) {
15+
if (__DEV__) {
16+
console.warn(
17+
'[Vue warn] Invalid value used for CSS binding. Expected a string or a finite number but received:',
18+
value,
19+
)
20+
}
21+
}
22+
23+
return String(value)
24+
}

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export * from './escapeHtml'
1212
export * from './looseEqual'
1313
export * from './toDisplayString'
1414
export * from './typeUtils'
15+
export * from './cssVars'

0 commit comments

Comments
 (0)