Skip to content

JS-1391 Fix S6767 false positive for components exported via HOC#6704

Open
sonar-nigel[bot] wants to merge 18 commits intomasterfrom
fix/JS-1391-fix-fp-on-s6767-props-reported-unused-when-component-is-wrapped-and-exported-via-an-hoc-sonnet
Open

JS-1391 Fix S6767 false positive for components exported via HOC#6704
sonar-nigel[bot] wants to merge 18 commits intomasterfrom
fix/JS-1391-fix-fp-on-s6767-props-reported-unused-when-component-is-wrapped-and-exported-via-an-hoc-sonnet

Conversation

@sonar-nigel
Copy link
Copy Markdown
Contributor

@sonar-nigel sonar-nigel bot commented Mar 27, 2026

Rule Profile

Field Value
Rule S6767 — no-unused-prop-types
Severity / type Minor CODE_SMELL
Sonar Way Yes
Scope Main

Genuine violation (rule should report):

interface MyComponentProps {
  items: string[];
  unusedProp: string; // never accessed in the component body
}

function MyComponent({ items }: MyComponentProps) {
  return <ul>{items.map(i => <li>{i}</li>)}</ul>;
}

export default MyComponent; // not HOC-wrapped — unusedProp is a real violation

False Positive Pattern

When a React component is wrapped in a Higher-Order Component (HOC) and exported, the upstream eslint-plugin-react/no-unused-prop-types rule has no visibility into which props the HOC injects or consumes at the call site. Props that are declared in the component's interface but passed in and consumed by the HOC (e.g. dispatch from Redux connect, relay from Relay's createFragmentContainer) are flagged as unused even though they are used — just indirectly through the wrapper.

Pattern 1 — curried HOC default export:

interface MyComponentProps {
  dispatch: Dispatch; // injected by connect() — FP
  items: string[];
}

class MyComponent extends React.Component<MyComponentProps> {
  render() {
    return <ul>{this.props.items.map(i => <li>{i}</li>)}</ul>;
  }
}

export default connect(mapStateToProps)(MyComponent);

Pattern 2 — named export with HOC:

export const Container = withStyles(styles)(MyComponent);

Pattern 3 — CommonJS HOC export:

module.exports = withRouter(MyComponent);

False Negative Risk

The fix applies all-or-nothing suppression: once a component is identified as exported via an HOC, all unused-prop reports for that component are suppressed — including any prop that is genuinely declared but never accessed. This trades a small amount of precision for simplicity and correctness in the common case.

The risk is bounded: suppression is file-local, it applies only to components with a recognized direct HOC export form, and in practice HOC-decorated component interfaces are engineered to match exactly what the HOC injects. Genuinely unused props in HOC-wrapped components are very rare because the whole point of the interface is to satisfy the HOC's contract.

Example of a genuine violation that would be incorrectly suppressed:

interface MyComponentProps {
  dispatch: Dispatch;   // consumed by connect — correctly suppressed
  staleDebugFlag: boolean; // never used anywhere — incorrectly suppressed
}

class MyComponent extends React.Component<MyComponentProps> {
  render() { return <div />; }
}

export default connect(mapStateToProps)(MyComponent);

staleDebugFlag is a real unused prop, but the rule will not report it once it sees the connect()(MyComponent) export form.


Implementation

How the rule works today: The decorator.ts intercepts reports from eslint-plugin-react/no-unused-prop-types via interceptReportForReact. Before forwarding a report, it checks for opaque props usage (whole-object delegation, JSX spread, computed access) and for prop references inside forwardRef callbacks; if either suppression applies, the report is dropped.

What changed:

A third suppression layer is added after the existing two checks. When the component owning the reported prop is identified as exported via an HOC anywhere in the file, the report is suppressed.

HOC export detection — three composable export form checkers:

// Pattern 1: export default connect(...)(MyComponent)
function getDirectHocExportedComponentNamesFromDefaultExport(stmt): string[]

// Pattern 2: export const Wrapped = withRouter(MyComponent)
function getDirectHocExportedComponentNamesFromNamedExport(stmt): string[]

