Skip to content

Commit 422ef62

Browse files
authored
Revert no-unnecessary-key to remove constant key checking, closes #1436 (#1439)
1 parent 2e87d7c commit 422ef62

5 files changed

Lines changed: 56 additions & 216 deletions

File tree

apps/website/content/docs/rules/overview.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ full: true
235235
| [`no-set-state-in-component-did-update`](no-set-state-in-component-did-update) | 1️⃣ 1️⃣ | | Disallows calling `this.setState` in `componentDidUpdate` outside functions such as callbacks | |
236236
| [`no-set-state-in-component-will-update`](no-set-state-in-component-will-update) | 1️⃣ 1️⃣ | | Disallows calling `this.setState` in `componentWillUpdate` outside functions such as callbacks | |
237237
| [`no-string-refs`](no-string-refs) | 2️⃣ 2️⃣ | `🔄` | Replaces string refs with callback refs | >=16.3.0 |
238-
| [`no-unnecessary-key`](no-unnecessary-key) | 0️⃣ 0️⃣ | `🧪` | Disallows unnecessary `key` props on elements | |
238+
| [`no-unnecessary-key`](no-unnecessary-key) | 0️⃣ 0️⃣ | `🧪` | Disallows unnecessary `key` props on nested child elements when rendering lists | |
239239
| [`no-unnecessary-use-callback`](no-unnecessary-use-callback) | 0️⃣ 1️⃣ | `🧪` | Disallows unnecessary usage of `useCallback` | |
240240
| [`no-unnecessary-use-memo`](no-unnecessary-use-memo) | 0️⃣ 1️⃣ | `🧪` | Disallows unnecessary usage of `useMemo` | |
241241
| [`no-unnecessary-use-ref`](no-unnecessary-use-ref) | 0️⃣ 0️⃣ | `🧪` | Disallows unnecessary usage of `useRef` | |

