1+ /* eslint-disable jsdoc/require-param */
12import * as AST from "@eslint-react/ast" ;
3+ import * as ER from "@eslint-react/core" ;
24import type { RuleContext , RuleFeature } from "@eslint-react/kit" ;
5+ import { AST_NODE_TYPES as T } from "@typescript-eslint/types" ;
36import type { TSESTree } from "@typescript-eslint/utils" ;
47import 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-
109import { createRule } from "../utils" ;
1110
1211export const RULE_NAME = "no-useless-fragment" ;
@@ -57,37 +56,41 @@ export default createRule<Options, MessageID>({
5756
5857export 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 */
7678function 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 */
8585function 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+ */
9194function 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+ */
101107function 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+ */
192199function 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+ */
206217function 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}
0 commit comments