@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.
@@ -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, but only when applied through a
36
- // property whose value grammar accepts a transform function.
37
- const COMPOSITING_TRANSFORM_FUNCTIONS = [
38
- 'translate3d',
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: 'CSS property "{{property}}" promotes this element to its own GPU compositing layer. Extra layers allocate GPU memory and isolate painting, which slows scrolling and animation when used broadly. Remove "{{property}}" or keep it only when the layer promotion is intentional and documented (e.g., eslint-disable with a comment).',
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
- create(context) {
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
- COMPOSITING_TRANSFORM_FUNCTIONS.some((fn) => value.includes(fn)));
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
- // Get property value if it's a string literal or numeric literal
216
- if (node.value.type === utils_1.AST_NODE_TYPES.Literal) {
217
- propertyValue = String(node.value.value);
218
- }
219
- if (checkProperty(propertyName, propertyValue)) {
220
- context.report({
221
- node,
222
- messageId: 'compositingLayer',
223
- data: {
224
- property: propertyName,
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)