Skip to content

Commit fbb4950

Browse files
Rel1cxCopilot
andauthored
feat(jsx): add jsx-no-leaked-semicolon rule, closes #1685 (#1686)
Signed-off-by: REL1CX <solarflarex@qq.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d2860c6 commit fbb4950

8 files changed

Lines changed: 286 additions & 0 deletions

File tree

apps/website/content/docs/rules/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"jsx-no-children-prop-with-children",
6565
"jsx-no-comment-textnodes",
6666
"jsx-no-key-after-spread",
67+
"jsx-no-leaked-semicolon",
6768
"jsx-no-namespace",
6869
"jsx-no-useless-fragment",
6970
"---RSC Rules---",

packages/plugins/eslint-plugin-react-jsx/src/configs/recommended.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const rules = {
99
"react-jsx/no-children-prop": "warn",
1010
"react-jsx/no-comment-textnodes": "warn",
1111
"react-jsx/no-key-after-spread": "error",
12+
"react-jsx/no-leaked-semicolon": "warn",
1213
"react-jsx/no-namespace": "error",
1314
} as const satisfies Record<string, RuleConfig>;
1415

packages/plugins/eslint-plugin-react-jsx/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import noChildrenPropWithChildren from "./rules/no-children-prop-with-children/n
66
import noChildrenProp from "./rules/no-children-prop/no-children-prop";
77
import noCommentTextnodes from "./rules/no-comment-textnodes/no-comment-textnodes";
88
import noKeyAfterSpread from "./rules/no-key-after-spread/no-key-after-spread";
9+
import noLeakedSemicolon from "./rules/no-leaked-semicolon/no-leaked-semicolon";
910
import noNamespace from "./rules/no-namespace/no-namespace";
1011
import noUselessFragment from "./rules/no-useless-fragment/no-useless-fragment";
1112

