@blumintinc/eslint-plugin-blumint 1.20.194 → 1.20.196
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/no-unused-usestate.d.ts +1 -0
- package/lib/rules/no-unused-usestate.js +148 -59
- package/lib/rules/no-useless-fragment.js +235 -27
- package/lib/rules/prefer-fragment-component.js +160 -187
- package/lib/rules/prefer-map-over-conditional-dispatch.js +171 -28
- package/lib/utils/composedFixConfig.d.ts +46 -0
- package/lib/utils/composedFixConfig.js +80 -0
- package/lib/utils/fixtureTypeProgram.d.ts +306 -0
- package/lib/utils/fixtureTypeProgram.js +801 -0
- package/lib/utils/reactFragmentBinding.d.ts +27 -0
- package/lib/utils/reactFragmentBinding.js +74 -0
- package/lib/utils/validCaseFalsifiability.d.ts +65 -7
- package/lib/utils/validCaseFalsifiability.js +111 -8
- package/package.json +2 -1
- package/release-manifest.json +44 -0
package/lib/index.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.noUnusedUseState = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
6
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
5
7
|
const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com/BluMintInc/eslint-custom-rules/blob/main/docs/rules/${name}.md`);
|
|
6
8
|
/**
|
|
7
9
|
* A destructuring binding counts as live when anything other than its own
|
|
@@ -11,6 +13,49 @@ const createRule = utils_1.ESLintUtils.RuleCreator((name) => `https://github.com
|
|
|
11
13
|
const isBindingReferenced = (variable) => {
|
|
12
14
|
return variable.references.some((reference) => !reference.init);
|
|
13
15
|
};
|
|
16
|
+
const isWithinAny = (range, ranges) => ranges.some(([start, end]) => range[0] >= start && range[1] <= end);
|
|
17
|
+
const rangesOverlap = (left, right) => left[0] < right[1] && right[0] < left[1];
|
|
18
|
+
/**
|
|
19
|
+
* The slice a fix deletes to retire `node`, separators included, or `null` when
|
|
20
|
+
* the declaration sits somewhere this rule does not rewrite.
|
|
21
|
+
*
|
|
22
|
+
* The sole declarator of a statement takes the statement with it, up to the next
|
|
23
|
+
* token or comment so the line it occupied does not survive as blank space. One
|
|
24
|
+
* declarator among several takes exactly one separator: the comma after it, plus
|
|
25
|
+
* the whitespace up to the next declarator so no double space is left behind —
|
|
26
|
+
* or the comma before it when it ends the list. A comment stops the removal so
|
|
27
|
+
* it survives the fix.
|
|
28
|
+
*/
|
|
29
|
+
const declarationRemovalRange = (sourceCode, node) => {
|
|
30
|
+
const parentStatement = node.parent;
|
|
31
|
+
if (!parentStatement ||
|
|
32
|
+
parentStatement.type !== utils_1.TSESTree.AST_NODE_TYPES.VariableDeclaration) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (parentStatement.declarations.length === 1) {
|
|
36
|
+
const nextToken = sourceCode.getTokenAfter(parentStatement, {
|
|
37
|
+
includeComments: true,
|
|
38
|
+
});
|
|
39
|
+
return nextToken
|
|
40
|
+
? [parentStatement.range[0], nextToken.range[0]]
|
|
41
|
+
: [parentStatement.range[0], parentStatement.range[1]];
|
|
42
|
+
}
|
|
43
|
+
const tokenAfter = sourceCode.getTokenAfter(node);
|
|
44
|
+
if (tokenAfter && tokenAfter.value === ',') {
|
|
45
|
+
const tokenAfterComma = sourceCode.getTokenAfter(tokenAfter, {
|
|
46
|
+
includeComments: true,
|
|
47
|
+
});
|
|
48
|
+
return [
|
|
49
|
+
node.range[0],
|
|
50
|
+
tokenAfterComma ? tokenAfterComma.range[0] : tokenAfter.range[1],
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
const tokenBefore = sourceCode.getTokenBefore(node);
|
|
54
|
+
if (tokenBefore && tokenBefore.value === ',') {
|
|
55
|
+
return [tokenBefore.range[0], node.range[1]];
|
|
56
|
+
}
|
|
57
|
+
return [node.range[0], node.range[1]];
|
|
58
|
+
};
|
|
14
59
|
/**
|
|
15
60
|
* Rule to detect and remove unused useState hooks in React components
|
|
16
61
|
* This rule identifies cases where the state variable from useState is ignored (e.g., replaced with _)
|
|
@@ -31,6 +76,66 @@ exports.noUnusedUseState = createRule({
|
|
|
31
76
|
},
|
|
32
77
|
defaultOptions: [],
|
|
33
78
|
create(context) {
|
|
79
|
+
const sourceCode = context.sourceCode;
|
|
80
|
+
/**
|
|
81
|
+
* Every discarded pair the rule finds, in traversal order.
|
|
82
|
+
*
|
|
83
|
+
* Reporting waits for `Program:exit` because the `useState` import is left
|
|
84
|
+
* unreferenced only once NO surviving call mentions it. Judged one
|
|
85
|
+
* declaration at a time, a file with two dead pairs never sees either as the
|
|
86
|
+
* import's last use, and the pass that deletes both resolves every report —
|
|
87
|
+
* so nothing ever revisits the stranded import.
|
|
88
|
+
*/
|
|
89
|
+
const violations = [];
|
|
90
|
+
/**
|
|
91
|
+
* A suppressed report is discarded together with its fix, so its removal
|
|
92
|
+
* never happens: counting it toward the batch would unbind an import the
|
|
93
|
+
* surviving text still calls.
|
|
94
|
+
*/
|
|
95
|
+
const isReportSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
96
|
+
/**
|
|
97
|
+
* The extra deletions that keep `removed` from stranding a binding.
|
|
98
|
+
*
|
|
99
|
+
* Deleting a `useState` declaration strands two kinds of binding. The
|
|
100
|
+
* import is unbound by dropping its specifier, which the shared helper
|
|
101
|
+
* plans. The pattern's own `_` and setter are unbound by the very deletion
|
|
102
|
+
* being planned — their declarations sit inside `removed`, so they need
|
|
103
|
+
* nothing further and the unbinder claims them with an empty plan. Anything
|
|
104
|
+
* else the deletion leaves unreferenced (a `const` read only by the
|
|
105
|
+
* discarded initializer) declines the whole fix: leaving the report standing
|
|
106
|
+
* costs less than trading it for an unused-variable error the fixer resolved
|
|
107
|
+
* out of view.
|
|
108
|
+
*/
|
|
109
|
+
const planRemoval = (removed) => (0, importRemoval_1.planOrphanedBindingRemoval)(sourceCode, removed, (variables, ranges) => variables.every((variable) => variable.identifiers.every((identifier) => isWithinAny(identifier.range, ranges)))
|
|
110
|
+
? []
|
|
111
|
+
: null);
|
|
112
|
+
/**
|
|
113
|
+
* The removals that ship, in traversal order.
|
|
114
|
+
*
|
|
115
|
+
* Each is screened alone before joining the batch: a deletion that strands
|
|
116
|
+
* something unbindable would otherwise withhold every other removal in the
|
|
117
|
+
* file. Overlapping deletions are dropped because ESLint rejects a fix whose
|
|
118
|
+
* own edits collide — two dead declarators of one statement overlap on the
|
|
119
|
+
* separator between them, and the later one is deleted on a following pass.
|
|
120
|
+
*/
|
|
121
|
+
const planViolations = () => {
|
|
122
|
+
const planned = [];
|
|
123
|
+
const claimed = [];
|
|
124
|
+
for (const violation of violations) {
|
|
125
|
+
const { removal } = violation;
|
|
126
|
+
if (!removal)
|
|
127
|
+
continue;
|
|
128
|
+
if (isReportSuppressed(violation.node))
|
|
129
|
+
continue;
|
|
130
|
+
if (planRemoval([removal]) === null)
|
|
131
|
+
continue;
|
|
132
|
+
if (claimed.some((taken) => rangesOverlap(removal, taken)))
|
|
133
|
+
continue;
|
|
134
|
+
claimed.push(removal);
|
|
135
|
+
planned.push({ violation, removal });
|
|
136
|
+
}
|
|
137
|
+
return planned;
|
|
138
|
+
};
|
|
34
139
|
return {
|
|
35
140
|
// Look for variable declarations that destructure from useState
|
|
36
141
|
VariableDeclarator(node) {
|
|
@@ -60,72 +165,56 @@ exports.noUnusedUseState = createRule({
|
|
|
60
165
|
// Every other binding of the pattern (the setter, and any nested
|
|
61
166
|
// or rest binding) must be dead before the declaration can be
|
|
62
167
|
// deleted. Removing it while the setter is still called strands
|
|
63
|
-
// the call sites and breaks the component.
|
|
168
|
+
// the call sites and breaks the component. A live setter therefore
|
|
169
|
+
// yields a report without a fix.
|
|
64
170
|
const hasLiveSiblingBinding = declaredVariables.some((variable) => variable !== stateVariable && isBindingReferenced(variable));
|
|
65
|
-
|
|
171
|
+
violations.push({
|
|
66
172
|
node,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
fix: (fixer) => {
|
|
72
|
-
// A live setter still needs its declaration, so report the
|
|
73
|
-
// discarded value without offering a destructive fix.
|
|
74
|
-
if (hasLiveSiblingBinding) {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
// Remove the entire useState declaration
|
|
78
|
-
const sourceCode = context.sourceCode;
|
|
79
|
-
const parentStatement = node.parent;
|
|
80
|
-
if (parentStatement &&
|
|
81
|
-
parentStatement.type ===
|
|
82
|
-
utils_1.TSESTree.AST_NODE_TYPES.VariableDeclaration) {
|
|
83
|
-
// If this is the only declarator, remove the entire statement and any extra whitespace
|
|
84
|
-
if (parentStatement.declarations.length === 1) {
|
|
85
|
-
// Get the next token after the statement to handle whitespace properly
|
|
86
|
-
const nextToken = sourceCode.getTokenAfter(parentStatement, { includeComments: true });
|
|
87
|
-
if (nextToken) {
|
|
88
|
-
// Remove the statement and any whitespace up to the next token
|
|
89
|
-
return fixer.removeRange([
|
|
90
|
-
parentStatement.range[0],
|
|
91
|
-
nextToken.range[0],
|
|
92
|
-
]);
|
|
93
|
-
}
|
|
94
|
-
return fixer.remove(parentStatement);
|
|
95
|
-
}
|
|
96
|
-
// Otherwise, just remove this declarator and any trailing comma
|
|
97
|
-
const declaratorRange = node.range;
|
|
98
|
-
// Check if there's a comma after this declarator
|
|
99
|
-
const tokenAfter = sourceCode.getTokenAfter(node);
|
|
100
|
-
if (tokenAfter && tokenAfter.value === ',') {
|
|
101
|
-
// Consume the separator plus the whitespace before the
|
|
102
|
-
// surviving declarator so no double space is left behind.
|
|
103
|
-
// Comments stop the removal so they survive the fix.
|
|
104
|
-
const tokenAfterComma = sourceCode.getTokenAfter(tokenAfter, { includeComments: true });
|
|
105
|
-
return fixer.removeRange([
|
|
106
|
-
declaratorRange[0],
|
|
107
|
-
tokenAfterComma
|
|
108
|
-
? tokenAfterComma.range[0]
|
|
109
|
-
: tokenAfter.range[1],
|
|
110
|
-
]);
|
|
111
|
-
}
|
|
112
|
-
// Check if there's a comma before this declarator
|
|
113
|
-
const tokenBefore = sourceCode.getTokenBefore(node);
|
|
114
|
-
if (tokenBefore && tokenBefore.value === ',') {
|
|
115
|
-
return fixer.removeRange([
|
|
116
|
-
tokenBefore.range[0],
|
|
117
|
-
declaratorRange[1],
|
|
118
|
-
]);
|
|
119
|
-
}
|
|
120
|
-
return fixer.remove(node);
|
|
121
|
-
}
|
|
122
|
-
return null;
|
|
123
|
-
},
|
|
173
|
+
stateName: stateIdentifier.name,
|
|
174
|
+
removal: hasLiveSiblingBinding
|
|
175
|
+
? null
|
|
176
|
+
: declarationRemovalRange(sourceCode, node),
|
|
124
177
|
});
|
|
125
178
|
}
|
|
126
179
|
}
|
|
127
180
|
}
|
|
128
181
|
},
|
|
182
|
+
'Program:exit'() {
|
|
183
|
+
if (violations.length === 0)
|
|
184
|
+
return;
|
|
185
|
+
const planned = planViolations();
|
|
186
|
+
// One plan over every surviving removal: the `useState` binding is left
|
|
187
|
+
// unreferenced by their union even when no single deletion strips its
|
|
188
|
+
// last call, and the pass that applies them all resolves every report —
|
|
189
|
+
// so this is the only moment the stranded import is visible.
|
|
190
|
+
const orphanRemoval = planned.length > 0
|
|
191
|
+
? planRemoval(planned.map((entry) => entry.removal))
|
|
192
|
+
: null;
|
|
193
|
+
// The whole batch ships as one fix, so no deletion lands without the
|
|
194
|
+
// others the import's orphanhood was judged against, and no unbinding
|
|
195
|
+
// lands without the deletion it was claimed on. The other violations
|
|
196
|
+
// report without a fixer; the carrier's pass already resolves them.
|
|
197
|
+
//
|
|
198
|
+
// No plan at all means some binding would be left unreferenced yet
|
|
199
|
+
// cannot be unbound safely, so every deletion stays behind: reports
|
|
200
|
+
// without a fixer are the lesser damage.
|
|
201
|
+
const carrier = orphanRemoval ? planned[0] : undefined;
|
|
202
|
+
const removals = orphanRemoval
|
|
203
|
+
? [...orphanRemoval, ...planned.map((entry) => entry.removal)]
|
|
204
|
+
: [];
|
|
205
|
+
for (const violation of violations) {
|
|
206
|
+
context.report({
|
|
207
|
+
node: violation.node,
|
|
208
|
+
messageId: 'unusedUseState',
|
|
209
|
+
data: {
|
|
210
|
+
stateName: violation.stateName,
|
|
211
|
+
},
|
|
212
|
+
fix: violation === carrier?.violation
|
|
213
|
+
? (fixer) => removals.map((range) => fixer.removeRange([range[0], range[1]]))
|
|
214
|
+
: undefined,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
},
|
|
129
218
|
};
|
|
130
219
|
},
|
|
131
220
|
});
|
|
@@ -3,16 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.noUselessFragment = void 0;
|
|
4
4
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
5
|
const createRule_1 = require("../utils/createRule");
|
|
6
|
+
const ASTHelpers_1 = require("../utils/ASTHelpers");
|
|
7
|
+
const reactFragmentBinding_1 = require("../utils/reactFragmentBinding");
|
|
8
|
+
const disableDirectives_1 = require("../utils/disableDirectives");
|
|
9
|
+
const importRemoval_1 = require("../utils/importRemoval");
|
|
6
10
|
/**
|
|
7
11
|
* Normalizes JSX child node types into short descriptors used inside lint messages.
|
|
8
12
|
* Keeps message phrasing consistent regardless of the specific child node shape.
|
|
13
|
+
* A long-form fragment child (`<Fragment>`, `<React.Fragment>`) is described as
|
|
14
|
+
* a fragment rather than a JSX element so the message reads the same whichever
|
|
15
|
+
* spelling the nested fragment uses.
|
|
9
16
|
* @param child - The JSX child node to describe.
|
|
17
|
+
* @param isFragmentElement - Recognizes the long-form fragment spellings.
|
|
10
18
|
* @returns Human-readable descriptor for the child type used in lint messages.
|
|
11
19
|
*/
|
|
12
|
-
const describeChild = (child) => {
|
|
20
|
+
const describeChild = (child, isFragmentElement) => {
|
|
13
21
|
switch (child.type) {
|
|
14
22
|
case 'JSXElement':
|
|
15
|
-
return 'JSX element';
|
|
23
|
+
return isFragmentElement(child) ? 'fragment' : 'JSX element';
|
|
16
24
|
case 'JSXFragment':
|
|
17
25
|
return 'fragment';
|
|
18
26
|
case 'JSXText':
|
|
@@ -112,44 +120,244 @@ const reindentPromotedChild = (sourceCode, fragment, child) => {
|
|
|
112
120
|
})
|
|
113
121
|
.join('\n');
|
|
114
122
|
};
|
|
123
|
+
/** The opening and closing tag spans of either fragment spelling. */
|
|
124
|
+
const tagRangesOf = (node) => {
|
|
125
|
+
if (node.type === 'JSXFragment') {
|
|
126
|
+
return [node.openingFragment.range, node.closingFragment.range];
|
|
127
|
+
}
|
|
128
|
+
return node.closingElement
|
|
129
|
+
? [node.openingElement.range, node.closingElement.range]
|
|
130
|
+
: [node.openingElement.range];
|
|
131
|
+
};
|
|
132
|
+
const contains = (outer, inner) => outer[0] <= inner[0] && inner[1] <= outer[1];
|
|
133
|
+
/**
|
|
134
|
+
* Partitions violations into the sets whose unwraps have to travel together.
|
|
135
|
+
*
|
|
136
|
+
* A `Fragment` import read by two useless `<Fragment>` elements is orphaned only
|
|
137
|
+
* once BOTH are unwrapped, so neither unwrap may drop the import alone — and a
|
|
138
|
+
* fix may only count on the other unwrap happening if it performs that unwrap
|
|
139
|
+
* itself. Fragments that jointly hold a binding alive therefore become one
|
|
140
|
+
* batch; every other fragment is a batch of one, judged against the file as it
|
|
141
|
+
* stands. Shorthand `<>` names nothing, so it is never unioned with anything and
|
|
142
|
+
* keeps fixing independently.
|
|
143
|
+
*
|
|
144
|
+
* EVERY binding is asked about, not the imported ones alone, so that a binding
|
|
145
|
+
* no fix asks about cannot end up owned by none of them.
|
|
146
|
+
*/
|
|
147
|
+
function batchViolations(source, violations) {
|
|
148
|
+
const parents = violations.map((_violation, index) => index);
|
|
149
|
+
const find = (index) => {
|
|
150
|
+
let current = index;
|
|
151
|
+
while (parents[current] !== current) {
|
|
152
|
+
parents[current] = parents[parents[current]];
|
|
153
|
+
current = parents[current];
|
|
154
|
+
}
|
|
155
|
+
return current;
|
|
156
|
+
};
|
|
157
|
+
const union = (left, right) => {
|
|
158
|
+
const rootLeft = find(left);
|
|
159
|
+
const rootRight = find(right);
|
|
160
|
+
if (rootLeft !== rootRight) {
|
|
161
|
+
parents[rootRight] = rootLeft;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const ownerOf = (use) => violations.findIndex(({ tags }) => tags.some((tag) => use[0] >= tag[0] && use[1] <= tag[1]));
|
|
165
|
+
for (const { uses } of (0, importRemoval_1.bindingUses)(source)) {
|
|
166
|
+
if (uses.length < 2)
|
|
167
|
+
continue;
|
|
168
|
+
const owners = new Set();
|
|
169
|
+
const escapes = uses.some((use) => {
|
|
170
|
+
const owner = ownerOf(use);
|
|
171
|
+
if (owner === -1)
|
|
172
|
+
return true;
|
|
173
|
+
owners.add(owner);
|
|
174
|
+
return false;
|
|
175
|
+
});
|
|
176
|
+
// A use outside every unwrap keeps the binding alive whatever these fixes
|
|
177
|
+
// do, so their fixes owe each other nothing.
|
|
178
|
+
if (escapes || owners.size < 2)
|
|
179
|
+
continue;
|
|
180
|
+
const [first, ...rest] = [...owners];
|
|
181
|
+
for (const other of rest) {
|
|
182
|
+
union(first, other);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const groups = new Map();
|
|
186
|
+
violations.forEach((violation, index) => {
|
|
187
|
+
const root = find(index);
|
|
188
|
+
const group = groups.get(root);
|
|
189
|
+
if (group) {
|
|
190
|
+
group.push(violation);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
groups.set(root, [violation]);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return [...groups.values()];
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The unwraps `batch` performs plus the import specifiers they leave bound to
|
|
200
|
+
* nothing. `null` when an orphan cannot be unbound safely — an import behind a
|
|
201
|
+
* directive comment, say — in which case the caller keeps the report and drops
|
|
202
|
+
* the fix, because unwrapping while leaving the import behind turns a clean file
|
|
203
|
+
* into one that fails `no-unused-vars`.
|
|
204
|
+
*/
|
|
205
|
+
function planBatch(source, batch) {
|
|
206
|
+
const unwraps = batch.map((violation) => ({
|
|
207
|
+
node: violation.node,
|
|
208
|
+
// Only fixable violations reach a plan, so the replacement is present.
|
|
209
|
+
text: violation.replacement,
|
|
210
|
+
}));
|
|
211
|
+
const cleanups = (0, importRemoval_1.planOrphanedImportRemoval)(source, batch.flatMap((violation) => violation.tags));
|
|
212
|
+
return cleanups ? { unwraps, cleanups } : null;
|
|
213
|
+
}
|
|
115
214
|
exports.noUselessFragment = (0, createRule_1.createRule)({
|
|
116
215
|
name: 'no-useless-fragment',
|
|
117
216
|
create(context) {
|
|
217
|
+
const sourceCode = context.sourceCode;
|
|
218
|
+
/**
|
|
219
|
+
* Long-form fragments are recognized through the element's own scope, so a
|
|
220
|
+
* `Fragment` shadowed by a local component is not mistaken for react's.
|
|
221
|
+
*/
|
|
222
|
+
const isFragmentElement = (element) => (0, reactFragmentBinding_1.isReactFragmentElement)(element, (node) => ASTHelpers_1.ASTHelpers.getScope(context, node));
|
|
223
|
+
/**
|
|
224
|
+
* Fragments held until `Program:exit`. Whether an unwrap strands the import
|
|
225
|
+
* that names the fragment is a whole-file question, and the answer can only
|
|
226
|
+
* be given once every fragment reading that import is known.
|
|
227
|
+
*/
|
|
228
|
+
const violations = [];
|
|
229
|
+
/**
|
|
230
|
+
* Whether ESLint will discard a report, resolved the way ESLint resolves it.
|
|
231
|
+
* A batched fix counts on every unwrap in its batch happening; a suppressed
|
|
232
|
+
* report never fixes, so its fragment — and the reference its tags hold —
|
|
233
|
+
* outlives the pass and must not be counted on.
|
|
234
|
+
*/
|
|
235
|
+
const isSuppressed = (0, disableDirectives_1.createSuppressionChecker)(context);
|
|
236
|
+
/**
|
|
237
|
+
* The single decision every fragment spelling shares. `<>`, `<Fragment>`
|
|
238
|
+
* and `<React.Fragment>` denote the same node, so they are reported and
|
|
239
|
+
* unwrapped identically; splitting the logic per spelling is what let the
|
|
240
|
+
* long forms go unexamined in the first place.
|
|
241
|
+
*/
|
|
242
|
+
const collectWhenUseless = (node) => {
|
|
243
|
+
const meaningfulChildren = node.children.filter((child) => !isFormattingWhitespace(child));
|
|
244
|
+
if (meaningfulChildren.length !== 1) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const [child] = meaningfulChildren;
|
|
248
|
+
/**
|
|
249
|
+
* A fragment whose only child is an expression container — e.g.
|
|
250
|
+
* `<>{portal}</>` — is NOT useless. Unwrapping it to a bare
|
|
251
|
+
* `{portal}` is invalid in statement/return position, and wrapping a
|
|
252
|
+
* single ReactNode expression in a fragment is the idiomatic way to
|
|
253
|
+
* render it. (Mirrors the upstream rule's `allowExpressions`.)
|
|
254
|
+
*/
|
|
255
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Unwrapping is only sound when the child is itself standalone JSX.
|
|
260
|
+
* A text child (`<>hello</>`) would become a bare identifier
|
|
261
|
+
* reference, and a spread child (`<>{...items}</>`) is not a valid
|
|
262
|
+
* expression on its own — both are report-only so the developer
|
|
263
|
+
* chooses how to restructure the surrounding code.
|
|
264
|
+
*/
|
|
265
|
+
const isFixable = child.type === 'JSXElement' || child.type === 'JSXFragment';
|
|
266
|
+
violations.push({
|
|
267
|
+
node,
|
|
268
|
+
childKind: describeChild(child, isFragmentElement),
|
|
269
|
+
replacement: isFixable
|
|
270
|
+
? reindentPromotedChild(sourceCode, node, child)
|
|
271
|
+
: null,
|
|
272
|
+
tags: tagRangesOf(node),
|
|
273
|
+
});
|
|
274
|
+
};
|
|
118
275
|
return {
|
|
119
276
|
JSXFragment(node) {
|
|
120
|
-
|
|
121
|
-
|
|
277
|
+
collectWhenUseless(node);
|
|
278
|
+
},
|
|
279
|
+
JSXElement(node) {
|
|
280
|
+
if (!isFragmentElement(node)) {
|
|
122
281
|
return;
|
|
123
282
|
}
|
|
124
|
-
const [child] = meaningfulChildren;
|
|
125
283
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* render it. (Mirrors the upstream rule's `allowExpressions`.)
|
|
284
|
+
* An attribute is content the shorthand cannot carry: `key` positions
|
|
285
|
+
* the fragment in a sibling list, and unwrapping would move it onto
|
|
286
|
+
* the promoted child, changing reconciliation. Only the long forms can
|
|
287
|
+
* reach this branch, since `<>` admits no attributes at all.
|
|
131
288
|
*/
|
|
132
|
-
if (
|
|
289
|
+
if (node.openingElement.attributes.length > 0) {
|
|
133
290
|
return;
|
|
134
291
|
}
|
|
292
|
+
collectWhenUseless(node);
|
|
293
|
+
},
|
|
294
|
+
/**
|
|
295
|
+
* Emits every held report, each carrying the import cleanup its own
|
|
296
|
+
* unwrap makes necessary.
|
|
297
|
+
*
|
|
298
|
+
* Orphanhood is judged against a single fix's own deletions, never
|
|
299
|
+
* against what sibling reports might also delete: ESLint may discard a
|
|
300
|
+
* sibling, and the fragment it was going to unwrap then keeps the import
|
|
301
|
+
* alive.
|
|
302
|
+
*/
|
|
303
|
+
'Program:exit'() {
|
|
304
|
+
if (violations.length === 0)
|
|
305
|
+
return;
|
|
135
306
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
307
|
+
* A fragment nested inside another fragment being unwrapped is left out
|
|
308
|
+
* of the batching. Its tags are carried into the outer fragment's
|
|
309
|
+
* replacement text verbatim, so its reference SURVIVES that fix — and
|
|
310
|
+
* the two replacements would overlap besides. The outer unwrap then
|
|
311
|
+
* finds the import still in use and leaves it, and the next `--fix`
|
|
312
|
+
* pass unwraps what is by then a lone fragment and takes the import
|
|
313
|
+
* with it.
|
|
141
314
|
*/
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
node,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
315
|
+
const unwrapping = violations.filter((violation) => violation.replacement !== null && !isSuppressed(violation.node));
|
|
316
|
+
const batchable = unwrapping.filter((violation) => !unwrapping.some((other) => other !== violation &&
|
|
317
|
+
contains(other.node.range, violation.node.range)));
|
|
318
|
+
const batchableSet = new Set(batchable);
|
|
319
|
+
const plans = new Map();
|
|
320
|
+
for (const batch of batchViolations(sourceCode, batchable)) {
|
|
321
|
+
const plan = planBatch(sourceCode, batch);
|
|
322
|
+
if (!plan)
|
|
323
|
+
continue;
|
|
324
|
+
for (const violation of batch) {
|
|
325
|
+
plans.set(violation, plan);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const violation of violations) {
|
|
329
|
+
/**
|
|
330
|
+
* A fixable fragment the batching left out fixes on its own. It is
|
|
331
|
+
* nested inside another fragment being unwrapped — so its tags are
|
|
332
|
+
* carried into that fragment's replacement rather than deleted, and
|
|
333
|
+
* strand nothing — or it is suppressed, in which case ESLint discards
|
|
334
|
+
* the report and the fix never runs. A fragment that WAS batched and
|
|
335
|
+
* whose batch declined gets no fix at all, which is the whole point of
|
|
336
|
+
* the decline.
|
|
337
|
+
*/
|
|
338
|
+
const own = violation.replacement !== null && !batchableSet.has(violation)
|
|
339
|
+
? {
|
|
340
|
+
unwraps: [
|
|
341
|
+
{ node: violation.node, text: violation.replacement },
|
|
342
|
+
],
|
|
343
|
+
cleanups: [],
|
|
344
|
+
}
|
|
345
|
+
: null;
|
|
346
|
+
const applied = plans.get(violation) ?? own;
|
|
347
|
+
context.report({
|
|
348
|
+
node: violation.node,
|
|
349
|
+
messageId: 'noUselessFragment',
|
|
350
|
+
data: { childKind: violation.childKind },
|
|
351
|
+
...(applied
|
|
352
|
+
? {
|
|
353
|
+
fix: (fixer) => [
|
|
354
|
+
...applied.unwraps.map((unwrap) => fixer.replaceText(unwrap.node, unwrap.text)),
|
|
355
|
+
...applied.cleanups.map((range) => fixer.removeRange([range[0], range[1]])),
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
: {}),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
153
361
|
},
|
|
154
362
|
};
|
|
155
363
|
},
|