Skip to content

Commit f228424

Browse files
committed
feat(tsl-rules-of-react): add noImplicitKey rule for spread attributes
Adds a new rule that detects when spread props implicitly pass a 'key' prop to JSX elements, which can lead to unintended reconciliation behavior. Also adds react/preact dev dependencies for type checking and updates tsl-dx to use the published version.
1 parent 10ea6b3 commit f228424

File tree

5 files changed

+173
-6
lines changed

5 files changed

+173
-6
lines changed

.pkgs/configs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
"@tsconfig/node24": "^24.0.4",
1818
"@tsconfig/strictest": "^2.0.8",
1919
"tsl": "^1.0.29",
20-
"tsl-dx": "workspace:*"
20+
"tsl-dx": "^0.7.0"
2121
}
2222
}

packages/tsl-rules-of-react/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,15 @@
3434
"devDependencies": {
3535
"@local/configs": "workspace:*",
3636
"@local/eff": "workspace:*",
37+
"@types/react": "^19.2.14",
38+
"@types/react-dom": "^19.2.3",
3739
"dedent": "^1.7.1",
40+
"preact": "^10.28.3",
41+
"react": "^19.2.4",
42+
"react-dom": "^19.2.4",
3843
"tsdown": "^0.20.3",
3944
"tsl": "^1.0.29",
45+
"tsl-shared": "workspace:*",
4046
"vitest": "^4.0.18"
4147
},
4248
"peerDependencies": {

packages/tsl-rules-of-react/src/rules/rules-of-jsx.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,47 @@ test("rules-of-jsx", () => {
1010
tsx: true,
1111
ruleFn: rulesOfJsx,
1212
invalid: [
13+
{
14+
code: tsx`
15+
function Component({ data }: { data: { key: string; name: string } }) {
16+
return <div {...data} />;
17+
}
18+
`,
19+
compilerOptions: {
20+
jsx: ts.JsxEmit.ReactJSX,
21+
},
22+
errors: [
23+
{ message: messages.noImplicitKey },
24+
],
25+
},
26+
{
27+
code: tsx`
28+
interface Props { key: string; value: number }
29+
function Component({ props }: { props: Props }) {
30+
return <div {...props} />;
31+
}
32+
`,
33+
compilerOptions: {
34+
jsx: ts.JsxEmit.ReactJSX,
35+
},
36+
errors: [
37+
{ message: messages.noImplicitKey },
38+
],
39+
},
40+
{
41+
code: tsx`
42+
function Component({ item }: { item: { key: number; id: string } }) {
43+
return <div {...item} {...item} />;
44+
}
45+
`,
46+
compilerOptions: {
47+
jsx: ts.JsxEmit.ReactJSX,
48+
},
49+
errors: [
50+
{ message: messages.noImplicitKey },
51+
{ message: messages.noImplicitKey },
52+
],
53+
},
1354
{
1455
code: tsx`
1556
function Component({ props }) {
@@ -145,6 +186,38 @@ test("rules-of-jsx", () => {
145186
jsx: ts.JsxEmit.ReactNative,
146187
},
147188
},
189+
{
190+
code: tsx`
191+
import React from "react";
192+
function Component({ attrs }: { attrs: React.Attributes }) {
193+
return <div {...attrs} />;
194+
}
195+
`,
196+
compilerOptions: {
197+
jsx: ts.JsxEmit.ReactJSX,
198+
},
199+
},
200+
{
201+
code: tsx`
202+
function Component({ data }: { data: { id: string; name: string } }) {
203+
return <div {...data} />;
204+
}
205+
`,
206+
compilerOptions: {
207+
jsx: ts.JsxEmit.ReactJSX,
208+
},
209+
},
210+
{
211+
code: tsx`
212+
interface Props { id: number; value: string }
213+
function Component({ props }: { props: Props }) {
214+
return <div {...props} />;
215+
}
216+
`,
217+
compilerOptions: {
218+
jsx: ts.JsxEmit.ReactJSX,
219+
},
220+
},
148221
],
149222
});
150223
expect(ret).toBe(false);

packages/tsl-rules-of-react/src/rules/rules-of-jsx.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { type AST, defineRule } from "tsl";
2+
import { getFullyQualifiedNameEx } from "tsl-shared";
23
import ts from "typescript";
34

45
export const messages = {
5-
/**
6-
* Enforces 'key' prop placement before spread props.
7-
*/
86
keyMustBeforeSpread: "The 'key' prop must be placed before any spread props when using the new JSX transform.",
7+
noImplicitKey:
8+
"This spread attribute implicitly passes a 'key' prop to the component, which can lead to unintended consequences if the passed key is not intended for React's reconciliation process. Consider explicitly defining the 'key' prop with 'key={value}' if you intend to pass a key.",
99
/**
1010
* @todo: Add the rest of messages
1111
*/
@@ -30,6 +30,17 @@ export const rulesOfJsx = defineRule(() => {
3030
},
3131
visitor: {
3232
JsxSpreadAttribute(ctx, node) {
33+
const types = ctx.utils.unionConstituents(ctx.utils.getConstrainedTypeAtLocation(node.expression));
34+
for (const type of types) {
35+
const key = type.getProperty("key");
36+
if (key == null) continue;
37+
const fqn = getFullyQualifiedNameEx(ctx.checker, key);
38+
if (fqn.toLowerCase().trim().endsWith("react.attributes.key")) continue;
39+
ctx.report({
40+
node,
41+
message: messages.noImplicitKey,
42+
});
43+
}
3344
if (!ctx.data.isNewJsxTransform) return;
3445
const element = node.parent.parent;
3546
if (element.kind !== ts.SyntaxKind.JsxOpeningElement && element.kind !== ts.SyntaxKind.JsxSelfClosingElement) {

pnpm-lock.yaml

Lines changed: 79 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)