@blumintinc/eslint-plugin-blumint 1.21.18 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/index.js +4 -1
- package/lib/rules/no-compositing-layer-props.d.ts +8 -1
- package/lib/rules/no-compositing-layer-props.js +577 -34
- package/lib/rules/no-margin-properties.d.ts +9 -1
- package/lib/rules/no-margin-properties.js +157 -6
- package/lib/rules/require-sx-props-theme.d.ts +2 -0
- package/lib/rules/require-sx-props-theme.js +361 -0
- package/lib/utils/fixtureTypeProgram.js +46 -0
- package/package.json +1 -1
- package/release-manifest.json +36 -0
|
@@ -15,6 +15,10 @@ function normalizePropertyName(name) {
|
|
|
15
15
|
// Convert camelCase to kebab-case
|
|
16
16
|
return toKebabCase(name).toLowerCase();
|
|
17
17
|
}
|
|
18
|
+
// `scale`, `rotate` and `translate` are the individual transform properties.
|
|
19
|
+
// They promote a layer exactly as `transform` does, so leaving them out would
|
|
20
|
+
// let a declaration the exemptions below refuse under `transform` through
|
|
21
|
+
// under another spelling.
|
|
18
22
|
const COMPOSITING_PROPERTIES = new Set([
|
|
19
23
|
'filter',
|
|
20
24
|
'backdrop-filter',
|
|
@@ -25,6 +29,21 @@ const COMPOSITING_PROPERTIES = new Set([
|
|
|
25
29
|
'contain',
|
|
26
30
|
'mix-blend-mode',
|
|
27
31
|
'opacity',
|
|
32
|
+
'scale',
|
|
33
|
+
'rotate',
|
|
34
|
+
'translate',
|
|
35
|
+
]);
|
|
36
|
+
// The only properties the interaction-state and state-transition exemptions
|
|
37
|
+
// may clear. Every other compositing property (`filter`, `backdrop-filter`,
|
|
38
|
+
// `will-change`, `mix-blend-mode`, `perspective`, `backface-visibility`,
|
|
39
|
+
// `contain`) stays reported in every context: none of them is the cheap,
|
|
40
|
+
// compositor-only animation the exemptions exist to allow.
|
|
41
|
+
const EXEMPTABLE_PROPERTIES = new Set([
|
|
42
|
+
'transform',
|
|
43
|
+
'opacity',
|
|
44
|
+
'scale',
|
|
45
|
+
'rotate',
|
|
46
|
+
'translate',
|
|
28
47
|
]);
|
|
29
48
|
// Vendor-prefixed spellings name the same CSS property as their unprefixed
|
|
30
49
|
// counterpart: `WebkitTransform` normalizes to `-webkit-transform`, `msTransform`
|
|
@@ -32,13 +51,10 @@ const COMPOSITING_PROPERTIES = new Set([
|
|
|
32
51
|
function stripVendorPrefix(normalizedName) {
|
|
33
52
|
return normalizedName.replace(/^-?(?:webkit|moz|ms|o)-/, '');
|
|
34
53
|
}
|
|
35
|
-
// 3D transform functions promote a layer
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'scale3d',
|
|
40
|
-
'translateZ',
|
|
41
|
-
];
|
|
54
|
+
// 3D transform functions promote a layer on their own, whatever state the
|
|
55
|
+
// element is in, so they are never exempt. Matched case-insensitively and only
|
|
56
|
+
// as a function call, so a class name or prose containing the word is inert.
|
|
57
|
+
const THREE_D_TRANSFORM_FUNCTION = /\b(?:translate3d|translatez|scale3d|scalez|rotate3d|rotatex|rotatey|rotatez|matrix3d|perspective)\s*\(/i;
|
|
42
58
|
// `transform` is the only property that takes a transform function as its
|
|
43
59
|
// value, so it is the whole key gate for the value-driven arm. A value can only
|
|
44
60
|
// act through a property that accepts it; ungated, any key at all was named as
|
|
@@ -58,6 +74,9 @@ const NON_COMPOSITING_VALUES = {
|
|
|
58
74
|
filter: new Set(['none']),
|
|
59
75
|
'backdrop-filter': new Set(['none']),
|
|
60
76
|
transform: new Set(['none']),
|
|
77
|
+
scale: new Set(['none', '1']),
|
|
78
|
+
rotate: new Set(['none']),
|
|
79
|
+
translate: new Set(['none']),
|
|
61
80
|
contain: new Set(['none']),
|
|
62
81
|
perspective: new Set(['none']),
|
|
63
82
|
'will-change': new Set(['auto', 'unset', 'initial', 'inherit', 'revert']),
|
|
@@ -67,6 +86,461 @@ const NON_COMPOSITING_VALUES = {
|
|
|
67
86
|
// it. `inherit` is excluded: it can resolve to a blending value set higher up.
|
|
68
87
|
'mix-blend-mode': new Set(['normal', 'initial', 'unset', 'revert']),
|
|
69
88
|
};
|
|
89
|
+
// Stands in for the element the style object applies to while nested selector
|
|
90
|
+
// keys are composed, so "on the `&` compound or to its right" is decidable
|
|
91
|
+
// after `&` has been substituted away.
|
|
92
|
+
const ROOT_MARKER = '\u0000';
|
|
93
|
+
// Stands in for an interpolated `${...}` inside a selector key. It matches no
|
|
94
|
+
// qualifier pattern, so an unreadable fragment can never qualify a selector.
|
|
95
|
+
const INTERPOLATION_MARKER = '\u0001';
|
|
96
|
+
// A pseudo-class or class that one element holds at a time (the pointer's
|
|
97
|
+
// target, the pressed element, the focused element, and their ancestors). A
|
|
98
|
+
// transform under it can never promote a whole list at once.
|
|
99
|
+
const INTERACTION_PSEUDO_CLASS = /:(?:hover|active|focus|focus-visible|focus-within)(?![\w-])/i;
|
|
100
|
+
const INTERACTION_MUI_CLASS = /\.Mui-(?:focusVisible|focused|active)(?![\w-])/;
|
|
101
|
+
// A qualifier that flips at runtime rather than describing a static variant:
|
|
102
|
+
// MUI's global state classes (`.Mui-checked`, `.Mui-expanded`, ...), state
|
|
103
|
+
// pseudo-classes, and ARIA/data/boolean state attributes.
|
|
104
|
+
const STATE_PSEUDO_CLASS = /:(?:hover|active|focus|focus-visible|focus-within|checked|disabled|enabled|indeterminate|open|popover-open|target|visited|invalid|valid|user-invalid|user-valid|placeholder-shown|autofill|read-only|read-write|fullscreen)(?![\w-])/i;
|
|
105
|
+
const STATE_MUI_CLASS = /\.Mui-[a-z][\w]*(?![\w-])/;
|
|
106
|
+
const STATE_ATTRIBUTE = /\[\s*(?:aria-[\w-]+|data-[\w-]+|open|hidden|disabled|checked|selected)\s*(?:[~|^$*]?=[^\]]*)?\]/i;
|
|
107
|
+
const TRANSITION_PROPERTIES = new Set(['transition', 'transition-property']);
|
|
108
|
+
const TRANSITION_TIME = /^[+-]?(?:\d+\.?\d*|\.\d+)m?s$/;
|
|
109
|
+
const BARE_NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)$/;
|
|
110
|
+
const TRANSITION_KEYWORDS = new Set([
|
|
111
|
+
'ease',
|
|
112
|
+
'ease-in',
|
|
113
|
+
'ease-out',
|
|
114
|
+
'ease-in-out',
|
|
115
|
+
'linear',
|
|
116
|
+
'step-start',
|
|
117
|
+
'step-end',
|
|
118
|
+
'normal',
|
|
119
|
+
'allow-discrete',
|
|
120
|
+
]);
|
|
121
|
+
const GLOBAL_KEYWORDS = new Set([
|
|
122
|
+
'inherit',
|
|
123
|
+
'initial',
|
|
124
|
+
'unset',
|
|
125
|
+
'revert',
|
|
126
|
+
'revert-layer',
|
|
127
|
+
]);
|
|
128
|
+
// Lexical iteration: a state-driven transform declared inside one of these
|
|
129
|
+
// callbacks styles every rendered sibling, and many siblings can be "on" at
|
|
130
|
+
// once, so the state-transition exemption is withheld there.
|
|
131
|
+
const ITERATION_METHODS = new Set(['map', 'flatMap', 'forEach']);
|
|
132
|
+
// Render props the host calls once per item. Every `render*` prop is treated
|
|
133
|
+
// as one because the rule cannot tell `renderOption` from `renderInput`, and
|
|
134
|
+
// withholding the exemption only keeps the pre-existing report.
|
|
135
|
+
const ITEM_RENDER_PROP = /^(?:render[A-Z0-9_]\w*|itemContent|\w+Renderer)$/;
|
|
136
|
+
// A template literal with no substitutions spells a literal exactly, so it is
|
|
137
|
+
// read as one; any other non-literal value is unreadable (`null`).
|
|
138
|
+
function readStaticText(node) {
|
|
139
|
+
if (node.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
140
|
+
return String(node.value);
|
|
141
|
+
}
|
|
142
|
+
if (node.type === utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
|
143
|
+
node.expressions.length === 0) {
|
|
144
|
+
return node.quasis[0].value.cooked ?? node.quasis[0].value.raw;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
// Every literal fragment an expression can evaluate to or embed, so a 3D
|
|
149
|
+
// transform function is found even inside a ternary or an interpolation.
|
|
150
|
+
function collectStaticFragments(node) {
|
|
151
|
+
switch (node.type) {
|
|
152
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
153
|
+
return typeof node.value === 'string' ? [node.value] : [];
|
|
154
|
+
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
155
|
+
return [
|
|
156
|
+
...node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw),
|
|
157
|
+
...node.expressions.flatMap(collectStaticFragments),
|
|
158
|
+
];
|
|
159
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
160
|
+
return [
|
|
161
|
+
...collectStaticFragments(node.consequent),
|
|
162
|
+
...collectStaticFragments(node.alternate),
|
|
163
|
+
];
|
|
164
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
165
|
+
return [
|
|
166
|
+
...collectStaticFragments(node.left),
|
|
167
|
+
...collectStaticFragments(node.right),
|
|
168
|
+
];
|
|
169
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
170
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
171
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
172
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
173
|
+
return collectStaticFragments(node.expression);
|
|
174
|
+
default:
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Strip `!important` and normalize casing so reset/identity values written
|
|
179
|
+
// as e.g. `none !important` are still recognized as non-promoting.
|
|
180
|
+
function normalizePropertyValue(value) {
|
|
181
|
+
return value
|
|
182
|
+
.replace(/\s*!important\s*$/i, '')
|
|
183
|
+
.trim()
|
|
184
|
+
.toLowerCase();
|
|
185
|
+
}
|
|
186
|
+
// Splits on `separator` only outside parentheses, brackets and quotes, so
|
|
187
|
+
// `cubic-bezier(0.4, 0, 0.2, 1)` and `:is(a, b)` stay whole.
|
|
188
|
+
function splitTopLevel(text, isSeparator) {
|
|
189
|
+
const parts = [];
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let quote = null;
|
|
192
|
+
let current = '';
|
|
193
|
+
for (const ch of text) {
|
|
194
|
+
if (quote) {
|
|
195
|
+
if (ch === quote)
|
|
196
|
+
quote = null;
|
|
197
|
+
current += ch;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (ch === '"' || ch === "'") {
|
|
201
|
+
quote = ch;
|
|
202
|
+
}
|
|
203
|
+
else if (ch === '(' || ch === '[') {
|
|
204
|
+
depth += 1;
|
|
205
|
+
}
|
|
206
|
+
else if (ch === ')' || ch === ']') {
|
|
207
|
+
depth -= 1;
|
|
208
|
+
}
|
|
209
|
+
else if (depth === 0 && isSeparator(ch)) {
|
|
210
|
+
if (current.trim())
|
|
211
|
+
parts.push(current.trim());
|
|
212
|
+
current = '';
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
current += ch;
|
|
216
|
+
}
|
|
217
|
+
if (current.trim())
|
|
218
|
+
parts.push(current.trim());
|
|
219
|
+
return parts;
|
|
220
|
+
}
|
|
221
|
+
// Individual transform properties take a 3D form through their value grammar
|
|
222
|
+
// rather than a function: a third `translate`/`scale` component, or a
|
|
223
|
+
// `rotate` axis.
|
|
224
|
+
function isThreeDLiteral(propertyName, value) {
|
|
225
|
+
const tokens = splitTopLevel(normalizePropertyValue(value), (ch) => /\s/.test(ch));
|
|
226
|
+
if (propertyName === 'translate' || propertyName === 'scale') {
|
|
227
|
+
return tokens.length >= 3;
|
|
228
|
+
}
|
|
229
|
+
if (propertyName === 'rotate') {
|
|
230
|
+
return tokens.length >= 2;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
// `:not(:hover)` matches every element that is NOT hovered, the opposite of
|
|
235
|
+
// singular, so a negated qualifier must never count.
|
|
236
|
+
function stripNegations(compound) {
|
|
237
|
+
let result = compound;
|
|
238
|
+
let start = result.search(/:not\(/i);
|
|
239
|
+
while (start !== -1) {
|
|
240
|
+
let depth = 0;
|
|
241
|
+
let end = start + ':not'.length;
|
|
242
|
+
for (; end < result.length; end += 1) {
|
|
243
|
+
if (result[end] === '(')
|
|
244
|
+
depth += 1;
|
|
245
|
+
if (result[end] === ')') {
|
|
246
|
+
depth -= 1;
|
|
247
|
+
if (depth === 0)
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
result = result.slice(0, start) + result.slice(end + 1);
|
|
252
|
+
start = result.search(/:not\(/i);
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
// The compounds of a composed selector from the one holding the styled
|
|
257
|
+
// element rightward. A qualifier to the LEFT of that compound belongs to an
|
|
258
|
+
// ancestor (`.MuiList-root:hover &`), which matches every child at once.
|
|
259
|
+
function compoundsFromRoot(member) {
|
|
260
|
+
const compounds = splitTopLevel(member, (ch) => /[\s>+~]/.test(ch));
|
|
261
|
+
const rootIndex = compounds.findIndex((compound) => compound.includes(ROOT_MARKER));
|
|
262
|
+
return rootIndex === -1 ? [] : compounds.slice(rootIndex).map(stripNegations);
|
|
263
|
+
}
|
|
264
|
+
// Nested selector keys compose the way emotion composes them: `&` is replaced
|
|
265
|
+
// by the parent selector, a key opening with `:` attaches to the parent
|
|
266
|
+
// compound, and any other key is a descendant of it.
|
|
267
|
+
function composeSelectors(selectors) {
|
|
268
|
+
let members = [ROOT_MARKER];
|
|
269
|
+
for (const selector of selectors) {
|
|
270
|
+
const next = [];
|
|
271
|
+
for (const member of splitTopLevel(selector, (ch) => ch === ',')) {
|
|
272
|
+
for (const parent of members) {
|
|
273
|
+
if (member.includes('&')) {
|
|
274
|
+
next.push(member.split('&').join(parent));
|
|
275
|
+
}
|
|
276
|
+
else if (member.startsWith(':')) {
|
|
277
|
+
next.push(`${parent}${member}`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
next.push(`${parent} ${member}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
members = next;
|
|
285
|
+
}
|
|
286
|
+
return members;
|
|
287
|
+
}
|
|
288
|
+
function carriesInteraction(compound) {
|
|
289
|
+
return (INTERACTION_PSEUDO_CLASS.test(compound) ||
|
|
290
|
+
INTERACTION_MUI_CLASS.test(compound));
|
|
291
|
+
}
|
|
292
|
+
function carriesState(compound) {
|
|
293
|
+
return (carriesInteraction(compound) ||
|
|
294
|
+
STATE_PSEUDO_CLASS.test(compound) ||
|
|
295
|
+
STATE_MUI_CLASS.test(compound) ||
|
|
296
|
+
STATE_ATTRIBUTE.test(compound));
|
|
297
|
+
}
|
|
298
|
+
// Every comma-separated member of the composed selector must qualify: one
|
|
299
|
+
// unqualified member applies the declaration at rest.
|
|
300
|
+
function everyMemberQualifies(chain, qualifies) {
|
|
301
|
+
if (chain.hasUnreadableSelector || chain.selectors.length === 0) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
return composeSelectors(chain.selectors).every((member) => compoundsFromRoot(member).some(qualifies));
|
|
305
|
+
}
|
|
306
|
+
function readKeyName(key) {
|
|
307
|
+
if (key.type === utils_1.AST_NODE_TYPES.Identifier)
|
|
308
|
+
return key.name;
|
|
309
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal)
|
|
310
|
+
return String(key.value);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
// `[theme.breakpoints.up('md')]` compiles to an `@media` query, so it is
|
|
314
|
+
// skipped exactly as a literal `@media` key is.
|
|
315
|
+
function isBreakpointKey(key) {
|
|
316
|
+
let current = key;
|
|
317
|
+
for (;;) {
|
|
318
|
+
switch (current.type) {
|
|
319
|
+
case utils_1.AST_NODE_TYPES.ChainExpression:
|
|
320
|
+
current = current.expression;
|
|
321
|
+
break;
|
|
322
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
323
|
+
current = current.expression;
|
|
324
|
+
break;
|
|
325
|
+
case utils_1.AST_NODE_TYPES.CallExpression:
|
|
326
|
+
current = current.callee;
|
|
327
|
+
break;
|
|
328
|
+
case utils_1.AST_NODE_TYPES.MemberExpression:
|
|
329
|
+
if (current.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
330
|
+
current.property.name === 'breakpoints') {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
current = current.object;
|
|
334
|
+
break;
|
|
335
|
+
default:
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function classifyKey(property) {
|
|
341
|
+
const { key } = property;
|
|
342
|
+
if (key.type === utils_1.AST_NODE_TYPES.Literal && typeof key.value === 'string') {
|
|
343
|
+
return /^\s*@/.test(key.value)
|
|
344
|
+
? { kind: 'at-rule' }
|
|
345
|
+
: { kind: 'selector', selector: key.value };
|
|
346
|
+
}
|
|
347
|
+
if (key.type === utils_1.AST_NODE_TYPES.TemplateLiteral) {
|
|
348
|
+
const selector = key.quasis
|
|
349
|
+
.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
|
|
350
|
+
.join(INTERPOLATION_MARKER);
|
|
351
|
+
return /^\s*@/.test(selector)
|
|
352
|
+
? { kind: 'at-rule' }
|
|
353
|
+
: { kind: 'selector', selector };
|
|
354
|
+
}
|
|
355
|
+
if (property.computed) {
|
|
356
|
+
return isBreakpointKey(key)
|
|
357
|
+
? { kind: 'at-rule' }
|
|
358
|
+
: { kind: 'unreadable-selector' };
|
|
359
|
+
}
|
|
360
|
+
// A bare identifier key (`root`, `sx`, `style`, a slot name) names the
|
|
361
|
+
// element being styled rather than a selector below it.
|
|
362
|
+
return { kind: 'boundary' };
|
|
363
|
+
}
|
|
364
|
+
// Object entries of an `sx` array element, looking through the conditional
|
|
365
|
+
// wrappers MUI's array form is written with.
|
|
366
|
+
function objectsOfArrayEntry(node) {
|
|
367
|
+
if (!node)
|
|
368
|
+
return [];
|
|
369
|
+
switch (node.type) {
|
|
370
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
371
|
+
return [node];
|
|
372
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
373
|
+
return objectsOfArrayEntry(node.right);
|
|
374
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
375
|
+
return [
|
|
376
|
+
...objectsOfArrayEntry(node.consequent),
|
|
377
|
+
...objectsOfArrayEntry(node.alternate),
|
|
378
|
+
];
|
|
379
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
380
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
381
|
+
return objectsOfArrayEntry(node.expression);
|
|
382
|
+
default:
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Climbs from a declaration to the root of the style object it belongs to,
|
|
387
|
+
// collecting what both exemptions read. The climb crosses only constructs that
|
|
388
|
+
// keep it inside one style value; anything else (a call argument, a JSX
|
|
389
|
+
// attribute, a variable declarator, a statement) ends it.
|
|
390
|
+
function climbStyleChain(property) {
|
|
391
|
+
const selectorsInnerFirst = [];
|
|
392
|
+
const objects = [];
|
|
393
|
+
let hasUnreadableSelector = false;
|
|
394
|
+
let isConditional = false;
|
|
395
|
+
let child = property;
|
|
396
|
+
let current = property.parent;
|
|
397
|
+
climb: while (current) {
|
|
398
|
+
switch (current.type) {
|
|
399
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
400
|
+
objects.push(current);
|
|
401
|
+
break;
|
|
402
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
403
|
+
for (const element of current.elements) {
|
|
404
|
+
if (element !== child)
|
|
405
|
+
objects.push(...objectsOfArrayEntry(element));
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
case utils_1.AST_NODE_TYPES.Property: {
|
|
409
|
+
if (current.value !== child)
|
|
410
|
+
break climb;
|
|
411
|
+
const key = classifyKey(current);
|
|
412
|
+
if (key.kind === 'boundary')
|
|
413
|
+
break climb;
|
|
414
|
+
if (key.kind === 'selector')
|
|
415
|
+
selectorsInnerFirst.push(key.selector);
|
|
416
|
+
if (key.kind === 'unreadable-selector')
|
|
417
|
+
hasUnreadableSelector = true;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
421
|
+
if (current.test === child)
|
|
422
|
+
break climb;
|
|
423
|
+
isConditional = true;
|
|
424
|
+
break;
|
|
425
|
+
case utils_1.AST_NODE_TYPES.LogicalExpression:
|
|
426
|
+
isConditional = true;
|
|
427
|
+
break;
|
|
428
|
+
case utils_1.AST_NODE_TYPES.ArrowFunctionExpression:
|
|
429
|
+
if (current.body !== child)
|
|
430
|
+
break climb;
|
|
431
|
+
break;
|
|
432
|
+
case utils_1.AST_NODE_TYPES.SpreadElement:
|
|
433
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
434
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
435
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
436
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
437
|
+
break;
|
|
438
|
+
default:
|
|
439
|
+
break climb;
|
|
440
|
+
}
|
|
441
|
+
child = current;
|
|
442
|
+
current = current.parent;
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
selectors: selectorsInnerFirst.reverse(),
|
|
446
|
+
hasUnreadableSelector,
|
|
447
|
+
objects,
|
|
448
|
+
isConditional,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
// The properties a transition shorthand animates. An item naming no property
|
|
452
|
+
// animates `all`, which is the shorthand's initial `transition-property`.
|
|
453
|
+
function readTransitionShorthand(value) {
|
|
454
|
+
if (value === 'none')
|
|
455
|
+
return [];
|
|
456
|
+
return splitTopLevel(value, (ch) => ch === ',').map((item) => {
|
|
457
|
+
const tokens = splitTopLevel(item, (ch) => /\s/.test(ch));
|
|
458
|
+
const property = tokens.find((token) => !TRANSITION_TIME.test(token) &&
|
|
459
|
+
!BARE_NUMBER.test(token) &&
|
|
460
|
+
!TRANSITION_KEYWORDS.has(token) &&
|
|
461
|
+
!token.includes('('));
|
|
462
|
+
return property ?? 'all';
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
function transitionCovers(value, transitionProperty, target) {
|
|
466
|
+
// `isDragging ? 'none' : TRANSITIONS.transform` covers through the branch
|
|
467
|
+
// that animates.
|
|
468
|
+
if (value.type === utils_1.AST_NODE_TYPES.ConditionalExpression) {
|
|
469
|
+
return (transitionCovers(value.consequent, transitionProperty, target) ||
|
|
470
|
+
transitionCovers(value.alternate, transitionProperty, target));
|
|
471
|
+
}
|
|
472
|
+
const text = readStaticText(value);
|
|
473
|
+
// An expression the rule cannot read (`createTransition(...)`,
|
|
474
|
+
// `theme.transitions.create(...)`, a constant) is trusted to cover it.
|
|
475
|
+
if (text === null)
|
|
476
|
+
return true;
|
|
477
|
+
const normalized = normalizePropertyValue(text);
|
|
478
|
+
if (GLOBAL_KEYWORDS.has(normalized))
|
|
479
|
+
return true;
|
|
480
|
+
const animated = transitionProperty === 'transition'
|
|
481
|
+
? readTransitionShorthand(normalized)
|
|
482
|
+
: splitTopLevel(normalized, (ch) => ch === ',');
|
|
483
|
+
return animated.some((name) => name === 'all' || stripVendorPrefix(name) === target);
|
|
484
|
+
}
|
|
485
|
+
function hasCoveringTransition(chain, target) {
|
|
486
|
+
return chain.objects.some((object) => object.properties.some((entry) => {
|
|
487
|
+
if (entry.type !== utils_1.AST_NODE_TYPES.Property || entry.computed) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
const name = readKeyName(entry.key);
|
|
491
|
+
if (name === null)
|
|
492
|
+
return false;
|
|
493
|
+
const transitionProperty = stripVendorPrefix(normalizePropertyName(name));
|
|
494
|
+
return (TRANSITION_PROPERTIES.has(transitionProperty) &&
|
|
495
|
+
transitionCovers(entry.value, transitionProperty, target));
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
function isFunctionNode(node) {
|
|
499
|
+
return (node.type === utils_1.AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
500
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionExpression ||
|
|
501
|
+
node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration);
|
|
502
|
+
}
|
|
503
|
+
function isIterationCallee(callee) {
|
|
504
|
+
if (callee.type !== utils_1.AST_NODE_TYPES.MemberExpression ||
|
|
505
|
+
callee.computed ||
|
|
506
|
+
callee.property.type !== utils_1.AST_NODE_TYPES.Identifier) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
if (ITERATION_METHODS.has(callee.property.name))
|
|
510
|
+
return true;
|
|
511
|
+
return (callee.property.name === 'from' &&
|
|
512
|
+
callee.object.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
513
|
+
callee.object.name === 'Array');
|
|
514
|
+
}
|
|
515
|
+
function isItemRenderProp(fn) {
|
|
516
|
+
const { parent } = fn;
|
|
517
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer &&
|
|
518
|
+
parent.parent?.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
|
|
519
|
+
parent.parent.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
|
|
520
|
+
return ITEM_RENDER_PROP.test(parent.parent.name.name);
|
|
521
|
+
}
|
|
522
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.Property &&
|
|
523
|
+
parent.value === fn &&
|
|
524
|
+
parent.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
525
|
+
return ITEM_RENDER_PROP.test(parent.key.name);
|
|
526
|
+
}
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
function isInsideIteration(node) {
|
|
530
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
531
|
+
if (!isFunctionNode(current))
|
|
532
|
+
continue;
|
|
533
|
+
const { parent } = current;
|
|
534
|
+
if (parent?.type === utils_1.AST_NODE_TYPES.CallExpression &&
|
|
535
|
+
parent.arguments.some((argument) => argument === current) &&
|
|
536
|
+
isIterationCallee(parent.callee)) {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
if (isItemRenderProp(current))
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
70
544
|
exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
71
545
|
name: 'no-compositing-layer-props',
|
|
72
546
|
meta: {
|
|
@@ -75,13 +549,35 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
75
549
|
description: 'Discourage CSS properties that force GPU compositing layers (e.g., transform, filter, will-change). Extra layers consume GPU memory and split rendering work, which slows scrolling and animation when sprinkled across a page. The rule inspects inline style objects and MUI sx props so layer promotion stays intentional rather than incidental.',
|
|
76
550
|
recommended: 'error',
|
|
77
551
|
},
|
|
78
|
-
schema: [
|
|
552
|
+
schema: [
|
|
553
|
+
{
|
|
554
|
+
type: 'object',
|
|
555
|
+
properties: {
|
|
556
|
+
exemptInteractionStates: {
|
|
557
|
+
type: 'boolean',
|
|
558
|
+
default: true,
|
|
559
|
+
},
|
|
560
|
+
exemptStateTransitions: {
|
|
561
|
+
type: 'boolean',
|
|
562
|
+
default: true,
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
additionalProperties: false,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
79
568
|
messages: {
|
|
80
|
-
compositingLayer:
|
|
569
|
+
compositingLayer: "CSS property \"{{property}}\" promotes this element to its own GPU compositing layer. On a component repeated in a list or scroll view, one layer per row exhausts GPU memory and tears during scroll. Animate transform/opacity under an interaction state ('&:hover', '&:active', '&:focus-visible'), or drive a state-dependent value with a 'transition'; both are exempt. Otherwise remove it, or keep it with an eslint-disable-next-line comment giving the reason.",
|
|
81
570
|
},
|
|
82
571
|
},
|
|
83
|
-
defaultOptions: [
|
|
84
|
-
|
|
572
|
+
defaultOptions: [
|
|
573
|
+
{
|
|
574
|
+
exemptInteractionStates: true,
|
|
575
|
+
exemptStateTransitions: true,
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
create(context, [options]) {
|
|
579
|
+
const exemptInteractionStates = options.exemptInteractionStates !== false;
|
|
580
|
+
const exemptStateTransitions = options.exemptStateTransitions !== false;
|
|
85
581
|
const seenNodes = new WeakSet();
|
|
86
582
|
// The value-driven arm exists to catch a layer-promoting value written under
|
|
87
583
|
// a property spelling COMPOSITING_PROPERTIES misses (a vendor prefix). It
|
|
@@ -90,15 +586,7 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
90
586
|
// half is never a CSS property being reported, so it is never reported.
|
|
91
587
|
function checkPropertyValue(normalizedName, value) {
|
|
92
588
|
return (TRANSFORM_VALUE_PROPERTIES.has(stripVendorPrefix(normalizedName)) &&
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
// Strip `!important` and normalize casing so reset/identity values written
|
|
96
|
-
// as e.g. `none !important` are still recognized as non-promoting.
|
|
97
|
-
function normalizePropertyValue(value) {
|
|
98
|
-
return value
|
|
99
|
-
.replace(/\s*!important\s*$/i, '')
|
|
100
|
-
.trim()
|
|
101
|
-
.toLowerCase();
|
|
589
|
+
THREE_D_TRANSFORM_FUNCTION.test(value));
|
|
102
590
|
}
|
|
103
591
|
function checkProperty(propertyName, propertyValue) {
|
|
104
592
|
const normalizedName = normalizePropertyName(propertyName);
|
|
@@ -127,6 +615,63 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
127
615
|
}
|
|
128
616
|
return false;
|
|
129
617
|
}
|
|
618
|
+
// A responsive object or array holds one value per breakpoint, not per
|
|
619
|
+
// state, so it is state-driven only when no entry promotes a layer at rest.
|
|
620
|
+
function isStateDrivenValue(normalizedName, value) {
|
|
621
|
+
switch (value.type) {
|
|
622
|
+
case utils_1.AST_NODE_TYPES.TSAsExpression:
|
|
623
|
+
case utils_1.AST_NODE_TYPES.TSSatisfiesExpression:
|
|
624
|
+
case utils_1.AST_NODE_TYPES.TSNonNullExpression:
|
|
625
|
+
case utils_1.AST_NODE_TYPES.TSTypeAssertion:
|
|
626
|
+
return isStateDrivenValue(normalizedName, value.expression);
|
|
627
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
628
|
+
return value.properties.every((entry) => entry.type !== utils_1.AST_NODE_TYPES.Property ||
|
|
629
|
+
isStateDrivenValue(normalizedName, entry.value));
|
|
630
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
631
|
+
return value.elements.every((entry) => !entry ||
|
|
632
|
+
entry.type === utils_1.AST_NODE_TYPES.SpreadElement ||
|
|
633
|
+
isStateDrivenValue(normalizedName, entry));
|
|
634
|
+
default: {
|
|
635
|
+
const literal = readStaticText(value);
|
|
636
|
+
return literal === null || !checkProperty(normalizedName, literal);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// A 3D transform promotes a layer whatever state the element is in.
|
|
641
|
+
function writesThreeDTransform(normalizedName, value, literal) {
|
|
642
|
+
if (collectStaticFragments(value).some((fragment) => THREE_D_TRANSFORM_FUNCTION.test(fragment))) {
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
return literal !== null && isThreeDLiteral(normalizedName, literal);
|
|
646
|
+
}
|
|
647
|
+
// Both arms are list-safe by construction. A singular interaction state
|
|
648
|
+
// matches one element per pointer or focus however many siblings exist,
|
|
649
|
+
// and a CSS transition never runs on first paint, so a state-driven value
|
|
650
|
+
// promotes only the element whose state flipped, and only while it
|
|
651
|
+
// animates. The second arm is withheld inside lexical iteration, where
|
|
652
|
+
// many siblings can be "on" at once.
|
|
653
|
+
function isExempt(node, normalizedName, literal) {
|
|
654
|
+
if (!EXEMPTABLE_PROPERTIES.has(normalizedName))
|
|
655
|
+
return false;
|
|
656
|
+
if (!exemptInteractionStates && !exemptStateTransitions)
|
|
657
|
+
return false;
|
|
658
|
+
if (writesThreeDTransform(normalizedName, node.value, literal)) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
const chain = climbStyleChain(node);
|
|
662
|
+
if (exemptInteractionStates &&
|
|
663
|
+
everyMemberQualifies(chain, carriesInteraction)) {
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
if (!exemptStateTransitions)
|
|
667
|
+
return false;
|
|
668
|
+
const isStateDriven = (literal === null && isStateDrivenValue(normalizedName, node.value)) ||
|
|
669
|
+
chain.isConditional ||
|
|
670
|
+
everyMemberQualifies(chain, carriesState);
|
|
671
|
+
return (isStateDriven &&
|
|
672
|
+
hasCoveringTransition(chain, normalizedName) &&
|
|
673
|
+
!isInsideIteration(node));
|
|
674
|
+
}
|
|
130
675
|
// A property key names a `@keyframes` at-rule when it is a string literal
|
|
131
676
|
// (`'@keyframes spin'`) or a template literal whose leading text starts the
|
|
132
677
|
// at-rule (`` [`@keyframes ${name}`] `` for a dynamically-named animation).
|
|
@@ -204,7 +749,6 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
204
749
|
if (isInKeyframes(node))
|
|
205
750
|
return;
|
|
206
751
|
let propertyName = '';
|
|
207
|
-
let propertyValue = '';
|
|
208
752
|
// Get property name
|
|
209
753
|
if (node.key.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
210
754
|
propertyName = node.key.name;
|
|
@@ -212,19 +756,18 @@ exports.noCompositingLayerProps = (0, createRule_1.createRule)({
|
|
|
212
756
|
else if (node.key.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
213
757
|
propertyName = String(node.key.value);
|
|
214
758
|
}
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
759
|
+
const literal = readStaticText(node.value);
|
|
760
|
+
if (!checkProperty(propertyName, literal ?? ''))
|
|
761
|
+
return;
|
|
762
|
+
if (isExempt(node, normalizePropertyName(propertyName), literal))
|
|
763
|
+
return;
|
|
764
|
+
context.report({
|
|
765
|
+
node,
|
|
766
|
+
messageId: 'compositingLayer',
|
|
767
|
+
data: {
|
|
768
|
+
property: propertyName,
|
|
769
|
+
},
|
|
770
|
+
});
|
|
228
771
|
}
|
|
229
772
|
return {
|
|
230
773
|
// Handle object literal properties (inline styles)
|