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)`).
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]>
Summary
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:
The implementation recursively unwraps curried HOC chains (e.g., Additionally, a hardened fallback was added to class props detection in 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 knowKey files to review:
Non-obvious design decisions:
Watch for:
|
…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.
…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
|
Could you restrict this check to only the two cases we actually want to recover in the fallback:
That would keep the fallback useful for degraded type-information cases without reopening the broader “arbitrary namespace ending in |
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.
|




Rule Profile
S6767— no-unused-prop-typesGenuine violation (rule should report):
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-typesrule 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.dispatchfrom Reduxconnect,relayfrom Relay'screateFragmentContainer) are flagged as unused even though they are used — just indirectly through the wrapper.Pattern 1 — curried HOC default export:
Pattern 2 — named export with HOC:
Pattern 3 — CommonJS HOC export:
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:
staleDebugFlagis a real unused prop, but the rule will not report it once it sees theconnect()(MyComponent)export form.Implementation
How the rule works today: The
decorator.tsintercepts reports fromeslint-plugin-react/no-unused-prop-typesviainterceptReportForReact. Before forwarding a report, it checks for opaque props usage (whole-object delegation, JSX spread, computed access) and for prop references insideforwardRefcallbacks; 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:
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 perSourceCodeinstance and cached in aWeakMapto avoid repeated AST scans across multiple reported issues in the same file.Curried HOC unwinding:
This handles arbitrarily curried HOCs (
hoc(cfg1)(cfg2)(Component)) by recursing until an uppercaseIdentifieris 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), thematchesClassPropsfallback previously accepted the first type argument from anyextendsclause. The newmatchesClassPropsViaSyntaxhelper restricts this to heritage clauses whose token ists.SyntaxKind.ExtendsKeywordand whose base expression is exactlyReact.Componentor bareComponent. A sharedareMutuallyAssignablehelper centralises the privatechecker.isTypeAssignableTocalls (with a single@ts-ignoresuppression) used across four type-matching sites.Arrow function two-phase props resolution:
matchesFunctionPropswas updated to use a two-phase strategy. Phase 1 reads the props type directly from theReact.FC<Props>annotation on the parentVariableDeclarator, bypassing TypeScript inference that produces an incomplete destructured type when React types resolve toany. 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_modulesanalysis.Technical Summary
packages/analysis/src/jsts/rules/S6767/decorator.ts— adds the third suppression layer (HOC export detection), theisComponentExportedViaHocentry 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— addsmatchesClassPropsViaSyntax,isReactComponentExpression,getAnnotationBasedPropsType, and the sharedareMutuallyAssignablehelper; updatesmatchesFunctionPropsto use the two-phase strategy; renamesfindComponentNodetofindOwningComponentNode. (+126 lines net)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.