packages/plugins/eslint-plugin-react-x/src/rules/no-duplicate-key.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,4 +130,4 @@ function MyComponent() {
130130
- [`no-array-index-key`](./no-array-index-key)\
131131
Warns when an array `index` is used as a `key` prop.
132132
- [`no-unnecessary-key`](./no-unnecessary-key)\
133-
Disallows unnecessary `key` props on elements.
133+
Disallows unnecessary `key` props on nested child elements when rendering lists.

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

Lines changed: 1 addition & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,12 @@ react-x/no-unnecessary-key
2222

2323
## Description
2424

25-
Disallows unnecessary `key` props on elements.
25+
Disallows unnecessary `key` props on nested child elements when rendering lists.
2626

2727
When rendering a list of elements in React, the `key` prop should only be placed on the outermost element for each item in the list. Adding keys to nested child elements is redundant, can cause confusion, and may lead to subtle bugs during refactoring.
2828

2929
For example, if an element with a `key` is wrapped with a `React.Fragment` or another component, the `key` must be moved to the new wrapping element. Forgetting to remove the original `key` from the child element can lead to runtime warnings from React if it's duplicated or simply leave unnecessary code. This rule helps identify and remove these redundant `key` props.
3030

31-
Also, static `key` props on elements that are not part of a _dynamic structure_ (e.g., inside list rendering, conditional rendering, or control flow statements) are unnecessary and should be removed.
32-
3331
## Examples
3432

3533
### Failing
@@ -53,17 +51,6 @@ things.map(thing => (
5351
))
5452
```
5553

56-
```tsx
57-
// Static key on an element outside of a dynamic structure is unnecessary
58-
function ComponentWithStaticKey() {
59-
return (
60-
<div>
61-
<MyComponent key="static-key" /> {/* This key is unnecessary */}
62-
</div>
63-
);
64-
}
65-
```
66-
6754
### Passing
6855

6956
```tsx
@@ -77,34 +64,6 @@ things.map(thing => (
7764
<div>{thing.description}</div>
7865
</React.Fragment>
7966
))
80-
81-
// Keys used to re-mount components are allowed
82-
function ComponentWithDynamicKey({ someValue }) {
83-
return (
84-
<div>
85-
<MyComponent key={someValue} />
86-
</div>
87-
);
88-
}
89-
```
90-
91-
Additionally, keys are allowed in dynamic structures such as conditionals or control flow statements:
92-
93-
```tsx
94-
function ComponentWithControlFlow({items}) {
95-
const elements = [];
96-
for (const item of items) {
97-
elements.push(<MyComponent key={item.id} />);
98-
}
99-
return <div>{elements}</div>;
100-
}
101-
102-
function ComponentWithControlFlow2({cond}) {
103-
if (cond) {
104-
return <MyComponent key="key" />;
105-
}
106-
return <MyComponent />;
107-
}
10867
```
10968

11069
## Implementation

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

Lines changed: 14 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,6 @@ import rule, { RULE_NAME } from "./no-unnecessary-key";
55

66
ruleTester.run(RULE_NAME, rule, {
77
invalid: [
8-
{
9-
code: tsx`
10-
<div key="static-key"></div>
11-
`,
12-
errors: [{ messageId: "noUnnecessaryKey" }],
13-
},
14-
{
15-
code: tsx`
16-
<>
17-
<span key="child-1"></span>
18-
<span key="child-2"></span>
19-
</>
20-
`,
21-
errors: [{ messageId: "noUnnecessaryKey" }, { messageId: "noUnnecessaryKey" }],
22-
},
23-
{
24-
code: tsx`
25-
things.map(thing => {
26-
function NestedComponent() {
27-
return <span key='foo'><span key='bar' /></span>;
28-
}
29-
})
30-
`,
31-
errors: [{ messageId: "noUnnecessaryKey" }, { messageId: "noUnnecessaryKey" }],
32-
},
338
// Invalid: unnecessary key on a child element in a map
349
{
3510
code: tsx`
@@ -51,15 +26,6 @@ ruleTester.run(RULE_NAME, rule, {
5126
`,
5227
errors: [{ messageId: "noUnnecessaryKey" }, { messageId: "noUnnecessaryKey" }],
5328
},
54-
// TODO: Add support for array literal
55-
// Invalid: unnecessary key on a child element in an array literal
56-
// {
57-
// code: tsx`
58-
// const elements = [<div key='1'><p key='child' /></div>]
59-
// `,
60-
// errors: [{ messageId: "noUnnecessaryKey" }],
61-
// },
62-
// Invalid: unnecessary key within an element returned from a function expression
6329
{
6430
code: tsx`
6531
things.map(function(thing) { return <div key={thing.id}><i key='icon' /></div>; })
@@ -73,27 +39,11 @@ ruleTester.run(RULE_NAME, rule, {
7339
`,
7440
errors: [{ messageId: "noUnnecessaryKey" }],
7541
},
76-
// Invalid: unnecessary key with a static value
77-
{
78-
code: tsx`
79-
<span key="static-key"></span>
80-
`,
81-
errors: [{ messageId: "noUnnecessaryKey" }],
82-
},
83-
// Invalid: static key in a simple function component
84-
{
85-
code: tsx`
86-
function SimpleComponent() {
87-
return <div key="unnecessary" />;
88-
}
89-
`,
90-
errors: [{ messageId: "noUnnecessaryKey" }],
91-
},
9242
// Invalid: deeply nested unnecessary keys
9343
{
9444
code: tsx`
9545
things.map(thing => (
96-
<div key={thing. id}>
46+
<div key={thing.id}>
9747
<section>
9848
<article>
9949
<p key="deep-nested">Content</p>
@@ -111,57 +61,6 @@ ruleTester.run(RULE_NAME, rule, {
11161
`,
11262
errors: [{ messageId: "noUnnecessaryKey" }],
11363
},
114-
// Invalid: key with template literal value
115-
{
116-
code: tsx`
117-
<div key={\`static-template\`}></div>
118-
`,
119-
errors: [{ messageId: "noUnnecessaryKey" }],
120-
},
121-
// Invalid: key with number literal
122-
{
123-
code: tsx`
124-
<div key={123}></div>
125-
`,
126-
errors: [{ messageId: "noUnnecessaryKey" }],
127-
},
128-
// Invalid: key on child with sibling elements (not in list context)
129-
{
130-
code: tsx`
131-
function Component() {
132-
return (
133-
<div>
134-
<span key="first" />
135-
<span key="second" />
136-
</div>
137-
);
138-
}
139-
`,
140-
errors: [{ messageId: "noUnnecessaryKey" }, { messageId: "noUnnecessaryKey" }],
141-
},
142-
// Invalid: unnecessary key in flatMap callback
143-
{
144-
code: tsx`
145-
things.flatMap(thing => <div key={thing.id}><span key="child" /></div>)
146-
`,
147-
errors: [{ messageId: "noUnnecessaryKey" }],
148-
},
149-
// Invalid: key on wrapper and child in reduce
150-
// {
151-
// code: tsx`
152-
// items.reduce((acc, item) => [... acc, <div key={item.id}><p key="inner" /></div>], [])
153-
// `,
154-
// errors: [{ messageId: "noUnnecessaryKey" }],
155-
// },
156-
// Invalid: unnecessary keys in forEach (not returning elements, but still detected)
157-
{
158-
code: tsx`
159-
function Component() {
160-
return <div key="outer"><span key="inner" /></div>;
161-
}
162-
`,
163-
errors: [{ messageId: "noUnnecessaryKey" }, { messageId: "noUnnecessaryKey" }],
164-
},
16564
// Invalid: multiple levels of unnecessary keys
16665
{
16766
code: tsx`
@@ -322,9 +221,9 @@ ruleTester.run(RULE_NAME, rule, {
322221
};
323222
`,
324223
// Valid: key on element in reduce accumulator
325-
// tsx`
326-
// items.reduce((acc, item) => [...acc, <div key={item.id} />], [])
327-
// `,
224+
tsx`
225+
items.reduce((acc, item) => [...acc, <div key={item.id} />], [])
226+
`,
328227
// Valid: key with spread attribute (should be skipped)
329228
tsx`
330229
things.map(thing => <div {... props} key={thing.id} />)
@@ -380,5 +279,15 @@ ruleTester.run(RULE_NAME, rule, {
380279
tsx`
381280
[...items].map(item => <div key={item.id} />)
382281
`,
282+
// https://github.com/Rel1cx/eslint-react/issues/1436
283+
tsx`
284+
export default function App() {
285+
return [getChild()];
286+
}
287+
288+
function getChild() {
289+
return <div key="key">foo</div>;
290+
}
291+
`,
383292
],
384293
});

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

Lines changed: 39 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import {
55
getJsxConfigFromContext,
66
isJsxFragmentElement,
77
isRenderFunctionLoose,
8-
useComponentCollector,
98
} from "@eslint-react/core";
10-
import { type RuleContext, type RuleFeature, defineRuleListener } from "@eslint-react/shared";
11-
import { findEnclosingAssignmentTarget } from "@eslint-react/var";
9+
import { type RuleContext, type RuleFeature } from "@eslint-react/shared";
1210
import type { TSESTree } from "@typescript-eslint/types";
1311
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
1412
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
@@ -28,7 +26,7 @@ export default createRule<[], MessageID>({
2826
meta: {
2927
type: "problem",
3028
docs: {
31-
description: "Disallows unnecessary 'key' props on elements.",
29+
description: "Disallows unnecessary 'key' props on nested child elements when rendering lists.",
3230
},
3331
messages: {
3432
noUnnecessaryKey: "Unnecessary `key` prop on this element. {{reason}}",
@@ -47,70 +45,44 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
4745
...getJsxConfigFromContext(context),
4846
...getJsxConfigFromAnnotation(context),
4947
};
50-
const { ctx, visitor } = useComponentCollector(context);
51-
const constantKeys = new Set<TSESTree.JSXAttribute>();
52-
return defineRuleListener(
53-
visitor,
54-
{
55-
JSXAttribute(node: TSESTree.JSXAttribute) {
56-
// Check if the attribute is a `key` prop
57-
if (node.name.name !== "key") return;
58-
const jsxElement = node.parent.parent;
59-
// Always allow `<React.Fragment key={...}>` to avoid false positives
60-
if (isJsxFragmentElement(context, jsxElement, jsxConfig)) return;
61-
// If there is a spread attribute, it's not safe to report an unnecessary key
62-
if (jsxElement.openingElement.attributes.some((attr) => attr.type === T.JSXSpreadAttribute)) return;
63-
// If inside a render function, skip checking to avoid false positives
64-
if (AST.findParentNode(jsxElement, (n) => isRenderFunctionLoose(context, n)) != null) return;
65-
// Find the parent `.map()` callback function, if it exists
66-
const mapCallback = AST.findParentNode(jsxElement, isArrayMethodCallback);
67-
// Check static keys on elements that are not in a map context
68-
if (mapCallback == null || AST.findParentNode(jsxElement, AST.isFunction) !== mapCallback) {
69-
constantKeys.add(node);
70-
return;
71-
}
72-
// If the `.map()` callback is not in the same scope, exit
73-
if (context.sourceCode.getScope(mapCallback) !== context.sourceCode.getScope(jsxElement)) return;
74-
// Find the nearest parent that is either the map callback or a JSX element with a `key` prop
75-
const keyedElementOrElse = AST.findParentNode(
76-
jsxElement,
77-
(n) => {
78-
// Stop searching if we reach the map callback
79-
if (n === mapCallback) return true;
80-
// Check if the node is a JSX element with a `key` prop
81-
return AST.isJSXElement(n) && getJsxAttribute(context, n)("key") != null;
82-
},
83-
);
84-
// If the search stopped at the map callback, it means no parent element had a key
85-
// In this case, the current key is necessary, so we exit
86-
if (keyedElementOrElse == null || keyedElementOrElse === mapCallback) return;
87-
// Otherwise, a parent element with a `key` was found, so the current `key` is unnecessary
88-
context.report({
89-
messageId: "noUnnecessaryKey",
90-
node,
91-
data: { reason: "A parent element already has a `key` prop in the same list rendering context." },
92-
});
93-
},
94-
"Program:exit"(node) {
95-
const components = ctx.getAllComponents(node);
96-
for (const key of constantKeys) {
97-
// Check if the keyed element is inside dynamic structures
98-
const isInDynamicStructure = AST.findParentNode(key, (n) =>
99-
AST.isConditional(n)
100-
|| AST.isControlFlow(n)
101-
|| findEnclosingAssignmentTarget(n) != null
102-
|| components.some((comp) => comp.node === n && comp.rets.length > 1)) != null;
103-
// We cant be sure the key is unnecessary
104-
if (isInDynamicStructure) continue;
105-
context.report({
106-
messageId: "noUnnecessaryKey",
107-
node: key,
108-
data: { reason: "The `key` prop is not needed outside of dynamic rendering contexts." },
109-
});
110-
}
111-
},
48+
return {
49+
JSXAttribute(node: TSESTree.JSXAttribute) {
50+
// Check if the attribute is a `key` prop
51+
if (node.name.name !== "key") return;
52+
const jsxElement = node.parent.parent;
53+
// Always allow `<React.Fragment key={...}>` to avoid false positives
54+
if (isJsxFragmentElement(context, jsxElement, jsxConfig)) return;
55+
// If there is a spread attribute, it's not safe to report an unnecessary key
56+
if (jsxElement.openingElement.attributes.some((attr) => attr.type === T.JSXSpreadAttribute)) return;
57+
// If inside a render function, it's not safe to report an unnecessary key
58+
if (AST.findParentNode(jsxElement, (n) => isRenderFunctionLoose(context, n)) != null) return;
59+
// Find the parent `.map()` callback function, if it exists
60+
const mapCallback = AST.findParentNode(jsxElement, isArrayMethodCallback);
61+
// If the element is not in a map context, it's not safe to report an unnecessary key, @see https://github.com/Rel1cx/eslint-react/issues/1436
62+
if (mapCallback == null || AST.findParentNode(jsxElement, AST.isFunction) !== mapCallback) return;
63+
// If the `.map()` callback is not in the same scope, exit
64+
if (context.sourceCode.getScope(mapCallback) !== context.sourceCode.getScope(jsxElement)) return;
65+
// Find the nearest parent that is either the map callback or a JSX element with a `key` prop
66+
const keyedElementOrElse = AST.findParentNode(
67+
jsxElement,
68+
(n) => {
69+
// Stop searching if we reach the map callback
70+
if (n === mapCallback) return true;
71+
// Check if the node is a JSX element with a `key` prop
72+
return AST.isJSXElement(n) && getJsxAttribute(context, n)("key") != null;
73+
},
74+
);
75+
// If the search stopped at the map callback, it means no parent element had a key
76+
// In this case, the current key is necessary, so we exit
77+
if (keyedElementOrElse == null || keyedElementOrElse === mapCallback) return;
78+
// Otherwise, a parent element with a `key` was found, so the current `key` is unnecessary
79+
context.report({
80+
messageId: "noUnnecessaryKey",
81+
node,
82+
data: { reason: "A parent element already has a `key` prop in the same list rendering context." },
83+
});
11284
},
113-
);
85+
};
11486
}
11587

11688
export function getArrayMethodCallbackPosition(methodName: string) {

0 commit comments

Comments
 (0)