@@ -19,6 +20,7 @@ export const plugin = {
1920
"no-children-prop-with-children": noChildrenPropWithChildren,
2021
"no-comment-textnodes": noCommentTextnodes,
2122
"no-key-after-spread": noKeyAfterSpread,
23+
"no-leaked-semicolon": noLeakedSemicolon,
2224
"no-namespace": noNamespace,
2325
"no-useless-fragment": noUselessFragment,
2426
},
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
---
2+
title: no-leaked-semicolon
3+
description: Disallows leaked semicolons that would be rendered as text nodes in JSX.
4+
---
5+
6+
**Full Name in [`eslint-plugin-react-jsx`](https://npmx.dev/package/eslint-plugin-react-jsx/v/latest)**
7+
8+
```plain copy
9+
react-jsx/no-leaked-semicolon
10+
```
11+
12+
**Full Name in [`@eslint-react/eslint-plugin`](https://npmx.dev/package/@eslint-react/eslint-plugin/v/latest)**
13+
14+
```plain copy
15+
@eslint-react/jsx-no-leaked-semicolon
16+
```
17+
18+
**Presets**
19+
20+
`recommended`
21+
`recommended-typescript`
22+
`recommended-type-checked`
23+
`strict`
24+
`strict-typescript`
25+
`strict-type-checked`
26+
27+
## Rule Details
28+
29+
When refactoring JSX, trailing semicolons may be accidentally left immediately after JSX elements or fragments. This causes `;` to be unexpectedly rendered as text nodes:
30+
31+
```tsx
32+
// before
33+
return <div />;
34+
35+
// after
36+
return (
37+
<div>
38+
<div />;
39+
</div>
40+
);
41+
42+
## Common Violations
43+
44+
### Invalid
45+
46+
```tsx
47+
function MyComponent() {
48+
return (
49+
<div>
50+
<div />;
51+
</div>
52+
);
53+
}
54+
```
55+
56+
```tsx
57+
function MyComponent() {
58+
return (
59+
<div>
60+
<Component>
61+
<div />
62+
</Component>;
63+
</div>
64+
);
65+
}
66+
```
67+
68+
### Valid
69+
70+
```tsx
71+
function MyComponent() {
72+
return (
73+
<div>
74+
<div />
75+
</div>
76+
);
77+
}
78+
```
79+
80+
```tsx
81+
function MyComponent() {
82+
return (
83+
<div>
84+
<div />
85+
;
86+
</div>
87+
);
88+
}
89+
```
90+
91+
## Resources
92+
93+
- [Rule Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-jsx/src/rules/no-leaked-semicolon/no-leaked-semicolon.ts)
94+
- [Test Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-jsx/src/rules/no-leaked-semicolon/no-leaked-semicolon.spec.ts)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import tsx from "dedent";
2+
3+
import { ruleTester } from "../../../../../../test";
4+
import rule, { RULE_NAME } from "./no-leaked-semicolon";
5+
6+
ruleTester.run(RULE_NAME, rule, {
7+
invalid: [
8+
{
9+
code: tsx`
10+
const Component = () => {
11+
return (
12+
<div>
13+
<div />;
14+
</div>
15+
);
16+
}
17+
`,
18+
errors: [{ messageId: "default" }],
19+
},
20+
{
21+
code: tsx`
22+
const Component = () => {
23+
return (
24+
<div>
25+
<Component>
26+
<div />
27+
</Component>;
28+
</div>
29+
);
30+
}
31+
`,
32+
errors: [{ messageId: "default" }],
33+
},
34+
{
35+
code: tsx`
36+
const Component = () => (
37+
<div>
38+
<Component />;
39+
</div>
40+
)
41+
`,
42+
errors: [{ messageId: "default" }],
43+
},
44+
{
45+
code: tsx`
46+
const Component = () => {
47+
return (
48+
<>
49+
<div />;
50+
</>
51+
);
52+
}
53+
`,
54+
errors: [{ messageId: "default" }],
55+
},
56+
{
57+
code: tsx`
58+
const Component = () => {
59+
return (
60+
<>
61+
<Component>
62+
<div />
63+
</Component>;
64+
</>
65+
);
66+
}
67+
`,
68+
errors: [{ messageId: "default" }],
69+
},
70+
],
71+
valid: [
72+
tsx`
73+
const Component = () => {
74+
return (
75+
<div>
76+
<div />
77+
</div>
78+
);
79+
}
80+
`,
81+
tsx`
82+
const Component = () => {
83+
return (
84+
<div>
85+
<div />
86+
;
87+
</div>
88+
);
89+
}
90+
`,
91+
tsx`
92+
const Component = () => {
93+
return (
94+
<div>
95+
<div />{';'}
96+
</div>
97+
);
98+
}
99+
`,
100+
tsx`
101+
const Component = () => {
102+
return (
103+
<div>
104+
<div />&#59;
105+
</div>
106+
);
107+
}
108+
`,
109+
tsx`
110+
const Component = () => {
111+
return (
112+
<div>
113+
<span>;</span>
114+
<span />;<span />
115+
text; text;
116+
&amp;
117+
</div>
118+
);
119+
}
120+
`,
121+
tsx`
122+
const Component = () => {
123+
return <div />;
124+
}
125+
`,
126+
tsx`
127+
const Component = () => {
128+
return (
129+
<div>
130+
<div />text;
131+
</div>
132+
);
133+
}
134+
`,
135+
],
136+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as ast from "@eslint-react/ast";
2+
import { type RuleContext, type RuleFeature, defineRuleListener } from "@eslint-react/shared";
3+
import type { TSESTree } from "@typescript-eslint/types";
4+
5+
import { createRule } from "../../utils";
6+
7+
export const RULE_NAME = "no-leaked-semicolon";
8+
9+
export const RULE_FEATURES = [] as const satisfies RuleFeature[];
10+
11+
export type MessageID = "default";
12+
13+
export default createRule<[], MessageID>({
14+
meta: {
15+
type: "problem",
16+
docs: {
17+
description: "Disallows trailing semicolons that would be rendered as text nodes.",
18+
},
19+
messages: {
20+
default: "Leaked semicolon in JSX. This semicolon will be rendered as text nodes.",
21+
},
22+
schema: [],
23+
},
24+
name: RULE_NAME,
25+
create,
26+
defaultOptions: [],
27+
});
28+
29+
function hasLeakedSemicolon(text: string) {
30+
return text.startsWith(";\n") || text.startsWith(";\r");
31+
}
32+
33+
export function create(context: RuleContext<MessageID, []>) {
34+
const visitorFunction = (node: TSESTree.JSXText | TSESTree.Literal) => {
35+
if (!ast.isJSXElementLike(node.parent)) {
36+
return;
37+
}
38+
if (!hasLeakedSemicolon(context.sourceCode.getText(node))) {
39+
return;
40+
}
41+
context.report({
42+
messageId: "default",
43+
node,
44+
});
45+
};
46+
return defineRuleListener({
47+
JSXText: visitorFunction,
48+
Literal: visitorFunction,
49+
});
50+
}

packages/plugins/eslint-plugin/src/configs/all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const rules = {
6363
"@eslint-react/jsx-no-comment-textnodes": "warn",
6464
"@eslint-react/jsx-no-key-after-spread": "error",
6565
"@eslint-react/jsx-no-namespace": "error",
66+
"@eslint-react/jsx-no-leaked-semicolon": "warn",
6667
"@eslint-react/jsx-no-useless-fragment": "warn",
6768
"@eslint-react/rsc-function-definition": "error",
6869

packages/plugins/eslint-plugin/src/configs/jsx.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export const rules = {
77
"@eslint-react/jsx-no-children-prop-with-children": "error",
88
"@eslint-react/jsx-no-comment-textnodes": "warn",
99
"@eslint-react/jsx-no-key-after-spread": "error",
10+
"@eslint-react/jsx-no-leaked-semicolon": "warn",
1011
"@eslint-react/jsx-no-namespace": "error",
1112
} as const satisfies Record<string, RuleConfig>;

0 commit comments

Comments
 (0)