Skip to content

Commit 6f22beb

Browse files
authored
Merge pull request #2359 from dos1in/fix/css-var-crash
fix(rn): 修复 CSS 变量中存在非法 fallback 值引起的 crash
2 parents a70d97b + 0670951 commit 6f22beb

File tree

2 files changed

+292
-10
lines changed

2 files changed

+292
-10
lines changed

packages/webpack-plugin/lib/platform/style/wx/index.js

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,48 @@ module.exports = function getSpec ({ warn, error }) {
8989
if (rule[1].test(prop)) return rule[0]
9090
}
9191
}
92-
// const getDefaultValueFromVar = (str) => {
93-
// const totalVarExp = /^var\((.+)\)$/
94-
// if (!totalVarExp.test(str)) return str
95-
// const newVal = parseValues((str.match(totalVarExp)?.[1] || ''), ',')
96-
// if (newVal.length <= 1) return ''
97-
// if (!totalVarExp.test(newVal[1])) return newVal[1]
98-
// return getDefaultValueFromVar(newVal[1])
99-
// }
100-
// 属性值校验
92+
93+
// 从 CSS 变量中提取 fallback 值进行验证
94+
// 返回值:fallback 值 | null(没有 fallback)| undefined(循环引用)
95+
const getDefaultValueFromVar = (str, visited = new Set()) => {
96+
const totalVarExp = /^var\((.+)\)$/
97+
if (!totalVarExp.test(str)) return str
98+
99+
// 防止循环引用 - 返回 undefined 表示检测到循环
100+
if (visited.has(str)) return undefined
101+
visited.add(str)
102+
103+
const newVal = parseValues((str.match(totalVarExp)?.[1] || ''), ',')
104+
if (newVal.length <= 1) return null // 没有 fallback
105+
const fallback = newVal[1].trim()
106+
// 如果 fallback 也是 var(),递归提取
107+
if (totalVarExp.test(fallback)) return getDefaultValueFromVar(fallback, visited)
108+
return fallback
109+
}
110+
101111
const verifyValues = ({ prop, value, selector }, isError = true) => {
102112
prop = prop.trim()
103113
value = value.trim()
104114
const tips = isError ? error : warn
105-
if (cssVariableExp.test(value) || calcExp.test(value) || envExp.test(value)) return true
115+
116+
// 对于包含 CSS 变量的值,提取 fallback 值进行验证
117+
if (cssVariableExp.test(value)) {
118+
const fallback = getDefaultValueFromVar(value)
119+
// undefined 表示检测到循环引用
120+
if (fallback === undefined) {
121+
tips(`CSS variable circular reference in fallback chain detected in ${selector} for property ${prop}, value: ${value}`)
122+
return false
123+
}
124+
// null 表示没有 fallback,CSS 变量本身是合法的(运行时会解析)
125+
if (fallback === null) {
126+
return true
127+
}
128+
// 有 fallback 值,将 fallback 作为新的 value 继续后续验证流程
129+
value = fallback
130+
}
131+
132+
// calc() 和 env() 跳过验证
133+
if (calcExp.test(value) || envExp.test(value)) return true
106134
const namedColor = ['transparent', 'aliceblue', 'antiquewhite', 'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black', 'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue', 'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson', 'cyan', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray', 'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange', 'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray', 'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia', 'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green', 'greenyellow', 'grey', 'honeydew', 'hotpink', 'indianred', 'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen', 'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray', 'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen', 'linen', 'magenta', 'maroon', 'mediumaquamarine', 'mediumblue', 'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise', 'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite', 'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered', 'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip', 'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple', 'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon', 'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue', 'slateblue', 'slategray', 'snow', 'springgreen', 'steelblue', 'tan', 'teal', 'thistle', 'tomato', 'turquoise', 'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen']
107135
const valueExp = {
108136
number: /^((-?(\d+(\.\d+)?|\.\d+))(rpx|px|%|vw|vh)?|hairlineWidth)$/,
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
const { getClassMap } = require('../../../lib/react/style-helper')
2+
3+
describe('React Native style validation for CSS variables', () => {
4+
const createConfig = (mode = 'ios') => ({
5+
mode,
6+
srcMode: 'wx',
7+
ctorType: 'component',
8+
warn: jest.fn(),
9+
error: jest.fn()
10+
})
11+
12+
describe('CSS variable fallback validation', () => {
13+
test('should filter out letter-spacing with invalid "normal" fallback', () => {
14+
const css = '.text { letter-spacing: var(--x, normal); }'
15+
const config = createConfig()
16+
17+
const result = getClassMap({
18+
content: css,
19+
filename: 'test.css',
20+
...config
21+
})
22+
23+
expect(result).toEqual({})
24+
expect(config.error).toHaveBeenCalledWith(
25+
expect.stringContaining('letter-spacing')
26+
)
27+
})
28+
29+
test('should filter out line-height with invalid "normal" fallback', () => {
30+
const css = '.text { line-height: var(--y, normal); }'
31+
const config = createConfig()
32+
33+
const result = getClassMap({
34+
content: css,
35+
filename: 'test.css',
36+
...config
37+
})
38+
39+
expect(result).toEqual({})
40+
expect(config.error).toHaveBeenCalledWith(
41+
expect.stringContaining('line-height')
42+
)
43+
})
44+
45+
test('should keep valid CSS variable with numeric fallback', () => {
46+
const css = '.text { letter-spacing: var(--x, 2px); line-height: var(--y, 1.5); }'
47+
const config = createConfig()
48+
49+
const result = getClassMap({
50+
content: css,
51+
filename: 'test.css',
52+
...config
53+
})
54+
55+
expect(result.text._default).toEqual({
56+
letterSpacing: '"var(--x, 2px)"',
57+
lineHeight: '"var(--y, 1.5)"'
58+
})
59+
expect(config.error).not.toHaveBeenCalled()
60+
})
61+
62+
test('should keep CSS variable without fallback', () => {
63+
const css = '.text { letter-spacing: var(--x); line-height: var(--y); }'
64+
const config = createConfig()
65+
66+
const result = getClassMap({
67+
content: css,
68+
filename: 'test.css',
69+
...config
70+
})
71+
72+
expect(result.text._default).toEqual({
73+
letterSpacing: '"var(--x)"',
74+
lineHeight: '"var(--y)"'
75+
})
76+
expect(config.error).not.toHaveBeenCalled()
77+
})
78+
79+
test('should validate nested CSS variables recursively', () => {
80+
const css = '.text { letter-spacing: var(--x, var(--y, normal)); }'
81+
const config = createConfig()
82+
83+
const result = getClassMap({
84+
content: css,
85+
filename: 'test.css',
86+
...config
87+
})
88+
89+
expect(result).toEqual({})
90+
expect(config.error).toHaveBeenCalledWith(
91+
expect.stringContaining('letter-spacing')
92+
)
93+
})
94+
95+
test('should keep nested CSS variables with valid fallback', () => {
96+
const css = '.text { letter-spacing: var(--x, var(--y, 2px)); }'
97+
const config = createConfig()
98+
99+
const result = getClassMap({
100+
content: css,
101+
filename: 'test.css',
102+
...config
103+
})
104+
105+
expect(result.text._default).toEqual({
106+
letterSpacing: '"var(--x, var(--y, 2px))"'
107+
})
108+
expect(config.error).not.toHaveBeenCalled()
109+
})
110+
111+
test('should handle deeply nested CSS variables without infinite recursion', () => {
112+
// 测试深度嵌套(但不超过限制)
113+
const css = '.text { letter-spacing: var(--a, var(--b, var(--c, var(--d, var(--e, 2px))))); }'
114+
const config = createConfig()
115+
116+
const result = getClassMap({
117+
content: css,
118+
filename: 'test.css',
119+
...config
120+
})
121+
122+
expect(result.text._default).toEqual({
123+
letterSpacing: '"var(--a, var(--b, var(--c, var(--d, var(--e, 2px)))))"'
124+
})
125+
expect(config.error).not.toHaveBeenCalled()
126+
})
127+
128+
test('should stop at max depth for extremely nested CSS variables', () => {
129+
// 测试超深嵌套(超过10层)
130+
let nestedVar = '2px'
131+
for (let i = 0; i < 15; i++) {
132+
nestedVar = `var(--x${i}, ${nestedVar})`
133+
}
134+
const css = `.text { letter-spacing: ${nestedVar}; }`
135+
const config = createConfig()
136+
137+
// 不应该导致堆栈溢出,应该正常返回或报错
138+
expect(() => {
139+
getClassMap({
140+
content: css,
141+
filename: 'test.css',
142+
...config
143+
})
144+
}).not.toThrow()
145+
})
146+
147+
test('should handle self-referencing CSS variable without infinite loop', () => {
148+
// 测试循环引用的情况:var(--x, var(--x))
149+
// 注意:var(--x, var(--x)) 的 fallback 是 var(--x),它们是不同的字符串
150+
// 所以这种情况下 fallback 链会终止(var(--x) 没有 fallback 返回 null)
151+
const css = '.text { letter-spacing: var(--x, var(--x)); }'
152+
const config = createConfig()
153+
154+
// 不应该导致无限循环,应该正常返回
155+
const result = getClassMap({
156+
content: css,
157+
filename: 'test.css',
158+
...config
159+
})
160+
161+
// 由于 var(--x) 没有 fallback,整个表达式是合法的,应该保留
162+
expect(result.text._default).toEqual({
163+
letterSpacing: '"var(--x, var(--x))"'
164+
})
165+
expect(config.error).not.toHaveBeenCalled()
166+
})
167+
168+
test('should handle complex nested CSS variables with different fallbacks', () => {
169+
// 测试复杂嵌套:var(--a, var(--b, var(--a, 2px)))
170+
// 虽然看起来像循环引用,但每个 var() 的完整字符串都不同
171+
// var(--a, var(--b, var(--a, 2px))) -> fallback: var(--b, var(--a, 2px))
172+
// var(--b, var(--a, 2px)) -> fallback: var(--a, 2px)
173+
// var(--a, 2px) -> fallback: 2px (有效)
174+
const css = '.text { letter-spacing: var(--a, var(--b, var(--a, 2px))); }'
175+
const config = createConfig()
176+
177+
const result = getClassMap({
178+
content: css,
179+
filename: 'test.css',
180+
...config
181+
})
182+
183+
// 应该成功解析,因为最终 fallback 是有效的 2px
184+
expect(result.text._default).toEqual({
185+
letterSpacing: '"var(--a, var(--b, var(--a, 2px)))"'
186+
})
187+
expect(config.error).not.toHaveBeenCalled()
188+
})
189+
190+
test('should work on both ios and android modes', () => {
191+
const css = '.text { letter-spacing: var(--x, normal); }'
192+
193+
;['ios', 'android', 'harmony'].forEach(mode => {
194+
const config = createConfig(mode)
195+
const result = getClassMap({
196+
content: css,
197+
filename: 'test.css',
198+
...config
199+
})
200+
201+
expect(result).toEqual({})
202+
expect(config.error).toHaveBeenCalled()
203+
})
204+
})
205+
})
206+
207+
describe('Other number properties with CSS variables', () => {
208+
test('should keep margin with "auto" fallback', () => {
209+
const css = '.box { margin-left: var(--m, auto); }'
210+
const config = createConfig()
211+
212+
const result = getClassMap({
213+
content: css,
214+
filename: 'test.css',
215+
...config
216+
})
217+
218+
expect(result.box._default).toHaveProperty('marginLeft')
219+
expect(config.error).not.toHaveBeenCalled()
220+
})
221+
})
222+
223+
describe('Direct normal values (without CSS variables)', () => {
224+
test('should filter out direct letter-spacing: normal', () => {
225+
const css = '.text { letter-spacing: normal; }'
226+
const config = createConfig()
227+
228+
const result = getClassMap({
229+
content: css,
230+
filename: 'test.css',
231+
...config
232+
})
233+
234+
expect(result).toEqual({})
235+
expect(config.error).toHaveBeenCalled()
236+
})
237+
238+
test('should keep direct letter-spacing: 2px', () => {
239+
const css = '.text { letter-spacing: 2px; }'
240+
const config = createConfig()
241+
242+
const result = getClassMap({
243+
content: css,
244+
filename: 'test.css',
245+
...config
246+
})
247+
248+
expect(result.text._default).toEqual({
249+
letterSpacing: '2'
250+
})
251+
expect(config.error).not.toHaveBeenCalled()
252+
})
253+
})
254+
})

0 commit comments

Comments
 (0)