Skip to content

Commit 01e81a3

Browse files
sonar-nigel[bot]Vibe Botclaude
authored
JS-1303 Fix S6440 false positive on underscore-prefixed components typed as FC (#6388)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent e5c49e7 commit 01e81a3

3 files changed

Lines changed: 239 additions & 8 deletions

File tree

its/ruling/src/test/expected/jsts/eigen/typescript-S6440.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,7 @@
3333
],
3434
"eigen:src/app/Components/FancyModal/FancyModalContext.tsx": [
3535
77,
36-
86,
37-
124,
38-
125,
39-
127,
40-
129
36+
86
4137
],
4238
"eigen:src/app/Components/LoadFailureView.tsx": [
4339
36

packages/jsts/src/rules/S6440/rule.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,56 @@
1717
// https://sonarsource.github.io/rspec/#/rspec/S6441/javascript
1818

1919
import type { Rule } from 'eslint';
20+
import type { TSESTree } from '@typescript-eslint/utils';
21+
import type estree from 'estree';
2022
import eslintPlugin from 'eslint-plugin-react-hooks';
2123
const rulesOfHooks = (eslintPlugin as any).rules['rules-of-hooks'];
22-
import { detectReactRule, generateMeta, interceptReport, mergeRules } from '../helpers/index.js';
24+
import {
25+
detectReactRule,
26+
findFirstMatchingAncestor,
27+
generateMeta,
28+
interceptReport,
29+
isFunctionNode,
30+
mergeRules,
31+
} from '../helpers/index.js';
2332
import * as meta from './generated-meta.js';
2433

34+
const FC_TYPES = new Set(['FC', 'FunctionComponent']);
35+
36+
export const NOT_A_COMPONENT_MESSAGE =
37+
'that is neither a React function component nor a custom React Hook function';
38+
39+
/**
40+
* Checks whether the given function node is assigned to a variable
41+
* typed as FC, React.FC, FunctionComponent, or React.FunctionComponent.
42+
*/
43+
function isTypedAsFunctionalComponent(funcNode: TSESTree.Node): boolean {
44+
const parent = funcNode.parent;
45+
if (parent?.type !== 'VariableDeclarator') {
46+
return false;
47+
}
48+
const id = parent.id;
49+
if (id.type !== 'Identifier' || !id.typeAnnotation) {
50+
return false;
51+
}
52+
const annotation = id.typeAnnotation.typeAnnotation;
53+
if (annotation.type !== 'TSTypeReference') {
54+
return false;
55+
}
56+
const { typeName } = annotation;
57+
if (typeName.type === 'Identifier') {
58+
return FC_TYPES.has(typeName.name);
59+
}
60+
if (
61+
typeName.type === 'TSQualifiedName' &&
62+
typeName.left.type === 'Identifier' &&
63+
typeName.left.name === 'React'
64+
) {
65+
return FC_TYPES.has(typeName.right.name);
66+
}
67+
return false;
68+
}
69+
2570
export const rule: Rule.RuleModule = {
2671
meta: generateMeta(meta, { ...rulesOfHooks.meta }),
2772
create(context: Rule.RuleContext) {
@@ -33,9 +78,24 @@ export const rule: Rule.RuleModule = {
3378
const rulesOfHooksListener: Rule.RuleModule = interceptReport(
3479
rulesOfHooks,
3580
function (context: Rule.RuleContext, descriptor: Rule.ReportDescriptor) {
36-
if (isReact) {
37-
context.report(descriptor);
81+
if (!isReact) {
82+
return;
83+
}
84+
if (
85+
'message' in descriptor &&
86+
typeof descriptor.message === 'string' &&
87+
descriptor.message.includes(NOT_A_COMPONENT_MESSAGE) &&
88+
'node' in descriptor
89+
) {
90+
const hookNode = descriptor.node as unknown as TSESTree.Node;
91+
const enclosingFunction = findFirstMatchingAncestor(hookNode, node =>
92+
isFunctionNode(node as estree.Node),
93+
);
94+
if (enclosingFunction && isTypedAsFunctionalComponent(enclosingFunction)) {
95+
return;
96+
}
3897
}
98+
context.report(descriptor);
3999
},
40100
);
41101

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* SonarQube JavaScript Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
import { NoTypeCheckingRuleTester } from '../../../tests/tools/testers/rule-tester.js';
18+
import { rule } from './index.js';
19+
import { NOT_A_COMPONENT_MESSAGE } from './rule.js';
20+
import { describe, it } from 'node:test';
21+
22+
const ruleTester = new NoTypeCheckingRuleTester();
23+
24+
describe('S6440', () => {
25+
it('should not flag hooks in underscore-prefixed components typed as FC', () => {
26+
ruleTester.run('S6440', rule, {
27+
valid: [
28+
{
29+
// FC<Props> with underscore prefix
30+
code: `
31+
import { useState, useEffect, type FC } from 'react';
32+
interface Props { name: string; }
33+
const _Message: FC<Props> = (props) => {
34+
const [value, setValue] = useState(0);
35+
useEffect(() => { console.log(value); }, [value]);
36+
return <div>{props.name}</div>;
37+
};
38+
`,
39+
},
40+
{
41+
// React.FC<Props> with underscore prefix
42+
code: `
43+
import React, { useState } from 'react';
44+
interface PanelProps { title: string; }
45+
const _Panel: React.FC<PanelProps> = ({ title }) => {
46+
const [open, setOpen] = useState(false);
47+
return <div><h2>{title}</h2></div>;
48+
};
49+
`,
50+
},
51+
{
52+
// FunctionComponent with underscore prefix
53+
code: `
54+
import { useState, type FunctionComponent } from 'react';
55+
interface SidebarProps { items: string[]; }
56+
const _Sidebar: FunctionComponent<SidebarProps> = ({ items }) => {
57+
const [selected, setSelected] = useState(0);
58+
return <ul>{items.map((item, i) => <li key={i}>{item}</li>)}</ul>;
59+
};
60+
`,
61+
},
62+
{
63+
// React.FunctionComponent with underscore prefix
64+
code: `
65+
import React, { useEffect, useState } from 'react';
66+
const _Header: React.FunctionComponent<{ label: string }> = ({ label }) => {
67+
const [count, setCount] = useState(0);
68+
useEffect(() => { document.title = label; }, [label]);
69+
return <header>{label} ({count})</header>;
70+
};
71+
`,
72+
},
73+
{
74+
// FC-typed component wrapped with React.memo
75+
code: `
76+
import React, { useState, useCallback, type FC } from 'react';
77+
interface CardProps { title: string; content: string; }
78+
const _Card: FC<CardProps> = ({ title, content }) => {
79+
const [expanded, setExpanded] = useState(false);
80+
const toggle = useCallback(() => setExpanded(prev => !prev), []);
81+
return <div><h3 onClick={toggle}>{title}</h3>{expanded && <p>{content}</p>}</div>;
82+
};
83+
export const Card = React.memo(_Card);
84+
`,
85+
},
86+
{
87+
// React.FC without type parameters
88+
code: `
89+
import React, { useRef, useState, useEffect } from 'react';
90+
const _PageWrapper: React.FC = ({ children }) => {
91+
const ref = useRef(null);
92+
const [height, setHeight] = useState(undefined);
93+
useEffect(() => { console.log(height); }, [height]);
94+
return <div ref={ref}>{children}</div>;
95+
};
96+
`,
97+
},
98+
],
99+
invalid: [],
100+
});
101+
});
102+
103+
it('should still flag hooks in non-component functions without FC type', () => {
104+
ruleTester.run('S6440', rule, {
105+
valid: [],
106+
invalid: [
107+
{
108+
// Underscore-prefixed function without FC type annotation
109+
code: `
110+
import { useState } from 'react';
111+
const _helper = () => {
112+
const [val, setVal] = useState(0);
113+
return val;
114+
};
115+
`,
116+
errors: 1,
117+
},
118+
{
119+
// Non-PascalCase function without type annotation
120+
code: `
121+
import { useState } from 'react';
122+
function getState() {
123+
const [val, setVal] = useState(0);
124+
return val;
125+
}
126+
`,
127+
errors: 1,
128+
},
129+
],
130+
});
131+
});
132+
133+
it('should still flag conditional hooks in FC-typed components', () => {
134+
ruleTester.run('S6440', rule, {
135+
valid: [],
136+
invalid: [
137+
{
138+
code: `
139+
import { useState, useEffect, type FC } from 'react';
140+
const MyComponent: FC<{ flag: boolean }> = ({ flag }) => {
141+
const [val, setVal] = useState(0);
142+
if (flag) {
143+
useEffect(() => { console.log(val); }, [val]);
144+
}
145+
return <div>{val}</div>;
146+
};
147+
`,
148+
errors: 1,
149+
},
150+
],
151+
});
152+
});
153+
154+
it('should verify upstream message contains expected text', () => {
155+
ruleTester.run('S6440', rule, {
156+
valid: [],
157+
invalid: [
158+
{
159+
code: `
160+
import { useState } from 'react';
161+
const _notAComponent = () => {
162+
const [val, setVal] = useState(0);
163+
return val;
164+
};
165+
`,
166+
errors: [
167+
{
168+
message: new RegExp(NOT_A_COMPONENT_MESSAGE),
169+
},
170+
],
171+
},
172+
],
173+
});
174+
});
175+
});

0 commit comments

Comments
 (0)