// Pattern 3: module.exports = withStyles(...)(MyComponent)
function getDirectHocExportedComponentNamesFromModuleExports(stmt): string[]

Each checker returns the inner component name(s) found in its export form. A per-file Set<string> of wrapped component names is lazily built once per SourceCode instance and cached in a WeakMap to avoid repeated AST scans across multiple reported issues in the same file.

Curried HOC unwinding:

function getHocWrappedComponentName(call: estree.CallExpression): string | null {
  const arg = call.arguments[0];
  if (arg.type === 'Identifier' && /^[A-Z]/.test(arg.name)) return arg.name;
  if (arg.type === 'CallExpression') return getHocWrappedComponentName(arg); // recurse
  return null;
}

This handles arbitrarily curried HOCs (hoc(cfg1)(cfg2)(Component)) by recursing until an uppercase Identifier is found as the innermost first argument.

React helper hardening in helpers/react.ts:

When TypeScript cannot resolve React.Component (e.g. declare const React: any), the matchesClassProps fallback previously accepted the first type argument from any extends clause. The new matchesClassPropsViaSyntax helper restricts this to heritage clauses whose token is ts.SyntaxKind.ExtendsKeyword and whose base expression is exactly React.Component or bare Component. A shared areMutuallyAssignable helper centralises the private checker.isTypeAssignableTo calls (with a single @ts-ignore suppression) used across four type-matching sites.

Arrow function two-phase props resolution:

matchesFunctionProps was updated to use a two-phase strategy. Phase 1 reads the props type directly from the React.FC<Props> annotation on the parent VariableDeclarator, bypassing TypeScript inference that produces an incomplete destructured type when React types resolve to any. Phase 2 falls back to the original signature-based approach. This ensures HOC-export suppression is reachable for arrow-function components whose type information degrades in no-node_modules analysis.


Technical Summary

  • packages/analysis/src/jsts/rules/S6767/decorator.ts — adds the third suppression layer (HOC export detection), the isComponentExportedViaHoc entry point, the per-file cache, and the three composable HOC export pattern checkers. (+256 lines net)
  • packages/analysis/src/jsts/rules/S6767/unit.test.ts — adds 16 test cases covering all three export forms (PropTypes and TypeScript), curried HOCs, the CommonJS pattern, and a negative boundary test confirming the two-statement split-export form is out of scope. (+388 lines net)
  • packages/analysis/src/jsts/rules/helpers/react.ts — adds matchesClassPropsViaSyntax, isReactComponentExpression, getAnnotationBasedPropsType, and the shared areMutuallyAssignable helper; updates matchesFunctionProps to use the two-phase strategy; renames findComponentNode to findOwningComponentNode. (+126 lines net)
  • 5 ruling baseline files (ant-design, console, courselit, desktop, eigen) — removes FP entries for HOC-wrapped components that are now correctly suppressed. (-185 lines net)

Net change: +743 lines added, −212 lines removed.

