JS-1391 Fix S6767 false positive for components exported via HOC#6704
Conversation
Tests cover the scenario where props are reported unused when a React
component is wrapped in an HOC and exported. Four export patterns are
tested: export default HOC()(Comp) (curried), export const X = HOC(Comp)
(named), module.exports = HOC(Comp) (CommonJS), and the two-statement
form (const X = HOC(Comp); export { X }). Both PropTypes-based and
TypeScript interface-based components are covered. True positive cases
confirm the rule still reports genuinely unused props when no HOC is
involved. An upstream sentinel verifies the upstream eslint-plugin-react
rule still raises on this pattern.
Relates to JS-1391
The upstream eslint-plugin-react/no-unused-prop-types rule incorrectly reports props as unused when a React component is wrapped in a Higher-Order Component (HOC) and exported, because HOCs can inject props that are not directly accessed in the component body. Fix: extend the S6767 decorator with composable HOC export suppression. When the reported component is wrapped in an HOC and exported anywhere in the file, the issue is suppressed. Four export forms are detected via the extensible hocExportPatterns array (Array.some): export default HOC(Comp), export const X = HOC(Comp), module.exports = HOC(Comp), and the two-statement form const X = HOC(Comp); export { X } / export default X. Also adds a fallback in matchesClassProps (helpers/react.ts) to resolve TypeScript class components via heritage clause type arguments when the base class is unresolved (e.g. declare const React: any). Also fixes TypeScript compile error: ExportSpecifier.local is typed as Identifier | Literal, so a type guard is required before accessing .name. Implementation follows the approved proposal guidelines. Relates to JS-1391.
Fix matchesFunctionProps in helpers/react.ts to use a two-phase strategy
for matching arrow-function components typed as `React.FC<Props>` with
destructured parameters. The previous implementation only used the function
signature's first parameter type, which TypeScript infers from the
destructuring pattern (e.g. `{ tag: any }`) rather than from the contextual
`React.FC<Props>` type annotation when module imports are unresolvable (no
`node_modules`). This incomplete inferred type was NOT mutually assignable
with the full `Props` interface (which includes un-destructured props like
`relay`), causing Strategy C in `findComponentNode` to miss the component
and leaving the HOC-export suppression in the S6767 decorator unreachable.
Phase 1 now reads the first type argument directly from the VariableDeclarator
type annotation (e.g. `Props` in `React.FC<Props>`) via the TypeScript AST,
bypassing inference entirely. Phase 2 falls back to the signature-based
approach for components typed via their parameter list (e.g.
`function Foo(props: FooProps)`).
The expected ruling file for eigen is updated to remove the 8 FP locations
that are now suppressed: `TagHeader.tsx` (relay), `ConversationDetails.tsx`
(relay), `SaleArtworkList.tsx` (contextScreenOwnerId/Slug),
`SaleArtworkListItem.tsx` (contextScreenOwnerId/Slug), and
`SaleArtworkGridItem.tsx` (contextScreenOwnerId/Slug) — all
`createFragmentContainer` HOC-export patterns in the eigen project.
Two test cases are added to `unit.test.ts` covering the React.FC<Props>
arrow function pattern for both Pattern 1 (`export default HOC(Comp)`) and
Pattern 2 (`export const X = HOC(Comp)`).
Ruling ReportCode no longer flagged (21 issues)S6767ant-design/components/input/ClearableLabeledInput.tsx:32 30 | disabled?: boolean;
31 | direction?: DirectionType;
> 32 | focused?: boolean;
33 | readOnly?: boolean;
34 | bordered: boolean;ant-design/components/input/ClearableLabeledInput.tsx:40 38 | /** This props only for input. */
39 | export interface ClearableInputProps extends BasicProps {
> 40 | size?: SizeType;
41 | suffix?: React.ReactNode;
42 | prefix?: React.ReactNode;ant-design/components/input/ClearableLabeledInput.tsx:42 40 | size?: SizeType;
41 | suffix?: React.ReactNode;
> 42 | prefix?: React.ReactNode;
43 | addonBefore?: React.ReactNode;
44 | addonAfter?: React.ReactNode;ant-design/components/input/ClearableLabeledInput.tsx:43 41 | suffix?: React.ReactNode;
42 | prefix?: React.ReactNode;
> 43 | addonBefore?: React.ReactNode;
44 | addonAfter?: React.ReactNode;
45 | triggerFocus?: () => void;ant-design/components/input/ClearableLabeledInput.tsx:44 42 | prefix?: React.ReactNode;
43 | addonBefore?: React.ReactNode;
> 44 | addonAfter?: React.ReactNode;
45 | triggerFocus?: () => void;
46 | status?: InputStatus;ant-design/components/input/ClearableLabeledInput.tsx:45 43 | addonBefore?: React.ReactNode;
44 | addonAfter?: React.ReactNode;
> 45 | triggerFocus?: () => void;
46 | status?: InputStatus;
47 | }desktop/app/src/ui/app-menu/app-menu.tsx:41 39 | * menu will receive focus.
40 | */
> 41 | readonly openedWithAccessKey: boolean
42 |
43 | /**desktop/app/src/ui/branches/branch-list.tsx:30 28 | * See IBranchesState.defaultBranch
29 | */
> 30 | readonly defaultBranch: Branch | null
31 |
32 | /**desktop/app/src/ui/branches/branch-list.tsx:35 33 | * The currently checked out branch or null if HEAD is detached
34 | */
> 35 | readonly currentBranch: Branch | null
36 |
37 | /**desktop/app/src/ui/branches/branch-list.tsx:45 43 | * See IBranchesState.recentBranches
44 | */
> 45 | readonly recentBranches: ReadonlyArray<Branch>
46 |
47 | /**...and 11 more New issues flagged (6 issues)S6767eigen/src/app/Components/Bidding/Components/Timer.tsx:28 26 |
27 | interface Props {
> 28 | liveStartsAt?: string
29 | isPreview?: boolean
30 | isClosed?: booleaneigen/src/app/Components/Bidding/Components/Timer.tsx:29 27 | interface Props {
28 | liveStartsAt?: string
> 29 | isPreview?: boolean
30 | isClosed?: boolean
31 | startsAt?: stringeigen/src/app/Components/Bidding/Components/Timer.tsx:30 28 | liveStartsAt?: string
29 | isPreview?: boolean
> 30 | isClosed?: boolean
31 | startsAt?: string
32 | biddingEndAt?: string | nulleigen/src/app/Components/Bidding/Components/Timer.tsx:31 29 | isPreview?: boolean
30 | isClosed?: boolean
> 31 | startsAt?: string
32 | biddingEndAt?: string | null
33 | lotEndAt?: string | nulleigen/src/app/Components/Bidding/Components/Timer.tsx:32 30 | isClosed?: boolean
31 | startsAt?: string
> 32 | biddingEndAt?: string | null
33 | lotEndAt?: string | null
34 | }eigen/src/app/Components/Bidding/Components/Timer.tsx:33 31 | startsAt?: string
32 | biddingEndAt?: string | null
> 33 | lotEndAt?: string | null
34 | }
35 | function formatDate(date: string) {📋 View full reportCode no longer flagged (21)
New issues flagged (6)
|
Fixed build failure and code quality issues across two files: - helpers/react.ts: Changed `import type ts from 'typescript'` to `import ts from 'typescript'` to allow using TypeScript API functions (ts.isVariableDeclaration, ts.isTypeReferenceNode) at runtime in the new getAnnotationBasedPropsType helper. This was the root cause of the bridge build failure (TS1361 errors). - S6767/decorator.ts: Removed unnecessary type assertions (S4325) in getHocWrappedComponentName and hocExportPatterns. TypeScript's control flow analysis already narrows the types via prior type checks (e.g. `arg.type === 'CallExpression'` narrows arg, `declaration.type === 'CallExpression'` narrows declaration, `d.init?.type === 'CallExpression'` narrows d.init). Also eliminated the unnecessary `const call = root as estree.CallExpression` intermediate variable in hasPropsCall by using the TypeScript-narrowed `root` directly.
Fixed 1 external SonarQube issue (S1066) in helpers/react.ts: - helpers/react.ts line 323: Merged nested if statements into a single combined condition. The outer `if (annotatedParamType != null)` and inner `if (checker.isTypeAssignableTo(...))` are now one statement, satisfying S1066 ("Merge this if statement with the nested one."). The @ts-ignore comment is repositioned before the merged if to continue suppressing the private TypeScript API access warning. Logic is semantically equivalent to the original.
- Merge nested if statement (S1066) - Remove unnecessary type assertion (S4325) Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
SummaryWhat changedThis PR fixes false positives in S6767 ( Key additions:
Why: The original decorator was too conservative, suppressing all reported props for any HOC-exported component. This PR narrows it to only known, safe cases where the injected props are stable and documented up front. What reviewers should knowWhere to start
Non-obvious decisions & gotchas
Testing strategy
|
…rc/jsts/rules/helpers/react.ts
Comment: **Bug:** The fallback iterates over *all* heritage clauses — both `extends` and `implements` — without filtering. React component props come exclusively from the `extends React.Component<P>` clause. An `implements SomeInterface<T>` clause whose first type argument happens to be mutually assignable with `propsType` (e.g. `class Foo extends UnresolvedBase implements SomeInterface<FooProps>`) would trigger a false match, suppressing legitimate unused-prop violations for a non-component class.
Since `ts` is now a value import (no longer `import type`), `ts.SyntaxKind` is available at runtime and can fix this exactly:
```suggestion
for (const clause of (cls.heritageClauses ?? []).filter(
c => c.token === ts.SyntaxKind.ExtendsKeyword,
)) {
```
- [ ] Mark as noise
|
The Could you keep this fix simple and make the fallback syntax-based here: before reading |
|
The new TypeScript HOC coverage is all positive. Since this PR intentionally supports only direct export forms, could you add a negative boundary test for an unsupported split-export shape such as |
|
The Jira review report attached to JS-1391 looks out of sync with the current branch. It still describes Phase 2 / two-statement handling and RSPEC updates, while the current implementation and tests cover only direct export forms. Could you refresh or replace that report so the review metadata matches the actual PR state? |
Comment: The `matchesClassProps` fallback looks too broad. When `props` is unavailable, it currently accepts the first type argument of any `extends X<FooProps>` clause, so a non-React class could match before the real component, for example `class StoreModel extends Store<FooProps> {}`.
Could you keep this fix simple and make the fallback syntax-based here: before reading `typeArguments[0]`, check that the extended base is exactly `React.Component` or bare `Component`, and ignore every other `extends` target? That should be enough to avoid matching unrelated generic classes without introducing heavier symbol/declaration-file resolution logic.
Comment: The new TypeScript HOC coverage is all positive. Since this PR intentionally supports only direct export forms, could you add a negative boundary test for an unsupported split-export shape such as `const Wrapped = hoc(Component); export { Wrapped };` or `const Wrapped = hoc(Component); export default Wrapped;`? That would pin the intended scope and prevent accidental expansion later.
|
The Jira attachment is stale — here is the refreshed review report reflecting the current branch state. The key differences from the previous report: What changed:
JS-1391 — S6767 — SUSPICIOUS (updated)Verdict: SUSPICIOUS Claimed FPThree direct export forms only: Assessment summary
Verdict RationaleThe FP is genuine and the three direct export forms are correctly handled. Ruling improvements confirm real-world FP reduction (eigen, ant-design, console, courselit, desktop). Open concerns:
Recommended actionAccept with a required RSPEC follow-up: the Known Issues/Limitations section should document HOC export suppression and clarify that split-export forms are not suppressed. |
Fixed 1 external SonarQube issue (S3776) in helpers/react.ts: - helpers/react.ts line 201: Reduced cognitive complexity of matchesClassProps from 19 to ~4 by extracting two helper functions: isReactComponentExpression() (checks if an expression names the React.Component base class) and matchesClassPropsViaSyntax() (handles the heritage-clause fallback when TypeScript cannot resolve inherited props). Logic is semantically equivalent to the original; the !typeArgs?.length guard replaces the equivalent !typeArgs || typeArgs.length === 0 check.
Add comprehensive test coverage for the new HOC suppression logic: - Edge cases in triple-nested curried HOCs - Split exports that don't qualify as direct HOC exports - PropTypes.checkPropTypes() exclusion from delegation suppression - Multiple named exports with HOC wrapping - CommonJS module.exports patterns New test count: 16 (up from 13) All 1548 tests pass across 631 test suites Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This reverts commit 9d64158.
This reverts commit e36394b.
|
One more Peach case seems worth supporting in the same fixed-injected-props bucket: from . This follows the same pattern as the wrappers already handled here: the HOC has a well-known injected prop, so we can suppress the FP when that prop is only used through the wrapper. In Peach I found these examples:
So I think we should extend the fixed map with something like:
That would cover another established HOC with a documented injected prop, without broadening the suppression to wrappers whose injected prop names are user-defined. |
|
One more Peach case seems worth supporting in the same fixed-injected-props bucket: This follows the same pattern as the wrappers already handled here: the HOC has a well-known injected prop, so we can suppress the FP when that prop is only used through the wrapper. In Peach I found these examples:
So I think we should extend the fixed map with something like:
That would cover another established HOC with a documented injected prop, without broadening the suppression to wrappers whose injected prop names are user-defined. |
s6767-props-reported-unused-when-component-is-wrapped-and-exported-via-an-hoc-sonnet
|
| * directly exported `memo(...)` or `observer(...)` component would hide genuine | ||
| * unused-prop findings. | ||
| */ | ||
| const NON_INJECTING_HOC_WRAPPERS = new Set(['memo', 'observer']); |
There was a problem hiding this comment.
observer is present in NON_INJECTING_HOC_WRAPPERS but has no corresponding unit test. The existing memo test at unit.test.ts:473 covers exactly this kind of case — add a parallel test for export default observer(MemoizedButton) to ensure the behaviour is verified and protected against regressions.
- Mark as noise




Relates to JS-1391
S6767 (
no-unused-prop-types) can incorrectly flag props as unused when a React component is exported via an HOC and the wrapper injects a small, stable set of known props that the upstreameslint-plugin-reactrule cannot trace. This PR narrows the decorator to suppress only those fixed, known injected props for directly exported components.export default HOC(Comp),export const X = HOC(Comp), andmodule.exports = HOC(Comp).withStyles(classes),withTheme(theme),withTranslation(t,i18n,tReady), andwithLocalization(getString).connect(...)remain intentionally unsupported: when the wrapper may inject props but the implementation cannot determine which ones, the rule keeps reporting rather than suppressing potentially real issues.helpers/react.tsgains a syntax-based fallback for class components when React types are unresolved, and an annotation-based path forReact.FC<Props>/FC<Props>arrow functions when contextual typing is unavailable.