eslint-plugin-react-jsx 5.16.0 → 5.17.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.
Files changed (2) hide show
  1. package/dist/index.js +204 -250
  2. package/package.json +6 -6
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { DEFAULT_ESLINT_REACT_SETTINGS } from "@eslint-react/shared";
2
2
  import { ESLintUtils } from "@typescript-eslint/utils";
3
+ import { Check, Extract } from "@eslint-react/ast";
3
4
  import * as core from "@eslint-react/core";
4
- import "@eslint-react/eslint";
5
- import { collapseMultilineText, findAttribute, getChildren, getElementFullType, hasAnyAttribute, hasChildren, isFragmentElement, isHostElement, isWhitespaceText } from "@eslint-react/jsx";
6
5
  import { AST_NODE_TYPES } from "@typescript-eslint/types";
7
- import { Check, Extract } from "@eslint-react/ast";
6
+ import "@eslint-react/eslint";
7
+ import { collapseMultilineText, findAttribute, getAttributeName, getChildren, getElementFullType, hasAnyAttribute, hasChildren, isEmptyStringExpression, isFragmentElement, isHostElement, isWhitespaceText } from "@eslint-react/jsx";
8
8
  import ts from "typescript";
9
9
 
10
10
  //#region \0rolldown/runtime.js
