Compare commits

..

15 Commits

Author SHA1 Message Date
山吹色御守 e60edc06f2
chore(test): report correct value of `__EXTEND_POINT__` when subsequent error codes is less than it (#13213)
ci / test (push) Has been cancelled Details
ci / continuous-release (push) Has been cancelled Details
size data / upload (push) Has been cancelled Details
2025-07-18 16:24:29 +08:00
山吹色御守 21b685ad9d
fix(compiler-core): avoid self updates of `v-pre` (#12556) 2025-07-18 16:22:56 +08:00
山吹色御守 ce933390ad
fix(compiler-core): recognize empty string as non-identifier (#12553) 2025-07-18 15:58:50 +08:00
山吹色御守 d3af67e878
fix(compiler-core): transform empty `v-bind` dynamic argument content correctly (#12554) 2025-07-18 15:56:01 +08:00
edison e0e8221d7f
chore(sfc-playground): import vaporInteropPlugin only if Vapor mode is supported (#13645)
ci / test (push) Has been cancelled Details
ci / continuous-release (push) Has been cancelled Details
size data / upload (push) Has been cancelled Details
2025-07-17 10:03:13 +08:00
山吹色御守 347ef1d3f5
chore(compiler-sfc): optimize the regular expression for matching `@keyframes` (#13566)
ci / test (push) Has been cancelled Details
ci / continuous-release (push) Has been cancelled Details
size data / upload (push) Has been cancelled Details
Lock Closed Issues / action (push) Has been cancelled Details
Auto close issues with "can't reproduce" label / close-issues (push) Has been cancelled Details
2025-07-09 10:31:20 +08:00
renovate[bot] f97c4d4e6e
chore(deps): update compiler to ^7.28.0 (#13575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: edison <daiwei521@126.com>
2025-07-09 09:38:17 +08:00
Wick a0bd1f518e
refactor: migrate to getCurrentInstance API (#12958)
ci / test (push) Waiting to run Details
ci / continuous-release (push) Waiting to run Details
size data / upload (push) Waiting to run Details
2025-07-08 14:30:43 +08:00
renovate[bot] 01a122283f
chore(deps): update build (#13574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-08 14:20:35 +08:00
renovate[bot] eca0e1ccff
chore(deps): update build (#13542)
ci / test (push) Has been cancelled Details
ci / continuous-release (push) Has been cancelled Details
size data / upload (push) Has been cancelled Details
Lock Closed Issues / action (push) Has been cancelled Details
Auto close issues with "can't reproduce" label / close-issues (push) Has been cancelled Details
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 11:47:51 +08:00
GU Yiling c85f1b5a13
fix(css-vars): nullish v-bind in style should not lead to unexpected inheritance (#12461)
ci / test (push) Waiting to run Details
ci / continuous-release (push) Waiting to run Details
size data / upload (push) Waiting to run Details
close #12434
close #12439
close #7474
close #7475
2025-07-03 16:20:28 +08:00
renovate[bot] 7e133dbe01
chore(deps): update all non-major dependencies (#13541)
ci / test (push) Waiting to run Details
ci / continuous-release (push) Waiting to run Details
size data / upload (push) Waiting to run Details
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 08:16:56 +08:00
renovate[bot] ba391f5fdf
chore(deps): update dependency vite to v5.4.19 [security] (#13517)
ci / test (push) Has been cancelled Details
ci / continuous-release (push) Has been cancelled Details
size data / upload (push) Has been cancelled Details
Lock Closed Issues / action (push) Has been cancelled Details
Auto close issues with "can't reproduce" label / close-issues (push) Has been cancelled Details
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 14:33:04 +08:00
renovate[bot] 50a1c30899
chore(deps): update build (#13516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 14:32:41 +08:00
renovate[bot] b8b926cdee
chore(deps): update all non-major dependencies (#13515)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-23 14:04:52 +08:00
25 changed files with 595 additions and 426 deletions

View File

@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"version": "3.5.17", "version": "3.5.17",
"packageManager": "pnpm@10.12.1", "packageManager": "pnpm@10.12.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node scripts/dev.js", "dev": "node scripts/dev.js",
@ -69,9 +69,9 @@
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-replace": "5.0.4", "@rollup/plugin-replace": "5.0.4",
"@swc/core": "^1.12.1", "@swc/core": "^1.12.11",
"@types/hash-sum": "^1.0.2", "@types/hash-sum": "^1.0.2",
"@types/node": "^22.15.31", "@types/node": "^22.16.0",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/serve-handler": "^6.1.4", "@types/serve-handler": "^6.1.4",
"@vitest/coverage-v8": "^3.1.4", "@vitest/coverage-v8": "^3.1.4",
@ -79,7 +79,7 @@
"@vue/consolidate": "1.0.0", "@vue/consolidate": "1.0.0",
"conventional-changelog-cli": "^5.0.0", "conventional-changelog-cli": "^5.0.0",
"enquirer": "^2.4.1", "enquirer": "^2.4.1",
"esbuild": "^0.25.5", "esbuild": "^0.25.6",
"esbuild-plugin-polyfill-node": "^0.3.0", "esbuild-plugin-polyfill-node": "^0.3.0",
"eslint": "^9.27.0", "eslint": "^9.27.0",
"eslint-plugin-import-x": "^4.13.1", "eslint-plugin-import-x": "^4.13.1",
@ -97,7 +97,7 @@
"pug": "^3.0.3", "pug": "^3.0.3",
"puppeteer": "~24.9.0", "puppeteer": "~24.9.0",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rollup": "^4.43.0", "rollup": "^4.44.2",
"rollup-plugin-dts": "^6.2.1", "rollup-plugin-dts": "^6.2.1",
"rollup-plugin-esbuild": "^6.2.1", "rollup-plugin-esbuild": "^6.2.1",
"rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-polyfill-node": "^0.13.0",

View File

@ -13,7 +13,7 @@
"vite": "catalog:" "vite": "catalog:"
}, },
"dependencies": { "dependencies": {
"@vue/repl": "^4.6.1", "@vue/repl": "^4.6.2",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"vue": "workspace:*" "vue": "workspace:*"

View File

@ -2,7 +2,7 @@
import Header from './Header.vue' import Header from './Header.vue'
import { Repl, useStore, SFCOptions, useVueImportMap } from '@vue/repl' import { Repl, useStore, SFCOptions, useVueImportMap } from '@vue/repl'
import Monaco from '@vue/repl/monaco-editor' import Monaco from '@vue/repl/monaco-editor'
import { ref, watchEffect, onMounted, computed } from 'vue' import { ref, watchEffect, onMounted, computed, watch } from 'vue'
const replRef = ref<InstanceType<typeof Repl>>() const replRef = ref<InstanceType<typeof Repl>>()
@ -115,6 +115,34 @@ onMounted(() => {
// @ts-expect-error process shim for old versions of @vue/compiler-sfc dependency // @ts-expect-error process shim for old versions of @vue/compiler-sfc dependency
window.process = { env: {} } window.process = { env: {} }
}) })
const isVaporSupported = ref(false)
watch(
() => store.vueVersion,
(version, oldVersion) => {
const [major, minor] = (version || store.compiler.version)
.split('.')
.map((v: string) => parseInt(v, 10))
isVaporSupported.value = major > 3 || (major === 3 && minor >= 6)
if (oldVersion) reloadPage()
},
{ immediate: true, flush: 'pre' },
)
const previewOptions = computed(() => ({
customCode: {
importCode: `import { initCustomFormatter${isVaporSupported.value ? ', vaporInteropPlugin' : ''} } from 'vue'`,
useCode: `
${isVaporSupported.value ? 'app.use(vaporInteropPlugin)' : ''}
if (window.devtoolsFormatters) {
const index = window.devtoolsFormatters.findIndex((v) => v.__vue_custom_formatter)
window.devtoolsFormatters.splice(index, 1)
initCustomFormatter()
} else {
initCustomFormatter()
}`,
},
}))
</script> </script>
<template> <template>
@ -145,18 +173,7 @@ onMounted(() => {
:showOpenSourceMap="true" :showOpenSourceMap="true"
:autoResize="true" :autoResize="true"
:clearConsole="false" :clearConsole="false"
:preview-options="{ :preview-options="previewOptions"
customCode: {
importCode: `import { initCustomFormatter } from 'vue'`,
useCode: `if (window.devtoolsFormatters) {
const index = window.devtoolsFormatters.findIndex((v) => v.__vue_custom_formatter)
window.devtoolsFormatters.splice(index, 1)
initCustomFormatter()
} else {
initCustomFormatter()
}`,
},
}"
/> />
</template> </template>

View File

@ -43,6 +43,7 @@ import {
isCoreComponent, isCoreComponent,
isSimpleIdentifier, isSimpleIdentifier,
isStaticArgOf, isStaticArgOf,
isVPre,
} from './utils' } from './utils'
import { decodeHTML } from 'entities/lib/decode.js' import { decodeHTML } from 'entities/lib/decode.js'
import { import {
@ -246,7 +247,7 @@ const tokenizer = new Tokenizer(stack, {
ondirarg(start, end) { ondirarg(start, end) {
if (start === end) return if (start === end) return
const arg = getSlice(start, end) const arg = getSlice(start, end)
if (inVPre) { if (inVPre && !isVPre(currentProp!)) {
;(currentProp as AttributeNode).name += arg ;(currentProp as AttributeNode).name += arg
setLocEnd((currentProp as AttributeNode).nameLoc, end) setLocEnd((currentProp as AttributeNode).nameLoc, end)
} else { } else {
@ -262,7 +263,7 @@ const tokenizer = new Tokenizer(stack, {
ondirmodifier(start, end) { ondirmodifier(start, end) {
const mod = getSlice(start, end) const mod = getSlice(start, end)
if (inVPre) { if (inVPre && !isVPre(currentProp!)) {
;(currentProp as AttributeNode).name += '.' + mod ;(currentProp as AttributeNode).name += '.' + mod
setLocEnd((currentProp as AttributeNode).nameLoc, end) setLocEnd((currentProp as AttributeNode).nameLoc, end)
} else if ((currentProp as DirectiveNode).name === 'slot') { } else if ((currentProp as DirectiveNode).name === 'slot') {

View File

@ -65,7 +65,7 @@ export const transformBind: DirectiveTransform = (dir, _node, context) => {
arg.children.unshift(`(`) arg.children.unshift(`(`)
arg.children.push(`) || ""`) arg.children.push(`) || ""`)
} else if (!arg.isStatic) { } else if (!arg.isStatic) {
arg.content = `${arg.content} || ""` arg.content = arg.content ? `${arg.content} || ""` : `""`
} }
// .sync is replaced by v-model:arg // .sync is replaced by v-model:arg

View File

@ -63,7 +63,7 @@ export function isCoreComponent(tag: string): symbol | void {
} }
} }
const nonIdentifierRE = /^\d|[^\$\w\xA0-\uFFFF]/ const nonIdentifierRE = /^$|^\d|[^\$\w\xA0-\uFFFF]/
export const isSimpleIdentifier = (name: string): boolean => export const isSimpleIdentifier = (name: string): boolean =>
!nonIdentifierRE.test(name) !nonIdentifierRE.test(name)
@ -343,6 +343,10 @@ export function isText(
return node.type === NodeTypes.INTERPOLATION || node.type === NodeTypes.TEXT return node.type === NodeTypes.INTERPOLATION || node.type === NodeTypes.TEXT
} }
export function isVPre(p: ElementNode['props'][0]): p is DirectiveNode {
return p.type === NodeTypes.DIRECTIVE && p.name === 'pre'
}
export function isVSlot(p: ElementNode['props'][0]): p is DirectiveNode { export function isVSlot(p: ElementNode['props'][0]): p is DirectiveNode {
return p.type === NodeTypes.DIRECTIVE && p.name === 'slot' return p.type === NodeTypes.DIRECTIVE && p.name === 'slot'
} }

View File

@ -42,7 +42,7 @@ if (__TEST__) {
if (DOMErrorCodes.X_V_HTML_NO_EXPRESSION < ErrorCodes.__EXTEND_POINT__) { if (DOMErrorCodes.X_V_HTML_NO_EXPRESSION < ErrorCodes.__EXTEND_POINT__) {
throw new Error( throw new Error(
`DOMErrorCodes need to be updated to ${ `DOMErrorCodes need to be updated to ${
ErrorCodes.__EXTEND_POINT__ + 1 ErrorCodes.__EXTEND_POINT__
} to match extension point from core ErrorCodes.`, } to match extension point from core ErrorCodes.`,
) )
} }

View File

@ -884,9 +884,9 @@ export default {
return (_ctx, _push, _parent, _attrs) => { return (_ctx, _push, _parent, _attrs) => {
const _cssVars = { style: { const _cssVars = { style: {
"--xxxxxxxx-count": (count.value), ":--xxxxxxxx-count": (count.value),
"--xxxxxxxx-style\\\\.color": (style.color), ":--xxxxxxxx-style\\\\.color": (style.color),
"--xxxxxxxx-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px") ":--xxxxxxxx-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")
}} }}
_push(\`<!--[--><div\${ _push(\`<!--[--><div\${
_ssrRenderAttrs(_cssVars) _ssrRenderAttrs(_cssVars)

View File

@ -652,10 +652,10 @@ describe('SFC compile <script setup>', () => {
expect(content).toMatch(`return (_ctx, _push`) expect(content).toMatch(`return (_ctx, _push`)
expect(content).toMatch(`ssrInterpolate`) expect(content).toMatch(`ssrInterpolate`)
expect(content).not.toMatch(`useCssVars`) expect(content).not.toMatch(`useCssVars`)
expect(content).toMatch(`"--${mockId}-count": (count.value)`) expect(content).toMatch(`":--${mockId}-count": (count.value)`)
expect(content).toMatch(`"--${mockId}-style\\\\.color": (style.color)`) expect(content).toMatch(`":--${mockId}-style\\\\.color": (style.color)`)
expect(content).toMatch( expect(content).toMatch(
`"--${mockId}-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")`, `":--${mockId}-height\\\\ \\\\+\\\\ \\\\\\"px\\\\\\"": (height.value + "px")`,
) )
assertCode(content) assertCode(content)
}) })

View File

@ -18,6 +18,7 @@ import type {
Declaration, Declaration,
ExportSpecifier, ExportSpecifier,
Identifier, Identifier,
LVal,
Node, Node,
ObjectPattern, ObjectPattern,
Statement, Statement,
@ -540,7 +541,7 @@ export function compileScript(
} }
// defineProps // defineProps
const isDefineProps = processDefineProps(ctx, init, decl.id) const isDefineProps = processDefineProps(ctx, init, decl.id as LVal)
if (ctx.propsDestructureRestId) { if (ctx.propsDestructureRestId) {
setupBindings[ctx.propsDestructureRestId] = setupBindings[ctx.propsDestructureRestId] =
BindingTypes.SETUP_REACTIVE_CONST BindingTypes.SETUP_REACTIVE_CONST
@ -548,10 +549,10 @@ export function compileScript(
// defineEmits // defineEmits
const isDefineEmits = const isDefineEmits =
!isDefineProps && processDefineEmits(ctx, init, decl.id) !isDefineProps && processDefineEmits(ctx, init, decl.id as LVal)
!isDefineEmits && !isDefineEmits &&
(processDefineSlots(ctx, init, decl.id) || (processDefineSlots(ctx, init, decl.id as LVal) ||
processDefineModel(ctx, init, decl.id)) processDefineModel(ctx, init, decl.id as LVal))
if ( if (
isDefineProps && isDefineProps &&

View File

@ -23,7 +23,12 @@ export function genCssVarsFromList(
return `{\n ${vars return `{\n ${vars
.map( .map(
key => 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}` .join(',\n ')}\n}`
} }

View File

@ -10,6 +10,7 @@ import { warn } from '../warn'
const animationNameRE = /^(-\w+-)?animation-name$/ const animationNameRE = /^(-\w+-)?animation-name$/
const animationRE = /^(-\w+-)?animation$/ const animationRE = /^(-\w+-)?animation$/
const keyframesRE = /^(?:-\w+-)?keyframes$/
const scopedPlugin: PluginCreator<string> = (id = '') => { const scopedPlugin: PluginCreator<string> = (id = '') => {
const keyframes = Object.create(null) const keyframes = Object.create(null)
@ -21,10 +22,7 @@ const scopedPlugin: PluginCreator<string> = (id = '') => {
processRule(id, rule) processRule(id, rule)
}, },
AtRule(node) { AtRule(node) {
if ( if (keyframesRE.test(node.name) && !node.params.endsWith(`-${shortId}`)) {
/-?keyframes$/.test(node.name) &&
!node.params.endsWith(`-${shortId}`)
) {
// register keyframes // register keyframes
keyframes[node.params] = node.params = node.params + '-' + shortId keyframes[node.params] = node.params = node.params + '-' + shortId
} }
@ -72,7 +70,7 @@ function processRule(id: string, rule: Rule) {
processedRules.has(rule) || processedRules.has(rule) ||
(rule.parent && (rule.parent &&
rule.parent.type === 'atrule' && rule.parent.type === 'atrule' &&
/-?keyframes$/.test((rule.parent as AtRule).name)) keyframesRE.test((rule.parent as AtRule).name))
) { ) {
return return
} }

View File

@ -29,7 +29,7 @@ if (__TEST__) {
if (SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME < DOMErrorCodes.__EXTEND_POINT__) { if (SSRErrorCodes.X_SSR_UNSAFE_ATTR_NAME < DOMErrorCodes.__EXTEND_POINT__) {
throw new Error( throw new Error(
`SSRErrorCodes need to be updated to ${ `SSRErrorCodes need to be updated to ${
DOMErrorCodes.__EXTEND_POINT__ + 1 DOMErrorCodes.__EXTEND_POINT__
} to match extension point from core DOMErrorCodes.`, } to match extension point from core DOMErrorCodes.`,
) )
} }

View File

@ -1,6 +1,5 @@
import { isFunction } from '@vue/shared' import { isFunction } from '@vue/shared'
import { currentInstance } from './component' import { currentInstance, getCurrentInstance } from './component'
import { currentRenderingInstance } from './componentRenderContext'
import { currentApp } from './apiCreateApp' import { currentApp } from './apiCreateApp'
import { warn } from './warning' import { warn } from './warning'
@ -51,7 +50,7 @@ export function inject(
) { ) {
// fallback to `currentRenderingInstance` so that this can be called in // fallback to `currentRenderingInstance` so that this can be called in
// a functional component // a functional component
const instance = currentInstance || currentRenderingInstance const instance = getCurrentInstance()
// also support looking up from app-level provides w/ `app.runWithContext()` // also support looking up from app-level provides w/ `app.runWithContext()`
if (instance || currentApp) { if (instance || currentApp) {
@ -90,5 +89,5 @@ export function inject(
* user. One example is `useRoute()` in `vue-router`. * user. One example is `useRoute()` in `vue-router`.
*/ */
export function hasInjectionContext(): boolean { export function hasInjectionContext(): boolean {
return !!(currentInstance || currentRenderingInstance || currentApp) return !!(getCurrentInstance() || currentApp)
} }

View File

@ -585,13 +585,13 @@ export interface ComponentInternalInstance {
* For updating css vars on contained teleports * For updating css vars on contained teleports
* @internal * @internal
*/ */
ut?: (vars?: Record<string, string>) => void ut?: (vars?: Record<string, unknown>) => void
/** /**
* dev only. For style v-bind hydration mismatch checks * dev only. For style v-bind hydration mismatch checks
* @internal * @internal
*/ */
getCssVars?: () => Record<string, string> getCssVars?: () => Record<string, unknown>
/** /**
* v2 compat only, for caching mutated $options * v2 compat only, for caching mutated $options

View File

@ -28,6 +28,7 @@ import {
isReservedProp, isReservedProp,
isString, isString,
normalizeClass, normalizeClass,
normalizeCssVarValue,
normalizeStyle, normalizeStyle,
stringifyStyle, stringifyStyle,
} from '@vue/shared' } from '@vue/shared'
@ -945,10 +946,8 @@ function resolveCssVars(
) { ) {
const cssVars = instance.getCssVars() const cssVars = instance.getCssVars()
for (const key in cssVars) { for (const key in cssVars) {
expectedMap.set( const value = normalizeCssVarValue(cssVars[key])
`--${getEscapedCssVarName(key, false)}`, expectedMap.set(`--${getEscapedCssVarName(key, false)}`, value)
String(cssVars[key]),
)
} }
} }
if (vnode === root && instance.parent) { if (vnode === root && instance.parent) {

View File

@ -465,4 +465,27 @@ describe('useCssVars', () => {
render(h(App), root) render(h(App), root)
expect(colorInOnMount).toBe(`red`) 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')
})
}) })

View File

@ -10,14 +10,16 @@ import {
warn, warn,
watch, watch,
} from '@vue/runtime-core' } 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' : '') export const CSS_VAR_TEXT: unique symbol = Symbol(__DEV__ ? 'CSS_VAR_TEXT' : '')
/** /**
* Runtime helper for SFC's CSS variable injection feature. * Runtime helper for SFC's CSS variable injection feature.
* @private * @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 if (!__BROWSER__ && !__TEST__) return
const instance = getCurrentInstance() 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) { if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
const suspense = vnode.suspense! const suspense = vnode.suspense!
vnode = suspense.activeBranch! 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) { if (el.nodeType === 1) {
const style = (el as HTMLElement).style const style = (el as HTMLElement).style
let cssText = '' let cssText = ''
for (const key in vars) { for (const key in vars) {
style.setProperty(`--${key}`, vars[key]) const value = normalizeCssVarValue(vars[key])
cssText += `--${key}: ${vars[key]};` style.setProperty(`--${key}`, value)
cssText += `--${key}: ${value};`
} }
;(style as any)[CSS_VAR_TEXT] = cssText ;(style as any)[CSS_VAR_TEXT] = cssText
} }

View File

@ -203,4 +203,19 @@ describe('ssr: renderStyle', () => {
}), }),
).toBe(`color:&quot;&gt;&lt;script;`) ).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;`)
})
}) })

View File

@ -1,5 +1,7 @@
import { import {
escapeHtml, escapeHtml,
isArray,
isObject,
isRenderableAttrValue, isRenderableAttrValue,
isSVGTag, isSVGTag,
stringifyStyle, stringifyStyle,
@ -12,6 +14,7 @@ import {
isString, isString,
makeMap, makeMap,
normalizeClass, normalizeClass,
normalizeCssVarValue,
normalizeStyle, normalizeStyle,
propsToAttrMap, propsToAttrMap,
} from '@vue/shared' } from '@vue/shared'
@ -93,6 +96,22 @@ export function ssrRenderStyle(raw: unknown): string {
if (isString(raw)) { if (isString(raw)) {
return escapeHtml(raw) return escapeHtml(raw)
} }
const styles = normalizeStyle(raw) const styles = normalizeStyle(ssrResetCssVars(raw))
return escapeHtml(stringifyStyle(styles)) 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
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,8 @@ packages:
- 'packages-private/*' - 'packages-private/*'
catalog: catalog:
'@babel/parser': ^7.27.5 '@babel/parser': ^7.28.0
'@babel/types': ^7.27.6 '@babel/types': ^7.28.0
'estree-walker': ^2.0.2 'estree-walker': ^2.0.2
'magic-string': ^0.30.17 'magic-string': ^0.30.17
'source-map-js': ^1.2.1 'source-map-js': ^1.2.1