Skip to content

Commit c2e30ac

Browse files
committed
refactor: minor improvements
1 parent 551c5a2 commit c2e30ac

File tree

3 files changed

+67
-58
lines changed

3 files changed

+67
-58
lines changed

packages/plugins/eslint-plugin-react-x/src/rules/no-array-index-key.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ export const RULE_FEATURES = [] as const satisfies RuleFeature[];
1717

1818
export type MessageID = CamelCase<typeof RULE_NAME>;
1919

20-
const reactChildrenMethod = ["forEach", "map"] as const;
20+
const REACT_CHILDREN_METHOD = ["forEach", "map"] as const;
2121

22-
function isReactChildrenMethod(name: string): name is typeof reactChildrenMethod[number] {
23-
return reactChildrenMethod.some((method) => method === name);
22+
function isReactChildrenMethod(name: string): name is typeof REACT_CHILDREN_METHOD[number] {
23+
return REACT_CHILDREN_METHOD.includes(name as never);
2424
}
2525

2626
function isUsingReactChildren(context: RuleContext, node: TSESTree.CallExpression) {
Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1+
/* eslint-disable jsdoc/require-param */
12
import * as AST from "@eslint-react/ast";
3+
import * as ER from "@eslint-react/core";
24
import type { RuleContext, RuleFeature } from "@eslint-react/kit";
5+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
36
import type { TSESTree } from "@typescript-eslint/utils";
47
import type { RuleFixer, RuleListener } from "@typescript-eslint/utils/ts-eslint";
58

6-
import * as ER from "@eslint-react/core";
7-
8-
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
9-
109
import { createRule } from "../utils";
1110

1211
export const RULE_NAME = "no-useless-fragment";
@@ -57,37 +56,41 @@ export default createRule<Options, MessageID>({
5756

5857
export function create(context: RuleContext<MessageID, Options>, [option]: Options): RuleListener {
5958
const { allowExpressions = true } = option;
59+
6060
return {
61+
// Check JSX elements that might be fragments
6162
JSXElement(node) {
6263
if (!ER.isFragmentElement(context, node)) return;
6364
checkNode(context, node, allowExpressions);
6465
},
66+
// Check JSX fragments
6567
JSXFragment(node) {
6668
checkNode(context, node, allowExpressions);
6769
},
6870
};
6971
}
7072

73+
// ----- Helper Functions -----
74+
7175
/**
7276
* Check if a Literal or JSXText node is whitespace
73-
* @param node The AST node to check
74-
* @returns boolean `true` if the node is whitespace
7577
*/
7678
function isWhiteSpace(node: TSESTree.JSXText | TSESTree.Literal) {
7779
return typeof node.value === "string" && node.raw.trim() === "";
7880
}
7981

8082
/**
81-
* Check if a Literal or JSXText node is padding spaces
82-
* @param node The AST node to check
83-
* @returns boolean
83+
* Check if a node is padding spaces (whitespace with line breaks)
8484
*/
8585
function isPaddingSpaces(node: TSESTree.Node) {
8686
return ER.isJsxText(node)
8787
&& isWhiteSpace(node)
8888
&& node.raw.includes("\n");
8989
}
9090

91+
/**
92+
* Trim whitespace like React would in JSX
93+
*/
9194
function trimLikeReact(text: string) {
9295
const leadingSpaces = /^\s*/.exec(text)?.[0] ?? "";
9396
const trailingSpaces = /\s*$/.exec(text)?.[0] ?? "";
@@ -98,99 +101,104 @@ function trimLikeReact(text: string) {
98101
return text.slice(start, end);
99102
}
100103

104+
/**
105+
* Check if a fragment node is useless and should be reported
106+
*/
101107
function checkNode(
102108
context: RuleContext,
103109
node: TSESTree.JSXElement | TSESTree.JSXFragment,
104110
allowExpressions: boolean,
105111
) {
106112
const initialScope = context.sourceCode.getScope(node);
107-
// return if the fragment is keyed (e.g. <Fragment key={key}>)
113+
114+
// Skip if the fragment has a key prop (indicates it's needed for lists)
108115
if (node.type === T.JSXElement && ER.hasAttribute(context, "key", node.openingElement.attributes, initialScope)) {
109116
return;
110117
}
111-
// report if the fragment is placed inside a host component (e.g. <div><></></div>)
118+
119+
// Report fragment placed inside a host component (e.g. <div><></></div>)
112120
if (ER.isHostElement(context, node.parent)) {
113121
context.report({
114122
messageId: "uselessFragment",
115123
node,
116-
data: {
117-
reason: "placed inside a host component",
118-
},
124+
data: { reason: "placed inside a host component" },
119125
fix: getFix(context, node),
120126
});
121127
}
122-
// report and return if the fragment has no children (e.g. <></>)
128+
129+
// Report empty fragments (e.g. <></>)
123130
if (node.children.length === 0) {
124131
context.report({
125132
messageId: "uselessFragment",
126133
node,
127-
data: {
128-
reason: "contains less than two children",
129-
},
134+
data: { reason: "contains less than two children" },
130135
fix: getFix(context, node),
131136
});
132137
return;
133138
}
139+
134140
const isChildElement = AST.isOneOf([T.JSXElement, T.JSXFragment])(node.parent);
141+
142+
// Handle various fragment cases
135143
switch (true) {
136-
// <Foo content={<>ee eeee eeee ...</>} />
144+
// Allow single text child in attribute value (e.g. content={<>text</>})
137145
case allowExpressions
138146
&& !isChildElement
139147
&& node.children.length === 1
140148
&& ER.isJsxText(node.children.at(0)): {
141149
return;
142150
}
143-
// <Foo><>hello, world</></Foo>
151+
152+
// Report fragment with single child inside JSX element
144153
case !allowExpressions
145154
&& isChildElement: {
146155
context.report({
147156
messageId: "uselessFragment",
148157
node,
149-
data: {
150-
reason: "contains less than two children",
151-
},
158+
data: { reason: "contains less than two children" },
152159
fix: getFix(context, node),
153160
});
154161
return;
155162
}
163+
164+
// Report fragment with single child in expressions
156165
case !allowExpressions
157166
&& !isChildElement
158167
&& node.children.length === 1: {
159-
// const foo = <>{children}</>;
160-
// return <>{children}</>;
161168
context.report({
162169
messageId: "uselessFragment",
163170
node,
164-
data: {
165-
reason: "contains less than two children",
166-
},
171+
data: { reason: "contains less than two children" },
167172
fix: getFix(context, node),
168173
});
169174
return;
170175
}
171176
}
177+
178+
// Filter out padding spaces to check actual content
172179
const nonPaddingChildren = node.children.filter((child) => !isPaddingSpaces(child));
173180
const firstNonPaddingChild = nonPaddingChildren.at(0);
174-
switch (true) {
175-
case nonPaddingChildren.length === 0:
176-
case nonPaddingChildren.length === 1
177-
&& firstNonPaddingChild?.type !== T.JSXExpressionContainer: {
178-
context.report({
179-
messageId: "uselessFragment",
180-
node,
181-
data: {
182-
reason: "contains less than two children",
183-
},
184-
fix: getFix(context, node),
185-
});
186-
return;
187-
}
181+
182+
// Report if empty or only has one non-expression child
183+
if (
184+
nonPaddingChildren.length === 0
185+
|| (nonPaddingChildren.length === 1 && firstNonPaddingChild?.type !== T.JSXExpressionContainer)
186+
) {
187+
context.report({
188+
messageId: "uselessFragment",
189+
node,
190+
data: { reason: "contains less than two children" },
191+
fix: getFix(context, node),
192+
});
188193
}
189-
return;
190194
}
191195

196+
/**
197+
* Generate fix for removing useless fragment
198+
*/
192199
function getFix(context: RuleContext, node: TSESTree.JSXElement | TSESTree.JSXFragment) {
193200
if (!canFix(context, node)) return null;
201+
194202
return (fixer: RuleFixer) => {
195203
const opener = node.type === T.JSXFragment ? node.openingFragment : node.openingElement;
196204
const closer = node.type === T.JSXFragment ? node.closingFragment : node.closingElement;
@@ -203,20 +211,22 @@ function getFix(context: RuleContext, node: TSESTree.JSXElement | TSESTree.JSXFr
203211
};
204212
}
205213

214+
/**
215+
* Check if it's safe to automatically fix the fragment
216+
*/
206217
function canFix(context: RuleContext, node: TSESTree.JSXElement | TSESTree.JSXFragment) {
218+
// Don't fix fragments inside custom components (might require children to be ReactElement)
207219
if (node.parent.type === T.JSXElement || node.parent.type === T.JSXFragment) {
208-
// Not safe to fix `<Eeee><>foo</></Eeee>` because `Eeee` might require its children be a ReactElement.
209220
return ER.isHostElement(context, node.parent);
210221
}
211-
// Not safe to fix fragments without a jsx parent.
212-
// const a = <></>
222+
223+
// Don't fix empty fragments without a JSX parent
213224
if (node.children.length === 0) {
214225
return false;
215226
}
216-
// dprint-ignore
217-
// const a = <>{meow}</>
218-
if (node.children.some((child) => (ER.isJsxText(child) && !isWhiteSpace(child)) || AST.is(T.JSXExpressionContainer)(child))) {
219-
return false;
220-
}
221-
return true;
227+
228+
// Don't fix fragments with text or expressions outside of JSX context
229+
return !node
230+
.children
231+
.some((child) => (ER.isJsxText(child) && !isWhiteSpace(child)) || AST.is(T.JSXExpressionContainer)(child));
222232
}

packages/plugins/eslint-plugin-react-x/src/rules/prefer-namespace-import.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ export default createRule<[], MessageID>({
3535
export function create(context: RuleContext<MessageID, []>): RuleListener {
3636
const { importSource } = getSettingsFromContext(context);
3737
return {
38-
[`ImportDeclaration[source.value="${importSource}"] ImportDefaultSpecifier`](
39-
node: TSESTree.ImportDefaultSpecifier,
40-
) {
38+
// dprint-ignore
39+
[`ImportDeclaration[source.value="${importSource}"] ImportDefaultSpecifier`](node: TSESTree.ImportDefaultSpecifier) {
4140
const hasOtherSpecifiers = node.parent.specifiers.length > 1;
4241
context.report({
4342
messageId: "preferNamespaceImport",

0 commit comments

Comments
 (0)