@@ -26,7 +26,7 @@ var __exportAll = (all, no_symbols) => {
26
26
  //#endregion
27
27
  //#region package.json
28
28
  var name$2 = "eslint-plugin-react-jsx";
29
- var version = "5.16.0";
29
+ var version = "5.17.0";
30
30
 
31
31
  //#endregion
32
32
  //#region src/utils/create-rule.ts
@@ -36,46 +36,36 @@ function getDocsUrl(ruleName) {
36
36
  const createRule = ESLintUtils.RuleCreator(getDocsUrl);
37
37
 
38
38
  //#endregion
39
- //#region src/rules/no-children-prop-with-children/lib.ts
39
+ //#region src/utils/find-create-element-children-prop.ts
40
40
  /**
41
- * Find a `children` property inside an ObjectExpression.
42
- * @param node The object expression.
43
- * @returns The Property node whose key is "children", or null.
41
+ * Finds the statically named `children` property in the props object of a `createElement` call.
42
+ * @param context The rule context
43
+ * @param node The `CallExpression` node to inspect
44
+ * @returns The `children` property node, or `null` when the call has no static `children` prop
44
45
  */
45
- function findChildrenProperty$1(node) {
46
- for (const prop of node.properties) {
47
- if (prop.type !== AST_NODE_TYPES.Property) continue;
48
- const key = Extract.unwrap(prop.key);
49
- if (key.type === AST_NODE_TYPES.Identifier && key.name === "children") return prop;
50
- if (key.type === AST_NODE_TYPES.Literal && key.value === "children") return prop;
51
- }
46
+ function findCreateElementChildrenProp(context, node) {
47
+ if (!core.isCreateElementCall(context, node)) return null;
48
+ const propsArg = node.arguments[1];
49
+ if (propsArg == null) return null;
50
+ const propsObject = Extract.unwrap(propsArg);
51
+ if (propsObject.type !== AST_NODE_TYPES.ObjectExpression) return null;
52
+ for (const prop of propsObject.properties) if (prop.type === AST_NODE_TYPES.Property && Extract.getStaticPropertyName(prop) === "children") return prop;
52
53
  return null;
53
54
  }
55
+
56
+ //#endregion
57
+ //#region src/utils/remove-jsx-attribute.ts
54
58
  /**
55
- * Compute the removal range for a JSX attribute, consuming any leading
56
- * whitespace (spaces, tabs, newlines) so the resulting markup stays clean.
57
- * @param context The rule context.
58
- * @param prop The JSX attribute.
59
- */
60
- function getPropRemovalRange$1(context, prop) {
61
- const { sourceCode } = context;
62
- let start = prop.range[0];
63
- const end = prop.range[1];
64
- while (start > 0 && /\s/.test(sourceCode.text[start - 1])) start--;
65
- return [start, end];
66
- }
67
- /**
68
- * Compute the range covering **all** children content of a JSX element or
69
- * fragment (from the start of the first child to the end of the last child).
70
- *
71
- * Returns `null` when there are no children at all.
72
- * @param node The JSX element or fragment.
59
+ * Builds a fix that removes a JSX attribute along with the whitespace before it.
60
+ * @param context The rule context
61
+ * @param fixer The rule fixer
62
+ * @param attribute The `JSXAttribute` node to remove
63
+ * @returns A fix that removes the attribute and its preceding whitespace
73
64
  */
74
- function getChildrenContentRange(node) {
75
- if (node.children.length === 0) return null;
76
- const first = node.children[0];
77
- const last = node.children[node.children.length - 1];
78
- return [first.range[0], last.range[1]];
65
+ function removeJsxAttribute(context, fixer, attribute) {
66
+ let start = attribute.range[0];
67
+ while (start > 0 && /\s/.test(context.sourceCode.text[start - 1] ?? "")) start--;
68
+ return fixer.removeRange([start, attribute.range[1]]);
79
69
  }
80
70
 
81
71
  //#endregion
@@ -101,12 +91,9 @@ var no_children_prop_with_children_default = createRule({
101
91
  function create$7(context) {
102
92
  return {
103
93
  CallExpression(node) {
104
- if (!core.isCreateElementCall(context, node)) return;
105
- const [, propsArg, firstExtra] = node.arguments;
106
- if (propsArg == null || propsArg.type !== AST_NODE_TYPES.ObjectExpression) return;
107
- const childrenProp = findChildrenProperty$1(propsArg);
94
+ const childrenProp = findCreateElementChildrenProp(context, node);
108
95
  if (childrenProp == null) return;
109
- if (firstExtra == null) return;
96
+ if (node.arguments[2] == null) return;
110
97
  context.report({
111
98
  messageId: "default",
112
99
  node: childrenProp
@@ -114,8 +101,7 @@ function create$7(context) {
114
101
  },
115
102
  JSXElement(node) {
116
103
  const childrenProp = findAttribute(context, node, "children");
117
- if (childrenProp == null) return;
118
- if (!hasChildren(node)) return;
104
+ if (childrenProp == null || !hasChildren(node)) return;
119
105
  if (childrenProp.type !== AST_NODE_TYPES.JSXAttribute) {
120
106
  context.report({
121
107
  messageId: "default",
@@ -127,16 +113,14 @@ function create$7(context) {
127
113
  messageId: "default",
128
114
  node: childrenProp,
129
115
  suggest: [{
130
- fix(fixer) {
131
- const [start, end] = getPropRemovalRange$1(context, childrenProp);
132
- return fixer.removeRange([start, end]);
133
- },
116
+ fix: (fixer) => removeJsxAttribute(context, fixer, childrenProp),
134
117
  messageId: "removeChildrenProp"
135
118
  }, {
136
119
  fix(fixer) {
137
- const range = getChildrenContentRange(node);
138
- if (range == null) return [];
139
- return fixer.removeRange(range);
120
+ const first = node.children.at(0);
121
+ const last = node.children.at(-1);
122
+ if (first == null || last == null) return [];
123
+ return fixer.removeRange([first.range[0], last.range[1]]);
140
124
  },
141
125
  messageId: "removeChildrenContent"
142
126
  }]
@@ -145,61 +129,6 @@ function create$7(context) {
145
129
  };
146
130
  }
147
131
 
148
- //#endregion
149
- //#region src/rules/no-children-prop/lib.ts
150
- /**
151
- * Find a `children` property inside an ObjectExpression.
152
- * @param node The object expression.
153
- * @returns The Property node whose key is "children", or null.
154
- */
155
- function findChildrenProperty(node) {
156
- for (const prop of node.properties) {
157
- if (prop.type !== AST_NODE_TYPES.Property) continue;
158
- const key = Extract.unwrap(prop.key);
159
- if (key.type === AST_NODE_TYPES.Identifier && key.name === "children") return prop;
160
- if (key.type === AST_NODE_TYPES.Literal && key.value === "children") return prop;
161
- }
162
- return null;
163
- }
164
- /**
165
- * Compute the removal range for a JSX attribute, consuming any leading
166
- * whitespace (spaces, tabs, newlines) so the resulting markup stays clean.
167
- * @param context The rule context.
168
- * @param prop The JSX attribute.
169
- */
170
- function getPropRemovalRange(context, prop) {
171
- const { sourceCode } = context;
172
- let start = prop.range[0];
173
- const end = prop.range[1];
174
- while (start > 0 && /\s/.test(sourceCode.text[start - 1])) start--;
175
- return [start, end];
176
- }
177
- /**
178
- * Extract the text to use as JSX children content from a `children` prop.
179
- *
180
- * - `children="text"` -> `text` (raw string, no quotes)
181
- * - `children={<div />}` -> `<div />` (JSX element, no braces)
182
- * - `children={<>…</>}` -> `<>…</>` (JSX fragment, no braces)
183
- * - `children={expression}` -> `{expression}` (wrapped in braces)
184
- * - `children` -> `null` (boolean shorthand, cannot extract)
185
- * @param context The rule context.
186
- * @param prop The JSX attribute.
187
- */
188
- function getChildrenPropText(context, prop) {
189
- const { sourceCode } = context;
190
- const { value } = prop;
191
- if (value == null) return null;
192
- if (value.type === AST_NODE_TYPES.Literal) return String(value.value);
193
- if (value.type === AST_NODE_TYPES.JSXExpressionContainer) {
194
- const { expression } = value;
195
- if (expression.type === AST_NODE_TYPES.JSXEmptyExpression) return null;
196
- const exprText = sourceCode.getText(expression);
197
- if (Check.isJSXElementOrFragment(expression)) return exprText;
198
- return `{${exprText}}`;
199
- }
200
- return null;
201
- }
202
-
203
132
  //#endregion
204
133
  //#region src/rules/no-children-prop/no-children-prop.ts
205
134
  const RULE_NAME$6 = "no-children-prop";
@@ -222,10 +151,7 @@ var no_children_prop_default = createRule({
222
151
  function create$6(context) {
223
152
  return {
224
153
  CallExpression(node) {
225
- if (!core.isCreateElementCall(context, node)) return;
226
- const [, propsArg] = node.arguments;
227
- if (propsArg == null || propsArg.type !== AST_NODE_TYPES.ObjectExpression) return;
228
- const childrenProp = findChildrenProperty(propsArg);
154
+ const childrenProp = findCreateElementChildrenProp(context, node);
229
155
  if (childrenProp == null) return;
230
156
  context.report({
231
157
  messageId: "default",
@@ -242,7 +168,7 @@ function create$6(context) {
242
168
  });
243
169
  return;
244
170
  }
245
- const childrenText = getChildrenPropText(context, childrenProp);
171
+ const childrenText = getChildrenText(context, childrenProp);
246
172
  if (childrenText == null) {
247
173
  context.report({
248
174
  messageId: "default",
@@ -254,27 +180,60 @@ function create$6(context) {
254
180
  messageId: "default",
255
181
  node: childrenProp,
256
182
  suggest: [{
257
- fix(fixer) {
258
- const sourceCode = context.sourceCode;
259
- const { openingElement } = node;
260
- const [removeStart, removeEnd] = getPropRemovalRange(context, childrenProp);
261
- if (openingElement.selfClosing) {
262
- const tagName = sourceCode.getText(openingElement.name);
263
- const selfCloseOffset = sourceCode.getText(openingElement).lastIndexOf("/>");
264
- let wsStart = openingElement.range[0] + selfCloseOffset;
265
- while (wsStart > removeEnd && /\s/.test(sourceCode.text[wsStart - 1])) wsStart--;
266
- return [fixer.removeRange([removeStart, removeEnd]), fixer.replaceTextRange([wsStart, openingElement.range[1]], `>${childrenText}</${tagName}>`)];
267
- }
268
- const fixes = [fixer.removeRange([removeStart, removeEnd])];
269
- if (node.closingElement != null) fixes.push(fixer.insertTextBefore(node.closingElement, childrenText));
270
- return fixes;
271
- },
183
+ fix: buildFix$1(context, node, childrenProp, childrenText),
272
184
  messageId: "moveChildrenToContent"
273
185
  }]
274
186
  });
275
187
  }
276
188
  };
277
189
  }
190
+ /**
191
+ * Turns the value of a 'children' JSXAttribute into text usable as element content
192
+ * @param context The rule context
193
+ * @param attribute The 'children' JSXAttribute node
194
+ * @returns The text to insert as element content, or `null` when no usable value exists
195
+ */
196
+ function getChildrenText(context, attribute) {
197
+ const { value } = attribute;
198
+ if (value?.type === AST_NODE_TYPES.Literal) return escapeJsxText(String(value.value));
199
+ if (value?.type === AST_NODE_TYPES.JSXExpressionContainer && value.expression.type !== AST_NODE_TYPES.JSXEmptyExpression) {
200
+ const exprText = context.sourceCode.getText(value.expression);
201
+ return Check.isJSXElementOrFragment(value.expression) ? exprText : `{${exprText}}`;
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * Escapes characters that would be parsed as markup in JSX text
207
+ * @param text The raw string value
208
+ * @returns The escaped text safe for insertion as JSX content
209
+ */
210
+ function escapeJsxText(text) {
211
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("{", "&#123;").replaceAll("}", "&#125;");
212
+ }
213
+ /**
214
+ * Builds the fix that moves the 'children' prop value into the element's content
215
+ * @param context The rule context object
216
+ * @param node The JSXElement node being reported
217
+ * @param prop The 'children' JSXAttribute to remove
218
+ * @param childrenText The text to insert as element content
219
+ * @returns A fixer function that applies the changes
220
+ */
221
+ function buildFix$1(context, node, prop, childrenText) {
222
+ return (fixer) => {
223
+ const sourceCode = context.sourceCode;
224
+ const { openingElement } = node;
225
+ const removePropFix = removeJsxAttribute(context, fixer, prop);
226
+ if (openingElement.selfClosing) {
227
+ const tagName = sourceCode.getText(openingElement.name);
228
+ const selfCloseOffset = sourceCode.getText(openingElement).lastIndexOf("/>");
229
+ let wsStart = openingElement.range[0] + selfCloseOffset;
230
+ while (wsStart > prop.range[1] && /\s/.test(sourceCode.text[wsStart - 1])) wsStart--;
231
+ return [removePropFix, fixer.replaceTextRange([wsStart, openingElement.range[1]], `>${childrenText}</${tagName}>`)];
232
+ }
233
+ if (node.closingElement == null) return [removePropFix];
234
+ return [removePropFix, fixer.insertTextBefore(node.closingElement, childrenText)];
235
+ };
236
+ }
278
237
 
279
238
  //#endregion
280
239
  //#region src/rules/no-comment-textnodes/no-comment-textnodes.ts
@@ -291,22 +250,15 @@ var no_comment_textnodes_default = createRule({
291
250
  defaultOptions: []
292
251
  });
293
252
  function create$5(context) {
294
- function hasCommentLike(node) {
295
- if (Check.isOneOf([AST_NODE_TYPES.JSXAttribute, AST_NODE_TYPES.JSXExpressionContainer])(node.parent)) return false;
296
- return /^\s*\/(?:\/|\*)/mu.test(context.sourceCode.getText(node));
297
- }
298
- const visit = (node) => {
253
+ function visit(node) {
299
254
  if (!Check.isJSXElementOrFragment(node.parent)) return;
300
- if (!hasCommentLike(node)) return;
255
+ if (!/^\s*\/(?:\/|\*)/mu.test(context.sourceCode.getText(node))) return;
301
256
  context.report({
302
257
  messageId: "default",
303
258
  node
304
259
  });
305
- };
306
- return {
307
- JSXText: visit,
308
- Literal: visit
309
- };
260
+ }
261
+ return { JSXText: visit };
310
262
  }
311
263
 
312
264
  //#endregion
@@ -316,7 +268,7 @@ var no_key_after_spread_default = createRule({
316
268
  meta: {
317
269
  type: "problem",
318
270
  docs: { description: "Prevent patterns that cause deoptimization when using the automatic JSX runtime." },
319
- messages: { noKeyAfterSpread: "Placing 'key' after spread props causes deoptimization when using the automatic JSX runtime. Put 'key' before any spread props." },
271
+ messages: { default: "Placing 'key' after spread props causes deoptimization when using the automatic JSX runtime. Put 'key' before any spread props." },
320
272
  schema: []
321
273
  },
322
274
  name: RULE_NAME$4,
@@ -325,17 +277,16 @@ var no_key_after_spread_default = createRule({
325
277
  });
326
278
  function create$4(context) {
327
279
  const { jsx } = core.getJsxConfig(context);
328
- if (!(jsx === ts.JsxEmit.ReactJSX || jsx === ts.JsxEmit.ReactJSXDev)) return {};
280
+ if (jsx !== ts.JsxEmit.ReactJSX && jsx !== ts.JsxEmit.ReactJSXDev) return {};
329
281
  return { JSXOpeningElement(node) {
330
- let firstSpreadPropIndex = null;
331
- for (const [index, prop] of node.attributes.entries()) {
282
+ let hasSpreadBefore = false;
283
+ for (const prop of node.attributes) {
332
284
  if (prop.type === AST_NODE_TYPES.JSXSpreadAttribute) {
333
- firstSpreadPropIndex ??= index;
285
+ hasSpreadBefore = true;
334
286
  continue;
335
287
  }
336
- if (firstSpreadPropIndex == null) continue;
337
- if (prop.name.type === AST_NODE_TYPES.JSXIdentifier && prop.name.name === "key" && index > firstSpreadPropIndex) context.report({
338
- messageId: "noKeyAfterSpread",
288
+ if (hasSpreadBefore && getAttributeName(prop) === "key") context.report({
289
+ messageId: "default",
339
290
  node: prop
340
291
  });
341
292
  }
@@ -362,43 +313,45 @@ var no_leaked_dollar_default = createRule({
362
313
  defaultOptions: []
363
314
  });
364
315
  function create$3(context) {
365
- /**
366
- * Visitor function for JSXElement and JSXFragment nodes
367
- * @param node The JSXElement or JSXFragment node to be checked
368
- */
369
- const visit = (node) => {
316
+ if (!context.sourceCode.text.includes("$")) return {};
317
+ function visit(node) {
370
318
  for (const [index, child] of node.children.entries()) {
371
319
  if (child.type !== AST_NODE_TYPES.JSXText || !child.value.endsWith("$")) continue;
372
320
  if (node.children[index + 1]?.type !== AST_NODE_TYPES.JSXExpressionContainer) continue;
373
- if (child.value === "$" && node.children.length === 2) continue;
374
- const pos = child.loc.end;
321
+ if (child.value.trim() === "$" && node.children.every((sibling, siblingIndex) => siblingIndex === index || siblingIndex === index + 1 || isNonSubstantiveChild(sibling))) continue;
322
+ if (!context.sourceCode.getText(child).endsWith("$")) continue;
323
+ const dollarStart = child.range[1] - 1;
324
+ const dollarEnd = child.range[1];
375
325
  context.report({
376
326
  loc: {
377
- end: {
378
- column: pos.column,
379
- line: pos.line
380
- },
381
- start: {
382
- column: pos.column - 1,
383
- line: pos.line
384
- }
327
+ end: context.sourceCode.getLocFromIndex(dollarEnd),
328
+ start: context.sourceCode.getLocFromIndex(dollarStart)
385
329
  },
386
330
  messageId: "default",
387
331
  node: child,
388
332
  suggest: [{
389
333
  fix(fixer) {
390
- return fixer.removeRange([child.range[1] - 1, child.range[1]]);
334
+ return fixer.removeRange([dollarStart, dollarEnd]);
391
335
  },
392
336
  messageId: "removeDollarSign"
393
337
  }]
394
338
  });
395
339
  }
396
- };
340
+ }
397
341
  return {
398
342
  JSXElement: visit,
399
343
  JSXFragment: visit
400
344
  };
401
345
  }
346
+ /**
347
+ * Whether a JSX child carries no renderable content: padding whitespace, an
348
+ * empty expression (ex: `{}` or a comment), or an empty string expression.
349
+ * @param child The JSX child node to check.
350
+ */
351
+ function isNonSubstantiveChild(child) {
352
+ if (isWhitespaceText(child) || isEmptyStringExpression(child)) return true;
353
+ return child.type === AST_NODE_TYPES.JSXExpressionContainer && child.expression.type === AST_NODE_TYPES.JSXEmptyExpression;
354
+ }
402
355
 
403
356
  //#endregion
404
357
  //#region src/rules/no-leaked-semicolon/no-leaked-semicolon.ts
@@ -407,6 +360,7 @@ var no_leaked_semicolon_default = createRule({
407
360
  meta: {
408
361
  type: "problem",
409
362
  docs: { description: "Catches `;` at the start of JSX text nodes — typically from accidentally placing a statement-ending `;` inside JSX. The `;` \"leaks\" into the rendered output." },
363
+ fixable: "code",
410
364
  hasSuggestions: true,
411
365
  messages: {
412
366
  default: "Leaked ';' in JSX. This ';' will be rendered as text nodes.",
@@ -418,13 +372,10 @@ var no_leaked_semicolon_default = createRule({
418
372
  create: create$2,
419
373
  defaultOptions: []
420
374
  });
421
- function hasLeakedSemicolon(text) {
422
- return text.startsWith(";\n") || text.startsWith(";\r");
423
- }
424
375
  function create$2(context) {
425
- const visit = (node) => {
376
+ function visit(node) {
426
377
  if (!Check.isJSXElementOrFragment(node.parent)) return;
427
- if (!hasLeakedSemicolon(context.sourceCode.getText(node))) return;
378
+ if (!/^;+[ \t]*(?:\r\n|\r|\n)/u.test(context.sourceCode.getText(node))) return;
428
379
  context.report({
429
380
  messageId: "default",
430
381
  node,
@@ -435,11 +386,8 @@ function create$2(context) {
435
386
  messageId: "removeSemicolon"
436
387
  }]
437
388
  });
438
- };
439
- return {
440
- JSXText: visit,
441
- Literal: visit
442
- };
389
+ }
390
+ return { JSXText: visit };
443
391
  }
444
392
 
445
393
  //#endregion
@@ -459,7 +407,7 @@ var no_namespace_default = createRule({
459
407
  function create$1(context) {
460
408
  return { JSXElement(node) {
461
409
  const name = getElementFullType(node);
462
- if (typeof name !== "string" || !name.includes(":")) return;
410
+ if (!name.includes(":")) return;
463
411
  context.report({
464
412
  data: { name },
465
413
  messageId: "noNamespace",
@@ -492,7 +440,6 @@ const schema = [{
492
440
  var no_useless_fragment_default = createRule({
493
441
  meta: {
494
442
  type: "suggestion",
495
- defaultOptions: [...defaultOptions],
496
443
  docs: { description: "Disallows useless fragment elements." },
497
444
  fixable: "code",
498
445
  messages: { default: "A fragment {{reason}} is useless." },
@@ -503,89 +450,96 @@ var no_useless_fragment_default = createRule({
503
450
  defaultOptions
504
451
  });
505
452
  function create(context, [option]) {
506
- const { allowEmptyFragment = false, allowExpressions = true } = option;
453
+ const options = {
454
+ allowEmptyFragment: option.allowEmptyFragment ?? false,
455
+ allowExpressions: option.allowExpressions ?? true
456
+ };
507
457
  const jsxConfig = core.getJsxConfig(context);
508
- /**
509
- * Whether the fragment has too few meaningful children to justify its
510
- * existence (the "contains less than two children" reason).
511
- * @param node The fragment node to check.
512
- */
513
- function isContentUseless(node) {
514
- if (node.children.length === 0) return !allowEmptyFragment;
515
- const insideJsx = Check.isJSXElementOrFragment(node.parent);
516
- if (!allowExpressions) {
517
- if (insideJsx) return true;
518
- if (node.children.length === 1) return true;
519
- }
520
- if (allowExpressions && !insideJsx && node.children.length === 1) {
521
- const child = node.children[0];
522
- if (child != null && child.type === AST_NODE_TYPES.JSXText) return false;
523
- }
524
- const meaningful = getChildren(node);
525
- if (meaningful.length === 0) return true;
526
- if (meaningful.length === 1 && meaningful[0].type !== AST_NODE_TYPES.JSXExpressionContainer) return true;
527
- return false;
528
- }
529
- /**
530
- * Whether it is safe to auto-fix the fragment by unwrapping it.
531
- * @param node The fragment node to check.
532
- */
533
- function isSafeToFix(node) {
534
- if (Check.isJSXElementOrFragment(node.parent)) return isHostElement(node.parent);
535
- if (node.children.length === 0) return false;
536
- return !node.children.some((child) => {
537
- if (child.type === AST_NODE_TYPES.JSXExpressionContainer) return true;
538
- if (child.type === AST_NODE_TYPES.JSXText) return !isWhitespaceText(child);
539
- return false;
540
- });
541
- }
542
- /**
543
- * Build an autofix that unwraps the fragment, replacing it with its
544
- * meaningful children content. Returns `null` when the fix is unsafe.
545
- * @param node The fragment node to fix.
546
- */
547
- function buildFix(node) {
548
- if (!isSafeToFix(node)) return null;
549
- return (fixer) => {
550
- let text = "";
551
- for (const child of node.children) if (child.type === AST_NODE_TYPES.JSXText) {
552
- const cleaned = collapseMultilineText(child.value);
553
- if (cleaned != null) text += cleaned;
554
- } else text += context.sourceCode.getText(child);
555
- return fixer.replaceText(node, text);
556
- };
557
- }
558
- /**
559
- * Inspect a fragment node and report if it is useless.
560
- *
561
- * A fragment may be reported for **two independent reasons** on the same
562
- * node (e.g. `<p><>foo</></p>` is both "placed inside a host component"
563
- * and* "contains less than two children").
564
- * @param node The fragment node to inspect.
565
- */
566
- function visit(node) {
567
- if (isHostElement(node.parent)) context.report({
568
- data: { reason: "placed inside a host component" },
569
- fix: buildFix(node),
570
- messageId: "default",
571
- node
572
- });
573
- if (isContentUseless(node)) context.report({
574
- data: { reason: "contains less than two children" },
575
- fix: buildFix(node),
576
- messageId: "default",
577
- node
578
- });
579
- }
580
458
  return {
581
459
  JSXElement(node) {
582
460
  if (!isFragmentElement(node, jsxConfig.jsxFragmentFactory)) return;
583
461
  if (hasAnyAttribute(context, node, ["key", "ref"])) return;
584
- visit(node);
462
+ visitFragment(context, node, options);
585
463
  },
586
464
  JSXFragment(node) {
587
- visit(node);
465
+ visitFragment(context, node, options);
466
+ }
467
+ };
468
+ }
469
+ /**
470
+ * Inspect a fragment node and report if it is useless.
471
+ *
472
+ * A fragment may be reported for **two independent reasons** on the same
473
+ * node (e.g. `<p><>foo</></p>` is both "placed inside a host component"
474
+ * and "contains less than two children").
475
+ * @param context The rule context.
476
+ * @param node The fragment node to inspect.
477
+ * @param options The resolved rule options.
478
+ */
479
+ function visitFragment(context, node, options) {
480
+ const reasons = [];
481
+ if (isHostElement(node.parent)) reasons.push("placed inside a host component");
482
+ if (isContentUseless(node, options)) reasons.push("contains less than two children");
483
+ if (reasons.length === 0) return;
484
+ const fix = buildFix(context, node);
485
+ for (const reason of reasons) context.report({
486
+ data: { reason },
487
+ fix,
488
+ messageId: "default",
489
+ node
490
+ });
491
+ }
492
+ /**
493
+ * Whether the fragment has too few meaningful children to justify its
494
+ * existence (the "contains less than two children" reason).
495
+ * @param node The fragment node to check.
496
+ * @param options The resolved rule options.
497
+ */
498
+ function isContentUseless(node, options) {
499
+ const { allowEmptyFragment, allowExpressions } = options;
500
+ if (node.children.length === 0) return !allowEmptyFragment;
501
+ const insideJsx = Check.isJSXElementOrFragment(node.parent);
502
+ if (allowExpressions && !insideJsx && node.children.length === 1 && node.children[0]?.type === AST_NODE_TYPES.JSXText) return false;
503
+ const meaningful = getChildren(node);
504
+ if (meaningful.length === 0) return true;
505
+ if (meaningful.length > 1) return false;
506
+ if (meaningful[0]?.type === AST_NODE_TYPES.JSXExpressionContainer) return !allowExpressions;
507
+ return true;
508
+ }
509
+ /**
510
+ * Whether it is safe to auto-fix the fragment by unwrapping it.
511
+ * @param node The fragment node to check.
512
+ */
513
+ function isSafeToFix(node) {
514
+ if (node.type === AST_NODE_TYPES.JSXElement && node.openingElement.attributes.some((attr) => attr.type === AST_NODE_TYPES.JSXSpreadAttribute)) return false;
515
+ if (Check.isJSXElementOrFragment(node.parent)) return isHostElement(node.parent);
516
+ if (node.children.length === 0) return false;
517
+ return node.children.every((child) => {
518
+ if (Check.isJSXElementOrFragment(child)) return true;
519
+ return child.type === AST_NODE_TYPES.JSXText && isWhitespaceText(child);
520
+ });
521
+ }
522
+ /**
523
+ * Build an autofix that unwraps the fragment, replacing it with its
524
+ * meaningful children content. Returns `null` when the fix is unsafe.
525
+ * @param context The rule context.
526
+ * @param node The fragment node to fix.
527
+ */
528
+ function buildFix(context, node) {
529
+ if (!isSafeToFix(node)) return null;
530
+ return (fixer) => {
531
+ const sourceCode = context.sourceCode;
532
+ let text = "";
533
+ for (const child of node.children) if (child.type === AST_NODE_TYPES.JSXText) {
534
+ const cleaned = collapseMultilineText(child.value);
535
+ if (cleaned != null) text += cleaned;
536
+ } else text += sourceCode.getText(child);
537
+ let start = node.range[0];
538
+ if (text === "" && node.children.length > 0) {
539
+ const lineStart = Math.max(sourceCode.text.lastIndexOf("\n", start - 1), sourceCode.text.lastIndexOf("\r", start - 1)) + 1;
540
+ if (/^[ \t]*$/u.test(sourceCode.text.slice(lineStart, start))) start = lineStart;
588
541
  }
542
+ return fixer.replaceTextRange([start, node.range[1]], text);
589
543
  };
590
544
  }
591
545
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-react-jsx",
3
- "version": "5.16.0",
3
+ "version": "5.17.0",
4
4
  "description": "ESLint React's ESLint plugin for React Flavored JSX rules.",
5
5
  "keywords": [
6
6
  "react",
@@ -39,11 +39,11 @@
39
39
  "dependencies": {
40
40
  "@typescript-eslint/types": "^8.64.0",
41
41
  "@typescript-eslint/utils": "^8.64.0",
42
- "@eslint-react/core": "5.16.0",
43
- "@eslint-react/jsx": "5.16.0",
44
- "@eslint-react/ast": "5.16.0",
45
- "@eslint-react/shared": "5.16.0",
46
- "@eslint-react/eslint": "5.16.0"
42
+ "@eslint-react/ast": "5.17.0",
43
+ "@eslint-react/core": "5.17.0",
44
+ "@eslint-react/eslint": "5.17.0",
45
+ "@eslint-react/jsx": "5.17.0",
46
+ "@eslint-react/shared": "5.17.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/react": "^19.2.17",