@blumintinc/eslint-plugin-blumint 1.21.17 → 1.22.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/lib/index.js +1 -1
- package/lib/rules/enforce-empty-object-check.d.ts +14 -0
- package/lib/rules/enforce-empty-object-check.js +230 -7
- 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/prevent-children-clobber.js +11 -1
- package/package.json +1 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { TSESLint } from '@typescript-eslint/utils';
|
|
2
|
+
/**
|
|
3
|
+
* The emptiness call the fixer emits, and where its binding comes from.
|
|
4
|
+
*
|
|
5
|
+
* Recognition and EMISSION are separate settings on purpose. `emptyCheckFunctions`
|
|
6
|
+
* is an allowlist of spellings the rule accepts and carries no import path, so
|
|
7
|
+
* reusing it to emit would write a bare call to an identifier the file does not
|
|
8
|
+
* import, and — since it defaults to `isEmpty` — would silently change what every
|
|
9
|
+
* existing consumer's `--fix` produces (#2360).
|
|
10
|
+
*/
|
|
11
|
+
type EmptyCheckFix = {
|
|
12
|
+
name: string;
|
|
13
|
+
importPath?: string;
|
|
14
|
+
};
|
|
2
15
|
type Options = [
|
|
3
16
|
{
|
|
4
17
|
objectNamePattern?: string[];
|
|
5
18
|
ignoreInLoops?: boolean;
|
|
6
19
|
emptyCheckFunctions?: string[];
|
|
20
|
+
emptyCheckFix?: EmptyCheckFix;
|
|
7
21
|
printWidth?: number;
|
|
8
22
|
}
|
|
9
23
|
];
|
|
@@ -28,6 +28,7 @@ const utils_1 = require("@typescript-eslint/utils");
|
|
|
28
28
|
const ts = __importStar(require("typescript"));
|
|
29
29
|
const createRule_1 = require("../utils/createRule");
|
|
30
30
|
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
31
|
+
const importInsertion_1 = require("../utils/importInsertion");
|
|
31
32
|
const DEFAULT_OBJECT_SUFFIXES = [
|
|
32
33
|
'Config',
|
|
33
34
|
'Configs',
|
|
@@ -79,6 +80,20 @@ const NON_OBJECT_LIKE_NAMES = [
|
|
|
79
80
|
'array',
|
|
80
81
|
'arr',
|
|
81
82
|
];
|
|
83
|
+
/**
|
|
84
|
+
* The emptiness operand the fixer writes beside the falsiness check.
|
|
85
|
+
*
|
|
86
|
+
* `Object.keys(...).length === 0` needs nothing in scope, so it stays the
|
|
87
|
+
* default and every consumer that has not opted in keeps byte-identical output.
|
|
88
|
+
* A consumer whose config BANS the `Object` accessors names a helper instead,
|
|
89
|
+
* without which `--fix` has no fixed point: the rewrite it just wrote is
|
|
90
|
+
* rejected by the same run that produced it (#2360).
|
|
91
|
+
*/
|
|
92
|
+
function buildEmptyCheckText(identifierText, emptyCheckFix) {
|
|
93
|
+
return emptyCheckFix
|
|
94
|
+
? `${emptyCheckFix.name}(${identifierText})`
|
|
95
|
+
: `Object.keys(${identifierText}).length === 0`;
|
|
96
|
+
}
|
|
82
97
|
function hasBooleanPrefixBoundary(name) {
|
|
83
98
|
const lower = name.toLowerCase();
|
|
84
99
|
return BOOLEAN_PREFIXES.some((prefix) => {
|
|
@@ -1163,7 +1178,7 @@ function getDeclarationStatement(declaration) {
|
|
|
1163
1178
|
* width was measured (#2095).
|
|
1164
1179
|
*/
|
|
1165
1180
|
function planWidenedFix(input) {
|
|
1166
|
-
const { sourceCode, target, identifierText, needsParentheses } = input;
|
|
1181
|
+
const { sourceCode, target, identifierText, emptyCheckText, needsParentheses, } = input;
|
|
1167
1182
|
const source = sourceCode.getText();
|
|
1168
1183
|
const root = getLayoutRoot(target);
|
|
1169
1184
|
const parent = root.parent;
|
|
@@ -1175,7 +1190,7 @@ function planWidenedFix(input) {
|
|
|
1175
1190
|
operator: '||',
|
|
1176
1191
|
parts: [
|
|
1177
1192
|
{ kind: 'leaf', text: `${target.operator}${identifierText}` },
|
|
1178
|
-
{ kind: 'leaf', text:
|
|
1193
|
+
{ kind: 'leaf', text: emptyCheckText },
|
|
1179
1194
|
],
|
|
1180
1195
|
};
|
|
1181
1196
|
const docContext = {
|
|
@@ -1514,6 +1529,150 @@ function planReturnFix(root, parent, flat, doc, { sourceCode, printWidth }, sour
|
|
|
1514
1529
|
}
|
|
1515
1530
|
return { range: [root.range[0], root.range[1]], text: lines.join('\n') };
|
|
1516
1531
|
}
|
|
1532
|
+
/** Every name a binding pattern introduces. */
|
|
1533
|
+
function collectPatternNames(pattern, names) {
|
|
1534
|
+
switch (pattern.type) {
|
|
1535
|
+
case utils_1.AST_NODE_TYPES.Identifier:
|
|
1536
|
+
names.add(pattern.name);
|
|
1537
|
+
return;
|
|
1538
|
+
case utils_1.AST_NODE_TYPES.ObjectPattern:
|
|
1539
|
+
for (const property of pattern.properties) {
|
|
1540
|
+
collectPatternNames(property.type === utils_1.AST_NODE_TYPES.RestElement
|
|
1541
|
+
? property.argument
|
|
1542
|
+
: property.value, names);
|
|
1543
|
+
}
|
|
1544
|
+
return;
|
|
1545
|
+
case utils_1.AST_NODE_TYPES.ArrayPattern:
|
|
1546
|
+
for (const element of pattern.elements) {
|
|
1547
|
+
if (element) {
|
|
1548
|
+
collectPatternNames(element, names);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
return;
|
|
1552
|
+
case utils_1.AST_NODE_TYPES.AssignmentPattern:
|
|
1553
|
+
collectPatternNames(pattern.left, names);
|
|
1554
|
+
return;
|
|
1555
|
+
case utils_1.AST_NODE_TYPES.RestElement:
|
|
1556
|
+
collectPatternNames(pattern.argument, names);
|
|
1557
|
+
return;
|
|
1558
|
+
default:
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The names already declared at the module's top level.
|
|
1563
|
+
*
|
|
1564
|
+
* An import is only safe to insert when the name is free: a second binding of a
|
|
1565
|
+
* name the file already declares — imported from anywhere, or declared locally —
|
|
1566
|
+
* is a redeclaration error, which is a worse outcome than the lint warning the
|
|
1567
|
+
* insertion was meant to avoid.
|
|
1568
|
+
*/
|
|
1569
|
+
function moduleBindingNames(program) {
|
|
1570
|
+
const names = new Set();
|
|
1571
|
+
for (const statement of program.body) {
|
|
1572
|
+
const node = statement.type === utils_1.AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
1573
|
+
statement.type === utils_1.AST_NODE_TYPES.ExportDefaultDeclaration
|
|
1574
|
+
? statement.declaration
|
|
1575
|
+
: statement;
|
|
1576
|
+
if (!node) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
if (node.type === utils_1.AST_NODE_TYPES.ImportDeclaration) {
|
|
1580
|
+
for (const specifier of node.specifiers) {
|
|
1581
|
+
names.add(specifier.local.name);
|
|
1582
|
+
}
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (node.type === utils_1.AST_NODE_TYPES.VariableDeclaration) {
|
|
1586
|
+
for (const declarator of node.declarations) {
|
|
1587
|
+
collectPatternNames(declarator.id, names);
|
|
1588
|
+
}
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
if ((node.type === utils_1.AST_NODE_TYPES.FunctionDeclaration ||
|
|
1592
|
+
node.type === utils_1.AST_NODE_TYPES.ClassDeclaration ||
|
|
1593
|
+
node.type === utils_1.AST_NODE_TYPES.TSEnumDeclaration ||
|
|
1594
|
+
node.type === utils_1.AST_NODE_TYPES.TSInterfaceDeclaration ||
|
|
1595
|
+
node.type === utils_1.AST_NODE_TYPES.TSTypeAliasDeclaration ||
|
|
1596
|
+
node.type === utils_1.AST_NODE_TYPES.TSModuleDeclaration ||
|
|
1597
|
+
node.type === utils_1.AST_NODE_TYPES.TSDeclareFunction ||
|
|
1598
|
+
node.type === utils_1.AST_NODE_TYPES.TSImportEqualsDeclaration) &&
|
|
1599
|
+
node.id?.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
1600
|
+
names.add(node.id.name);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return names;
|
|
1604
|
+
}
|
|
1605
|
+
/** A module specifier written as source text, with its quotes escaped. */
|
|
1606
|
+
function quoteModuleSpecifier(importPath) {
|
|
1607
|
+
return `'${importPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* The edit that brings the emitted helper into scope, or null when nothing is
|
|
1611
|
+
* needed.
|
|
1612
|
+
*
|
|
1613
|
+
* A helper call the file cannot resolve trades a lint warning for an undefined
|
|
1614
|
+
* identifier, so emission and import travel together in ONE fixer. Four
|
|
1615
|
+
* outcomes are distinguished, because they are four different mistakes:
|
|
1616
|
+
*
|
|
1617
|
+
* * the name is already bound — insert nothing, whatever path it came from,
|
|
1618
|
+
* since a second binding would not compile;
|
|
1619
|
+
* * a value import from the same path already has a named-specifier list — join
|
|
1620
|
+
* it, so the file keeps one declaration per module;
|
|
1621
|
+
* * a file with imports gains a declaration after the LAST of them, which is
|
|
1622
|
+
* past every prologue by construction;
|
|
1623
|
+
* * a file with none defers to `importInsertion`, whose anchor owns the
|
|
1624
|
+
* placement a raw offset gets wrong: a `'use client'` demoted out of the
|
|
1625
|
+
* prologue, a `#!` pushed off line 1, or a line-binding suppression comment
|
|
1626
|
+
* severed from the line it covers.
|
|
1627
|
+
*/
|
|
1628
|
+
function planImportInsertion(sourceCode, emptyCheckFix) {
|
|
1629
|
+
const { name, importPath } = emptyCheckFix;
|
|
1630
|
+
if (!importPath) {
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
const program = sourceCode.ast;
|
|
1634
|
+
if (moduleBindingNames(program).has(name)) {
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
const imports = program.body.filter((statement) => statement.type === utils_1.AST_NODE_TYPES.ImportDeclaration);
|
|
1638
|
+
for (const declaration of imports) {
|
|
1639
|
+
/** A type-only import carries no value binding for the call to resolve. */
|
|
1640
|
+
if (declaration.importKind === 'type' ||
|
|
1641
|
+
declaration.source.value !== importPath) {
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
const specifiers = declaration.specifiers.filter((specifier) => specifier.type === utils_1.AST_NODE_TYPES.ImportSpecifier);
|
|
1645
|
+
const last = specifiers[specifiers.length - 1];
|
|
1646
|
+
if (!last) {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* A specifier list Prettier has already broken keeps one name per row, so
|
|
1651
|
+
* joining it inline would emit a layout the next format run undoes.
|
|
1652
|
+
*/
|
|
1653
|
+
const indent = declaration.loc.start.line === declaration.loc.end.line
|
|
1654
|
+
? null
|
|
1655
|
+
: getLineIndent(last, sourceCode);
|
|
1656
|
+
const text = indent === null ? `, ${name}` : `,\n${indent}${name}`;
|
|
1657
|
+
return {
|
|
1658
|
+
offset: last.range[1],
|
|
1659
|
+
apply: (fixer) => fixer.insertTextAfter(last, text),
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
const statement = `import { ${name} } from ${quoteModuleSpecifier(importPath)};`;
|
|
1663
|
+
const lastImport = imports[imports.length - 1];
|
|
1664
|
+
if (lastImport) {
|
|
1665
|
+
return {
|
|
1666
|
+
offset: lastImport.range[1],
|
|
1667
|
+
apply: (fixer) => fixer.insertTextAfter(lastImport, `\n${statement}`),
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const anchor = (0, importInsertion_1.importInsertionAnchor)(sourceCode);
|
|
1671
|
+
return {
|
|
1672
|
+
offset: anchor.kind === 'before' ? anchor.target.range[0] : anchor.index,
|
|
1673
|
+
apply: (fixer) => (0, importInsertion_1.insertAtImportAnchor)(sourceCode, fixer, anchor, `${statement}\n`),
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1517
1676
|
exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
1518
1677
|
name: 'enforce-empty-object-check',
|
|
1519
1678
|
meta: {
|
|
@@ -1538,6 +1697,20 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1538
1697
|
type: 'array',
|
|
1539
1698
|
items: { type: 'string' },
|
|
1540
1699
|
},
|
|
1700
|
+
emptyCheckFix: {
|
|
1701
|
+
type: 'object',
|
|
1702
|
+
properties: {
|
|
1703
|
+
/**
|
|
1704
|
+
* A bare identifier, so the emitted call is the shape the
|
|
1705
|
+
* recognition arm accepts — anything else would leave `--fix`
|
|
1706
|
+
* rewriting text it reports again on the next pass.
|
|
1707
|
+
*/
|
|
1708
|
+
name: { type: 'string', pattern: '^[A-Za-z_$][A-Za-z0-9_$]*$' },
|
|
1709
|
+
importPath: { type: 'string', minLength: 1 },
|
|
1710
|
+
},
|
|
1711
|
+
required: ['name'],
|
|
1712
|
+
additionalProperties: false,
|
|
1713
|
+
},
|
|
1541
1714
|
printWidth: {
|
|
1542
1715
|
type: 'number',
|
|
1543
1716
|
minimum: 1,
|
|
@@ -1556,7 +1729,7 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1556
1729
|
const parserServices = sourceCode.parserServices;
|
|
1557
1730
|
const checker = parserServices?.program?.getTypeChecker();
|
|
1558
1731
|
const options = context.options[0] ?? {};
|
|
1559
|
-
const { objectNamePattern = [], ignoreInLoops = false, emptyCheckFunctions = [], } = options;
|
|
1732
|
+
const { objectNamePattern = [], ignoreInLoops = false, emptyCheckFunctions = [], emptyCheckFix, } = options;
|
|
1560
1733
|
const printWidth = typeof options.printWidth === 'number' && options.printWidth > 0
|
|
1561
1734
|
? options.printWidth
|
|
1562
1735
|
: DEFAULT_PRINT_WIDTH;
|
|
@@ -1564,9 +1737,16 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1564
1737
|
...DEFAULT_OBJECT_SUFFIXES,
|
|
1565
1738
|
...objectNamePattern,
|
|
1566
1739
|
]);
|
|
1740
|
+
/**
|
|
1741
|
+
* The emitted helper counts as a satisfying check even when it is absent
|
|
1742
|
+
* from `emptyCheckFunctions`. Without this the fixer would report its own
|
|
1743
|
+
* output, so `--fix` would rewrite the same guard on every pass — the
|
|
1744
|
+
* fixed point this option exists to restore (#2360).
|
|
1745
|
+
*/
|
|
1567
1746
|
const emptyCheckFunctionsSet = new Set([
|
|
1568
1747
|
...DEFAULT_EMPTY_CHECK_FUNCTIONS,
|
|
1569
1748
|
...emptyCheckFunctions,
|
|
1749
|
+
...(emptyCheckFix ? [emptyCheckFix.name] : []),
|
|
1570
1750
|
]);
|
|
1571
1751
|
const processedExpressions = new WeakSet();
|
|
1572
1752
|
const processedNegations = new WeakSet();
|
|
@@ -1883,6 +2063,31 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1883
2063
|
}
|
|
1884
2064
|
return isObjectLikeName(identifier.name, patternSet);
|
|
1885
2065
|
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Whether a binding between the report site and the module scope would
|
|
2068
|
+
* capture the emitted helper call.
|
|
2069
|
+
*
|
|
2070
|
+
* A bare call resolves wherever the CALL sits, so an inner binding of the
|
|
2071
|
+
* same name silently takes it over — the rewrite compiles, reads
|
|
2072
|
+
* correctly, and calls something else entirely (#1455, #1456 are this
|
|
2073
|
+
* shape). No spelling of the call escapes that, so the fix is declined
|
|
2074
|
+
* and the report stands on its own; the message already says what to
|
|
2075
|
+
* write.
|
|
2076
|
+
*
|
|
2077
|
+
* The unconfigured emission is untouched by this: `Object` is a global
|
|
2078
|
+
* rather than a module binding, and its output is a compatibility
|
|
2079
|
+
* contract (#2360).
|
|
2080
|
+
*/
|
|
2081
|
+
function emissionIsShadowed(node, name) {
|
|
2082
|
+
let scope = ASTHelpers_1.ASTHelpers.getScope(context, node);
|
|
2083
|
+
while (scope) {
|
|
2084
|
+
if (scope.variables.some((variable) => variable.name === name)) {
|
|
2085
|
+
return scope.type !== 'module' && scope.type !== 'global';
|
|
2086
|
+
}
|
|
2087
|
+
scope = scope.upper;
|
|
2088
|
+
}
|
|
2089
|
+
return false;
|
|
2090
|
+
}
|
|
1886
2091
|
function reportNegation(node, identifier) {
|
|
1887
2092
|
if (processedNegations.has(node)) {
|
|
1888
2093
|
return;
|
|
@@ -1906,8 +2111,12 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1906
2111
|
name: identifier.name,
|
|
1907
2112
|
},
|
|
1908
2113
|
fix(fixer) {
|
|
2114
|
+
if (emptyCheckFix && emissionIsShadowed(node, emptyCheckFix.name)) {
|
|
2115
|
+
return null;
|
|
2116
|
+
}
|
|
1909
2117
|
const identifierText = sourceCode.getText(identifier);
|
|
1910
|
-
const
|
|
2118
|
+
const emptyCheckText = buildEmptyCheckText(identifierText, emptyCheckFix);
|
|
2119
|
+
const guard = `${node.operator}${identifierText} || ${emptyCheckText}`;
|
|
1911
2120
|
const needsParentheses = replacementNeedsParentheses(node, sourceCode);
|
|
1912
2121
|
const replacement = needsParentheses ? `(${guard})` : guard;
|
|
1913
2122
|
/**
|
|
@@ -1923,13 +2132,27 @@ exports.enforceEmptyObjectCheck = (0, createRule_1.createRule)({
|
|
|
1923
2132
|
sourceCode,
|
|
1924
2133
|
target: node,
|
|
1925
2134
|
identifierText,
|
|
2135
|
+
emptyCheckText,
|
|
1926
2136
|
needsParentheses,
|
|
1927
2137
|
printWidth,
|
|
1928
2138
|
});
|
|
1929
|
-
|
|
1930
|
-
|
|
2139
|
+
const guardEdit = widened ?? {
|
|
2140
|
+
range: node.range,
|
|
2141
|
+
text: replacement,
|
|
2142
|
+
};
|
|
2143
|
+
const importFix = emptyCheckFix
|
|
2144
|
+
? planImportInsertion(sourceCode, emptyCheckFix)
|
|
2145
|
+
: null;
|
|
2146
|
+
if (!importFix) {
|
|
2147
|
+
return fixer.replaceTextRange(guardEdit.range, guardEdit.text);
|
|
1931
2148
|
}
|
|
1932
|
-
|
|
2149
|
+
const guardFix = {
|
|
2150
|
+
offset: guardEdit.range[0],
|
|
2151
|
+
apply: (target) => target.replaceTextRange(guardEdit.range, guardEdit.text),
|
|
2152
|
+
};
|
|
2153
|
+
return [importFix, guardFix]
|
|
2154
|
+
.sort((left, right) => left.offset - right.offset)
|
|
2155
|
+
.map((edit) => edit.apply(fixer));
|
|
1933
2156
|
},
|
|
1934
2157
|
});
|
|
1935
2158
|
}
|
|
@@ -1 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
type Options = [
|
|
2
|
+
{
|
|
3
|
+
exemptInteractionStates?: boolean;
|
|
4
|
+
exemptStateTransitions?: boolean;
|
|
5
|
+
}
|
|
6
|
+
];
|
|
7
|
+
export declare const noCompositingLayerProps: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"compositingLayer", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
8
|
+
export {};
|
|
@@ -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)
|
|
@@ -1 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
type Options = [
|
|
2
|
+
{
|
|
3
|
+
exemptZero?: boolean;
|
|
4
|
+
exemptAuto?: boolean;
|
|
5
|
+
exemptNegative?: boolean;
|
|
6
|
+
}
|
|
7
|
+
];
|
|
8
|
+
export declare const noMarginProperties: import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleModule<"noMarginProperties", Options, import("@typescript-eslint/utils/dist/ts-eslint/Rule").RuleListener>;
|
|
9
|
+
export {};
|
|
@@ -65,6 +65,110 @@ const MARGIN_PROPERTIES = new Set([
|
|
|
65
65
|
'ml',
|
|
66
66
|
'm',
|
|
67
67
|
]);
|
|
68
|
+
const ZERO_LENGTH = /^[+-]?(?:0+\.?0*|\.0+)(?:[a-z]+|%)?$/;
|
|
69
|
+
const NEGATIVE_LENGTH = /^-(?:\d+\.?\d*|\.\d+)(?:[a-z]+|%)?$/;
|
|
70
|
+
function classifyMarginToken(token) {
|
|
71
|
+
if (token === 'auto')
|
|
72
|
+
return 'auto';
|
|
73
|
+
if (ZERO_LENGTH.test(token))
|
|
74
|
+
return 'zero';
|
|
75
|
+
if (NEGATIVE_LENGTH.test(token))
|
|
76
|
+
return 'negative';
|
|
77
|
+
return 'spacing';
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* A string value may be a 1-4 token `margin` shorthand (`'0 auto'`), so every
|
|
81
|
+
* token is classified; `!important` changes the cascade, not the value.
|
|
82
|
+
*/
|
|
83
|
+
function classifyMarginString(value) {
|
|
84
|
+
const tokens = value
|
|
85
|
+
.replace(/\s*!important\s*$/i, '')
|
|
86
|
+
.trim()
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.split(/\s+/)
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
return tokens.length === 0 ? ['spacing'] : tokens.map(classifyMarginToken);
|
|
91
|
+
}
|
|
92
|
+
function classifyMarginNumber(value) {
|
|
93
|
+
if (value === 0)
|
|
94
|
+
return 'zero';
|
|
95
|
+
return value < 0 ? 'negative' : 'spacing';
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every kind a margin value can take, or `null` when any part of it is
|
|
99
|
+
* unreadable (a variable, a call, a template substitution). A conditional and
|
|
100
|
+
* MUI's responsive object and array forms yield the kinds of all their
|
|
101
|
+
* branches, since any one of them may render.
|
|
102
|
+
*/
|
|
103
|
+
function classifyMarginValue(node) {
|
|
104
|
+
const value = unwrapAssertions(node);
|
|
105
|
+
switch (value.type) {
|
|
106
|
+
case utils_1.AST_NODE_TYPES.Literal:
|
|
107
|
+
if (typeof value.value === 'number') {
|
|
108
|
+
return [classifyMarginNumber(value.value)];
|
|
109
|
+
}
|
|
110
|
+
return typeof value.value === 'string'
|
|
111
|
+
? classifyMarginString(value.value)
|
|
112
|
+
: null;
|
|
113
|
+
case utils_1.AST_NODE_TYPES.UnaryExpression:
|
|
114
|
+
if ((value.operator === '-' || value.operator === '+') &&
|
|
115
|
+
value.argument.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
116
|
+
typeof value.argument.value === 'number') {
|
|
117
|
+
return [
|
|
118
|
+
classifyMarginNumber(value.operator === '-'
|
|
119
|
+
? -value.argument.value
|
|
120
|
+
: value.argument.value),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
case utils_1.AST_NODE_TYPES.TemplateLiteral:
|
|
125
|
+
return value.expressions.length === 0
|
|
126
|
+
? classifyMarginString(value.quasis[0].value.cooked ?? value.quasis[0].value.raw)
|
|
127
|
+
: null;
|
|
128
|
+
case utils_1.AST_NODE_TYPES.JSXExpressionContainer:
|
|
129
|
+
return value.expression.type === utils_1.AST_NODE_TYPES.JSXEmptyExpression
|
|
130
|
+
? null
|
|
131
|
+
: classifyMarginValue(value.expression);
|
|
132
|
+
case utils_1.AST_NODE_TYPES.ConditionalExpression:
|
|
133
|
+
return combineMarginKinds([value.consequent, value.alternate]);
|
|
134
|
+
case utils_1.AST_NODE_TYPES.ObjectExpression:
|
|
135
|
+
return combineMarginKinds(value.properties.map((entry) => entry.type === utils_1.AST_NODE_TYPES.Property && !entry.computed
|
|
136
|
+
? entry.value
|
|
137
|
+
: null));
|
|
138
|
+
case utils_1.AST_NODE_TYPES.ArrayExpression:
|
|
139
|
+
return combineMarginKinds(value.elements.filter((element) => element !== null));
|
|
140
|
+
default:
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* MUI's responsive array and object forms read a `null` entry as "skip this
|
|
146
|
+
* breakpoint" (`[null, 0]` sets a margin from `sm` up only), and `sx` drops a
|
|
147
|
+
* `null` value outright, so a `null` part renders no margin at all.
|
|
148
|
+
*/
|
|
149
|
+
function isNullLiteral(node) {
|
|
150
|
+
const value = unwrapAssertions(node);
|
|
151
|
+
return value.type === utils_1.AST_NODE_TYPES.Literal && value.raw === 'null';
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The kinds of every part of a composite value. A `null` part adds no kind,
|
|
155
|
+
* and a composite left with none (`[]`, `{}`, `[null, null]`) sets no margin
|
|
156
|
+
* that could earn an exemption, so it keeps the report like an unreadable one.
|
|
157
|
+
*/
|
|
158
|
+
function combineMarginKinds(nodes) {
|
|
159
|
+
const kinds = [];
|
|
160
|
+
for (const node of nodes) {
|
|
161
|
+
if (!node || node.type === utils_1.AST_NODE_TYPES.SpreadElement)
|
|
162
|
+
return null;
|
|
163
|
+
if (isNullLiteral(node))
|
|
164
|
+
continue;
|
|
165
|
+
const nodeKinds = classifyMarginValue(node);
|
|
166
|
+
if (!nodeKinds)
|
|
167
|
+
return null;
|
|
168
|
+
kinds.push(...nodeKinds);
|
|
169
|
+
}
|
|
170
|
+
return kinds.length === 0 ? null : kinds;
|
|
171
|
+
}
|
|
68
172
|
exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
69
173
|
name: 'no-margin-properties',
|
|
70
174
|
meta: {
|
|
@@ -73,18 +177,61 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
73
177
|
description: 'Prevent margin properties (margin, marginLeft, marginRight, marginTop, marginBottom, mx, my, etc.) in MUI styling because margins fight container-controlled spacing, double gutters, and misaligned breakpoints; keep spacing centralized with padding, gap, or spacing props instead.',
|
|
74
178
|
recommended: 'error',
|
|
75
179
|
},
|
|
76
|
-
schema: [
|
|
180
|
+
schema: [
|
|
181
|
+
{
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
exemptZero: {
|
|
185
|
+
type: 'boolean',
|
|
186
|
+
default: true,
|
|
187
|
+
},
|
|
188
|
+
exemptAuto: {
|
|
189
|
+
type: 'boolean',
|
|
190
|
+
default: true,
|
|
191
|
+
},
|
|
192
|
+
exemptNegative: {
|
|
193
|
+
type: 'boolean',
|
|
194
|
+
default: true,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
additionalProperties: false,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
77
200
|
messages: {
|
|
78
201
|
noMarginProperties: 'Margin property "{{property}}" in MUI styling fights container-controlled spacing (Stack/Grid spacing, gap, responsive gutters) and produces double gutters, misalignment, and overflow as layouts shift. Keep spacing inside the component with padding or let the parent handle separation via gap/spacing so layout remains predictable.',
|
|
79
202
|
},
|
|
80
203
|
},
|
|
81
|
-
defaultOptions: [
|
|
82
|
-
|
|
204
|
+
defaultOptions: [
|
|
205
|
+
{
|
|
206
|
+
exemptZero: true,
|
|
207
|
+
exemptAuto: true,
|
|
208
|
+
exemptNegative: true,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
create(context, [options]) {
|
|
83
212
|
const seenNodes = new WeakSet();
|
|
213
|
+
const exemptKinds = new Set();
|
|
214
|
+
if (options.exemptZero !== false)
|
|
215
|
+
exemptKinds.add('zero');
|
|
216
|
+
if (options.exemptAuto !== false)
|
|
217
|
+
exemptKinds.add('auto');
|
|
218
|
+
if (options.exemptNegative !== false)
|
|
219
|
+
exemptKinds.add('negative');
|
|
84
220
|
function checkProperty(propertyName) {
|
|
85
221
|
const normalizedName = normalizePropertyName(propertyName);
|
|
86
222
|
return MARGIN_PROPERTIES.has(normalizedName);
|
|
87
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* A value is exempt only when every part of it is a reset, `auto` or a
|
|
226
|
+
* negative offset whose exemption is on: one spacing token (`'0 8px'`) or
|
|
227
|
+
* one unreadable branch keeps the report.
|
|
228
|
+
*/
|
|
229
|
+
function isExemptValue(value) {
|
|
230
|
+
if (!value)
|
|
231
|
+
return false;
|
|
232
|
+
const kinds = classifyMarginValue(value);
|
|
233
|
+
return !!kinds && kinds.every((kind) => exemptKinds.has(kind));
|
|
234
|
+
}
|
|
88
235
|
/**
|
|
89
236
|
* True if node is in a flagged MUI styling context (sx attribute, sx
|
|
90
237
|
* object property, or css() call). Excludes createTheme styleOverrides:
|
|
@@ -159,7 +306,9 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
159
306
|
.join('');
|
|
160
307
|
propertyName = quasis + expressions;
|
|
161
308
|
}
|
|
162
|
-
if (propertyName &&
|
|
309
|
+
if (propertyName &&
|
|
310
|
+
checkProperty(propertyName) &&
|
|
311
|
+
!isExemptValue(node.value)) {
|
|
163
312
|
// Check if in MUI styling context
|
|
164
313
|
if (isMuiStylingContext(node)) {
|
|
165
314
|
context.report({
|
|
@@ -283,7 +432,9 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
283
432
|
else if (key.type === utils_1.AST_NODE_TYPES.Literal) {
|
|
284
433
|
propertyName = String(key.value);
|
|
285
434
|
}
|
|
286
|
-
if (propertyName &&
|
|
435
|
+
if (propertyName &&
|
|
436
|
+
checkProperty(propertyName) &&
|
|
437
|
+
!isExemptValue(prop.value)) {
|
|
287
438
|
// Check if this variable is used in an sx prop
|
|
288
439
|
if (sourceText.includes(`sx={${variableName}}`) ||
|
|
289
440
|
sourceText.includes(`sx={{ ...${variableName}`) ||
|
|
@@ -307,7 +458,7 @@ exports.noMarginProperties = (0, createRule_1.createRule)({
|
|
|
307
458
|
if (attr.type === utils_1.AST_NODE_TYPES.JSXAttribute &&
|
|
308
459
|
attr.name.type === utils_1.AST_NODE_TYPES.JSXIdentifier) {
|
|
309
460
|
const attrName = attr.name.name;
|
|
310
|
-
if (checkProperty(attrName)) {
|
|
461
|
+
if (checkProperty(attrName) && !isExemptValue(attr.value)) {
|
|
311
462
|
context.report({
|
|
312
463
|
node: attr,
|
|
313
464
|
messageId: 'noMarginProperties',
|
|
@@ -352,7 +352,17 @@ function typeNodeExcludesProperty(node, propertyName, resolveAlias, seen = new S
|
|
|
352
352
|
if (node.typeParameters?.params) {
|
|
353
353
|
const argumentsAreTheType = closedLiteralCounts &&
|
|
354
354
|
PROPERTY_PRESERVING_WRAPPERS.has(wrapperNameOf(node.typeName) ?? '');
|
|
355
|
-
|
|
355
|
+
if (node.typeParameters.params.some((param) => typeNodeExcludesProperty(param, propertyName, resolveAlias, seen, argumentsAreTheType))) {
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
// Arguments that prove nothing are not proof that the referenced alias
|
|
359
|
+
// proves nothing, so a reference carrying them still falls through to its
|
|
360
|
+
// own body. Returning here made `type P<T> = Omit<X, 'children'>` report
|
|
361
|
+
// when referenced as `P<string>` while the byte-identical unparameterized
|
|
362
|
+
// spelling passed. Type arguments are deliberately NOT substituted into
|
|
363
|
+
// the body: an unbound parameter there proves nothing, which is the
|
|
364
|
+
// conservative answer, and an undeclared wrapper still resolves to
|
|
365
|
+
// nothing and still reports.
|
|
356
366
|
}
|
|
357
367
|
// Keyed by position as well as by name: the same alias proves different
|
|
358
368
|
// things in a props position than inside an arbitrary generic, so a visit
|
package/package.json
CHANGED
package/release-manifest.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.22.0",
|
|
4
|
+
"date": "2026-09-27T06:08:08.588Z",
|
|
5
|
+
"rules": [
|
|
6
|
+
{
|
|
7
|
+
"name": "no-compositing-layer-props",
|
|
8
|
+
"changeType": "feat",
|
|
9
|
+
"issues": [
|
|
10
|
+
2383
|
|
11
|
+
],
|
|
12
|
+
"summary": "exempt interaction-state and transition-toggled transform/opacity"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "no-margin-properties",
|
|
16
|
+
"changeType": "feat",
|
|
17
|
+
"issues": [
|
|
18
|
+
1593
|
|
19
|
+
],
|
|
20
|
+
"summary": "skip null breakpoints in responsive margin values; exempt resets, auto and negative offsets"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "1.21.18",
|
|
26
|
+
"date": "2026-09-07T22:15:12.507Z",
|
|
27
|
+
"rules": [
|
|
28
|
+
{
|
|
29
|
+
"name": "enforce-empty-object-check",
|
|
30
|
+
"changeType": "fix",
|
|
31
|
+
"issues": [
|
|
32
|
+
2360
|
|
33
|
+
],
|
|
34
|
+
"summary": "emit a configurable empty-check helper (closes #2360)"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "prevent-children-clobber",
|
|
38
|
+
"changeType": "fix",
|
|
39
|
+
"issues": [
|
|
40
|
+
2361
|
|
41
|
+
],
|
|
42
|
+
"summary": "resolve a parameterized props alias (closes #2361)"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "1.21.17",
|
|
4
48
|
"date": "2026-09-07T12:35:50.668Z",
|