Skip to content

Commit b2acd3f

Browse files
committed
Fix no-unnecessary-key false positives on render function
1 parent c1a0474 commit b2acd3f

4 files changed

Lines changed: 17 additions & 45 deletions

File tree

packages/core/docs/functions/isRenderFunctionLoose.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Function: isRenderFunctionLoose()
44

55
```ts
6-
function isRenderFunctionLoose(context: RuleContext, node: TSESTreeFunction): boolean;
6+
function isRenderFunctionLoose(context: RuleContext, node: Node): node is TSESTreeFunction;
77
```
88

99
Unsafe check whether given node is a render function
@@ -19,10 +19,10 @@ _ = <Component renderRow={() => <div />} />
1919
| Parameter | Type | Description |
2020
| ------ | ------ | ------ |
2121
| `context` | `RuleContext` | The rule context |
22-
| `node` | `TSESTreeFunction` | The AST node to check |
22+
| `node` | `Node` | The AST node to check |
2323

2424
## Returns
2525

26-
`boolean`
26+
`node is TSESTreeFunction`
2727

2828
`true` if node is a render function, `false` if not

packages/core/src/component/component-render-prop.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,21 @@ import { JsxDetectionHint, isJsxLike } from "../jsx";
1616
* @param node The AST node to check
1717
* @returns `true` if node is a render function, `false` if not
1818
*/
19-
export function isRenderFunctionLoose(context: RuleContext, node: AST.TSESTreeFunction) {
20-
const { body, parent } = node;
19+
export function isRenderFunctionLoose(context: RuleContext, node: TSESTree.Node): node is AST.TSESTreeFunction {
20+
if (!AST.isFunction(node)) return false;
2121
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
22-
if (AST.getFunctionId(node)?.name.startsWith("render")) {
23-
return parent.type === T.JSXExpressionContainer
24-
&& parent.parent.type === T.JSXAttribute
25-
&& parent.parent.name.type === T.JSXIdentifier
26-
&& parent.parent.name.name.startsWith("render");
27-
}
22+
if (AST.getFunctionId(node)?.name.startsWith("render")) return true;
2823
return isJsxLike(
2924
context.sourceCode,
30-
body,
25+
node.body,
3126
JsxDetectionHint.SkipNullLiteral
3227
| JsxDetectionHint.SkipUndefined
3328
| JsxDetectionHint.StrictLogical
3429
| JsxDetectionHint.StrictConditional,
35-
);
30+
) && node.parent.type === T.JSXExpressionContainer
31+
&& node.parent.parent.type === T.JSXAttribute
32+
&& node.parent.parent.name.type === T.JSXIdentifier
33+
&& node.parent.parent.name.name.startsWith("render");
3634
}
3735

3836
/**
@@ -51,7 +49,6 @@ export function isRenderPropLoose(context: RuleContext, node: TSESTree.JSXAttrib
5149
}
5250
return node.name.name.startsWith("render")
5351
&& node.value?.type === T.JSXExpressionContainer
54-
&& AST.isFunction(node.value.expression)
5552
&& isRenderFunctionLoose(context, node.value.expression);
5653
}
5754

packages/plugins/eslint-plugin-react-x/src/rules/no-unnecessary-key.spec.ts

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,6 @@ ruleTester.run(RULE_NAME, rule, {
8989
`,
9090
errors: [{ messageId: "noUnnecessaryKey" }],
9191
},
92-
// Invalid: static key in arrow function component
93-
{
94-
code: tsx`
95-
const ArrowComponent = () => <span key="static" />;
96-
`,
97-
errors: [{ messageId: "noUnnecessaryKey" }],
98-
},
9992
// Invalid: deeply nested unnecessary keys
10093
{
10194
code: tsx`
@@ -132,17 +125,6 @@ ruleTester.run(RULE_NAME, rule, {
132125
`,
133126
errors: [{ messageId: "noUnnecessaryKey" }],
134127
},
135-
// Invalid: unnecessary key in class component render
136-
{
137-
code: tsx`
138-
class MyComponent extends React.Component {
139-
render() {
140-
return <div key="class-static" />;
141-
}
142-
}
143-
`,
144-
errors: [{ messageId: "noUnnecessaryKey" }],
145-
},
146128
// Invalid: key on child with sibling elements (not in list context)
147129
{
148130
code: tsx`
@@ -180,13 +162,6 @@ ruleTester.run(RULE_NAME, rule, {
180162
`,
181163
errors: [{ messageId: "noUnnecessaryKey" }, { messageId: "noUnnecessaryKey" }],
182164
},
183-
// Invalid: key in immediately invoked function expression
184-
{
185-
code: tsx`
186-
const element = (() => <div key="iife" />)();
187-
`,
188-
errors: [{ messageId: "noUnnecessaryKey" }],
189-
},
190165
// Invalid: multiple levels of unnecessary keys
191166
{
192167
code: tsx`
@@ -383,6 +358,7 @@ ruleTester.run(RULE_NAME, rule, {
383358
items?.map(item => <div key={item.id} />)
384359
`,
385360
// Valid: key on element in callback passed to custom component
361+
// FIXME: Currently reports a false positive
386362
tsx`
387363
<List renderItem={(item) => <ListItem key={item.id} />} />
388364
`,

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getJsxConfigFromAnnotation,
66
getJsxConfigFromContext,
77
isJsxFragmentElement,
8+
isRenderFunctionLoose,
89
} from "@eslint-react/core";
910
import { type RuleContext, type RuleFeature } from "@eslint-react/shared";
1011
import type { TSESTree } from "@typescript-eslint/types";
@@ -54,6 +55,9 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
5455
if (isJsxFragmentElement(context, jsxElement, jsxConfig)) return;
5556
// If there is a spread attribute, it's not safe to report an unnecessary key
5657
if (jsxElement.openingElement.attributes.some((attr) => attr.type === T.JSXSpreadAttribute)) return;
58+
const isInsideRenderFunction = AST.findParentNode(jsxElement, (n) => isRenderFunctionLoose(context, n)) != null;
59+
// If inside a render function, skip checking to avoid false positives
60+
if (isInsideRenderFunction) return;
5761
// Find the parent `.map()` callback function, if it exists
5862
const mapCallback = AST.findParentNode(jsxElement, isArrayMethodCallback);
5963
// Check static keys on elements that are not in a map context
@@ -99,13 +103,8 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
99103
};
100104
}
101105

102-
/**
103-
* Checks if a node is a callback function passed to an array's `.map()` method
104-
* @param node The node to check
105-
* @returns `true` if the node is a map callback, `false` otherwise
106-
*/
107106
function isArrayMethodCallback(node: TSESTree.Node) {
108107
if (node.parent?.type !== T.CallExpression) return false;
109-
if (!AST.isArrayMapCall(node.parent) || !AST.isArrayFromCall(node.parent)) return false;
108+
if (!AST.isArrayMapCall(node.parent) && !AST.isArrayFromCall(node.parent)) return false;
110109
return AST.isOneOf([T.ArrowFunctionExpression, T.FunctionExpression])(AST.getUnderlyingExpression(node));
111110
}

0 commit comments

Comments
 (0)