Vibe Bot added 4 commits March 27, 2026 15:28
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)`).
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

Ruling Report

Code no longer flagged (79 issues)

S6767

ant-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 | }

console/src/components/Header/Header.tsx:8

     6 | interface Props {
     7 |   children: Element
>    8 |   viewer: any
     9 |   project: Project
    10 |   params: any

console/src/components/Header/Header.tsx:9

     7 |   children: Element
     8 |   viewer: any
>    9 |   project: Project
    10 |   params: any
    11 |   left?: boolean

console/src/components/Header/Header.tsx:10

     8 |   viewer: any
     9 |   project: Project
>   10 |   params: any
    11 |   left?: boolean
    12 | }

console/src/components/NodeSelector/NodeSelector.tsx:17

    15 |   save: (value: string) => void
    16 |   cancel: () => void
>   17 |   onFocus?: () => void
    18 |   onKeyDown: (e: any) => void
    19 | }

...and 69 more

New issues flagged (20 issues)

S4157

TypeScript/src/compiler/core.ts:1192

  1190 |             }
  1191 | 
> 1192 |             return t => reduceLeft<(t: T) => T, T>(args, (u, f) => f(u), t);
  1193 |         }
  1194 |         else if (d) {

TypeScript/src/compiler/tsc.ts:562

   560 |         // Sort our options by their names, (e.g. "--noImplicitAny" comes before "--watch")
   561 |         const optsList = showAllOptions ?
>  562 |             optionDeclarations.slice().sort((a, b) => compareValues<string>(a.name.toLowerCase(), b.name.toLowerCase())) :
   563 |             filter(optionDeclarations.slice(), v => v.showInSimplifiedHelpView);
   564 | 

TypeScript/src/compiler/tsc.ts:562

   560 |         // Sort our options by their names, (e.g. "--noImplicitAny" comes before "--watch")
   561 |         const optsList = showAllOptions ?
>  562 |             optionDeclarations.slice().sort((a, b) => compareValues<string>(a.name.toLowerCase(), b.name.toLowerCase())) :
   563 |             filter(optionDeclarations.slice(), v => v.showInSimplifiedHelpView);
   564 | 

TypeScript/src/harness/fourslash.ts:1007

  1005 |                     ranges: references
  1006 |                 }));
> 1007 |                 this.assertObjectsEqual<ReferencesJson>(fullActual, fullExpected);
  1008 |             }
  1009 | 

TypeScript/src/harness/fourslash.ts:1007

  1005 |                     ranges: references
  1006 |                 }));
> 1007 |                 this.assertObjectsEqual<ReferencesJson>(fullActual, fullExpected);
  1008 |             }
  1009 | 

ant-design/components/upload/interface.tsx:108

   106 |     FileList: RcFile[],
   107 |   ) => BeforeUploadValueType | Promise<BeforeUploadValueType>;
>  108 |   onChange?: (info: UploadChangeParam<UploadFile<T>>) => void;
   109 |   onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
   110 |   listType?: UploadListType;

desktop/app/src/lib/git/diff.ts:578

   576 |   if (!isValidBuffer(buffer)) {
   577 |     // the buffer's diff is too large to be renderable in the UI
>  578 |     return Promise.resolve<IUnrenderableDiff>({ kind: DiffType.Unrenderable })
   579 |   }
   580 | 

postgraphql/src/graphql/utils/memoize.ts:50

    48 | 
    49 |   return (...args: Array<mixed>): T => {
>   50 |     const finalCache = args.reduce<Cache<T>>((cache, arg) => {
    51 |       // Because we can’t insert primitive values into `WeakMap`s, we need a
    52 |       // seperate map for primitive values. Here we select the appropriate map

rxjs/src/observable/GenerateObservable.ts:178

   176 |                       scheduler?: IScheduler): Observable<T> {
   177 |     if (arguments.length == 1) {
>  178 |       return new GenerateObservable<T, S>(
   179 |         (<GenerateOptions<T, S>>initialStateOrOptions).initialState,
   180 |         (<GenerateOptions<T, S>>initialStateOrOptions).condition,

rxjs/src/observable/GenerateObservable.ts:187

   185 | 
   186 |     if (resultSelectorOrObservable === undefined || isScheduler(resultSelectorOrObservable)) {
>  187 |       return new GenerateObservable<T, S>(
   188 |         <S>initialStateOrOptions,
   189 |         condition,

...and 10 more

📋 View full report

Code no longer flagged (79)

S6767

S7727

New issues flagged (20)

S4157

S6767

Vibe Bot and others added 3 commits March 30, 2026 12:43
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]>
@sonar-nigel sonar-nigel bot marked this pull request as ready for review March 31, 2026 09:33
@sonar-nigel sonar-nigel bot requested a review from a team March 31, 2026 09:33
@sonar-review-alpha
Copy link
Copy Markdown

sonar-review-alpha bot commented Mar 31, 2026

Summary

⚠️ The PR description exceeded the analysis limit and was truncated. The review may not reflect all context.

This PR adds a third suppression layer to rule S6767 (Unused React typed props) to eliminate false positives when components are wrapped in Higher-Order Components (HOCs) and directly exported.

The fix detects three direct-export patterns where an HOC wraps a component:

  • export default hoc(Component)
  • export const Wrapped = hoc(Component)
  • module.exports = hoc(Component)

The implementation recursively unwraps curried HOC chains (e.g., connect(mapState)(Component)) to identify the innermost component name, then builds a per-file cache of HOC-exported component names. When a prop is reported for a component in this cache, the violation is suppressed—acknowledging that the HOC likely injects or consumes those props.

Additionally, a hardened fallback was added to class props detection in react.ts for edge cases where TypeScript cannot resolve the inherited props property from an untyped base class (e.g., declare const React: any). The fix now validates that the base class is actually React.Component and checks mutual type assignability rather than blindly reading the first type argument.

Real-world impact: This removes ~20 false positives across ant-design and console test repositories that were caused by HOC-wrapped components.

What reviewers should know

Key files to review:

  1. decorator.ts — Entry point. Read the three suppression layers (lines 49–86) to understand the layered approach:

    • Layer 1: Opaque props usage (spread, delegation, computed access)
    • Layer 2: Prop references inside forwardRef callbacks
    • Layer 3: Component exported via HOC (new)

    Then examine isComponentExportedViaHoc() (line 194+) and the helpers that build the HOC export cache.

  2. react.ts — Look at the matchesClassProps() function (line 211+). The change filters heritage clauses to only extends (not implements), validates the base is React.Component, and adds a fallback syntax-based check for unresolved base classes.

  3. unit.test.ts — New test cases at line 296 show the three export patterns being detected and confirm that directly-exported components without HOCs still report unused props correctly.

Non-obvious design decisions:

  • Per-file caching: The HOC export cache is built once per source file and stored in a WeakMap keyed on SourceCode. This amortizes cost across multiple prop reports in the same file.
  • Coarse suppression: Once a component is detected as HOC-exported, all unused-prop reports for it are suppressed. This trades off the ability to catch genuinely unused props (false negatives) to avoid false positives. The authors note this is acceptable because HOC-wrapped props interfaces typically match the HOC's API contract.
  • Recursive HOC unwrapping: getHocWrappedComponentName() recurses through call arguments to handle curried HOCs like connect(x)(MyComponent), extracting the uppercase identifier.

Watch for:

  • The fallback in matchesClassPropsViaSyntax() only applies when TypeScript fails to resolve the inherited props via instance type. Verify the logic gates are correct (extends-only, React.Component check).
  • Test expectations in its/ruling/ were updated to remove now-suppressed false positives. This confirms the fix works on real projects.

  • Generate Walkthrough
  • Generate Diagram

🗣️ Give feedback

sonar-review-alpha[bot]

This comment was marked as resolved.

…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
sonar-review-alpha[bot]

This comment was marked as outdated.

sonar-review-alpha[bot]

This comment was marked as resolved.

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
sonar-review-alpha[bot]

This comment was marked as outdated.

@francois-mora-sonarsource
Copy link
Copy Markdown
Contributor

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.

@francois-mora-sonarsource
Copy link
Copy Markdown
Contributor

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.

@francois-mora-sonarsource
Copy link
Copy Markdown
Contributor

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?

Vibe Bot added 2 commits April 1, 2026 15:17
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.
@sonar-nigel
Copy link
Copy Markdown
Contributor Author

sonar-nigel bot commented Apr 1, 2026

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:

  • Phase 2 (two-statement form) was intentionally removed. The current implementation handles only three direct export forms: export default HOC(Comp), export const X = HOC(Comp), module.exports = HOC(Comp). Two explicit TP boundary tests document this scope decision.
  • No RSPEC changes are included in this branch (the approach document requested them — this is flagged as a gap).
  • decorator.ts grows from 183 → 402 lines (+219 lines, 119.7%), higher than the 85% previously reported because the baseline is the current master (which already had the forwardRef/opaque-props/bracket-notation layers).
  • helpers/react.ts adds: matchesClassProps fallback for extends React.Component<Props> heritage clauses (extends-only, base-class-filtered), and a two-phase matchesFunctionProps strategy for React.FC<Props> arrow functions.

JS-1391S6767 — SUSPICIOUS (updated)

Verdict: SUSPICIOUS
Note: This supersedes the previous Jira attachment. Reflects current branch after Phase 2 removal and scope refinement.

Claimed FP

Three direct export forms only: export default HOC(Comp), export const X = HOC(Comp), module.exports = HOC(Comp). Two-statement forms (const X = HOC(Comp); export { X } and const X = HOC(Comp); export default X) are intentionally out of scope, pinned by TP boundary tests.

Assessment summary

Criterion Result Notes
1 — Rule scope Main scope, production files
4 — Justification vs fix ⚠️ simple-examples.json has 4 examples; implementation handles 3
5 — Implementation vs approach ⚠️ Phase 2 omitted; RSPEC omitted; WeakMap cache + helpers changes not in approach
6 — RSPEC Approach document requires RSPEC changes; none present
10 — FP legitimacy Genuine: HOC injects props not directly accessed in body
12 — Over-inclusion Inherent trade-off; not verdict-changing
13 — Under-generalization ⚠️ Two-statement form intentionally excluded; documented via TP boundary tests
16 — Performance O(N) top-level scan with WeakMap cache; no type checker calls
17 — Code size decorator.ts +219 lines (119.7%), above 50% threshold → SUSPICIOUS

Verdict Rationale

The 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:

  1. Criterion 17 (code size): 119.7% growth of decorator.ts. SUSPICIOUS.
  2. Criterion 6 (RSPEC): No RSPEC update despite approach document requirement.
  3. Criterion 13 (under-generalization): Two-statement form intentionally out of scope.

Recommended action

Accept 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.
sonar-review-alpha[bot]

This comment was marked as resolved.

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]>
…rc/jsts/rules/helpers/react.ts

Comment: **Logic duplication:** The mutual-assignability guard — `// @ts-ignore` + `checker.isTypeAssignableTo(A, B) && checker.isTypeAssignableTo(B, A)` — appears four times in this file: here in `matchesClassProps` (lines 218–222), again in `matchesClassPropsViaSyntax` (lines 264–268), and in both phases of `matchesFunctionProps` (lines 354–358 and 370–372). Two of those four sites are added by this PR. If the private TypeScript API ever changes or needs to be swapped out, all four sites require the same edit with no compiler help to find them, and the `@ts-ignore` suppression has to be maintained in four places.

Extract a shared helper and consolidate the suppression:
```ts
// @ts-ignore — isTypeAssignableTo is a private TypeScript API
function areMutuallyAssignable(checker: ts.TypeChecker, a: ts.Type, b: ts.Type): boolean {
  return checker.isTypeAssignableTo(a, b) && checker.isTypeAssignableTo(b, a);
}
```
All four call sites then become `areMutuallyAssignable(checker, propsType, otherType)`, with the single `@ts-ignore` living in one place.

- [ ] Mark as noise
sonar-review-alpha[bot]

This comment was marked as outdated.

@francois-mora-sonarsource
Copy link
Copy Markdown
Contributor

isReactComponentExpression still looks a bit too broad here. It currently accepts any property access ending in .Component, so shapes like FooNS.Component<Props> can still be treated as React bases.

Could you restrict this check to only the two cases we actually want to recover in the fallback:

  • bare Component
  • React.Component

That would keep the fallback useful for degraded type-information cases without reopening the broader “arbitrary namespace ending in .Component” match.

Comment: `isReactComponentExpression` still looks a bit too broad here. It currently accepts any property access ending in `.Component`, so shapes like `FooNS.Component<Props>` can still be treated as React bases.

Could you restrict this check to only the two cases we actually want to recover in the fallback:
- bare `Component`
- `React.Component`

That would keep the fallback useful for degraded type-information cases without reopening the broader “arbitrary namespace ending in `.Component`” match.
@sonarqube-next
Copy link
Copy Markdown

sonarqube-next bot commented Apr 2, 2026

Copy link
Copy Markdown

@sonar-review-alpha sonar-review-alpha bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! ✅

🗣️ Give feedback

@francois-mora-sonarsource
Copy link
Copy Markdown
Contributor

LGTM - fixes S6767 false positives when React components are exported directly through HOC wrappers — #6704

@francois-mora-sonarsource francois-mora-sonarsource removed their request for review April 2, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant