Skip to content

Commit f63afc8

Browse files
authored
feat(react): port no-object-type-as-default-prop rule (#2323)
1 parent 33e35b7 commit f63afc8

14 files changed

Lines changed: 989 additions & 107 deletions

‎internal/plugins/react/all.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import (
6161
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_is_mounted"
6262
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_multi_comp"
6363
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_namespace"
64+
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_object_type_as_default_prop"
6465
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_redundant_should_component_update"
6566
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_render_return_value"
6667
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/no_set_state"
@@ -163,6 +164,7 @@ func GetAllRules() []rule.Rule {
163164
no_invalid_html_attribute.NoInvalidHtmlAttributeRule,
164165
no_multi_comp.NoMultiCompRule,
165166
no_namespace.NoNamespaceRule,
167+
no_object_type_as_default_prop.NoObjectTypeAsDefaultPropRule,
166168
no_unstable_nested_components.NoUnstableNestedComponentsRule,
167169
no_unused_class_component_methods.NoUnusedClassComponentMethodsRule,
168170
no_unused_prop_types.NoUnusedPropTypesRule,

‎internal/plugins/react/reactutil/component_detect.go‎

Lines changed: 35 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package reactutil
33
import (
44
"github.com/microsoft/TypeScript/tsc/shim/ast"
55
"github.com/microsoft/TypeScript/tsc/shim/checker"
6+
"github.com/web-infra-dev/rslint/internal/utils"
67
scopeAnalysis "github.com/web-infra-dev/rslint/internal/utils/scopeanalysis"
78
)
89

@@ -325,8 +326,8 @@ func GetParentStatelessComponent(node *ast.Node, pragma string, wrappers []Compo
325326
// upstream: VariableDeclarator/PropertyAssignment use the binding name;
326327
// `Id = fn` assignments use the LHS Identifier; MemberExpression LHS
327328
// uses the rightmost property name (with `module.exports = ...` as a
328-
// special blanket-true case); a named FunctionExpression defers to its
329-
// own Identifier.
329+
// special blanket-true case). A named FunctionExpression uses its own
330+
// Identifier only after these position-specific checks.
330331
//
331332
// Pass the empty string for `pragma` to default to `DefaultReactPragma`.
332333
//
@@ -386,10 +387,8 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
386387
switch fn.Kind {
387388
case ast.KindMethodDeclaration, ast.KindGetAccessor, ast.KindSetAccessor:
388389
// Object-literal shorthand method / accessor. Upstream's Property
389-
// branch (method && !computed) | (!id && !computed) classifies the
390-
// inner FE as a component when the property key is a capitalized
391-
// Identifier AND the function returns strict JSX (isReturningJSX).
392-
// Setters naturally fail functionReturnsJSX (no return value).
390+
// branch requires a capitalized Identifier and a JSX return for
391+
// non-computed keys. Computed keys reach the later name/null gates.
393392
// Class-body occurrences have a ClassLike parent — NOT
394393
// ObjectLiteralExpression — and are excluded so they continue to go
395394
// through the ES6-class path.
@@ -398,6 +397,16 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
398397
return false
399398
}
400399
name := fn.Name()
400+
if name != nil && name.Kind == ast.KindComputedPropertyName {
401+
key := utils.ESTreeRuntimeExpression(name.AsComputedPropertyName().Expression)
402+
if key.Kind == ast.KindIdentifier && IsLowercaseFirstLetter(key.Text()) && len(utils.ESTreeParameters(fn)) > 0 {
403+
return false
404+
}
405+
if ast.IsAccessExpression(key) && !ast.IsOptionalChain(key) {
406+
return functionReturnsJSXInternal(fn, false, pragma, tc, scopes)
407+
}
408+
return functionReturnsJSXInternal(fn, true, pragma, tc, scopes) && !functionReturnsOnlyNull(fn)
409+
}
401410
if name == nil || name.Kind != ast.KindIdentifier {
402411
return false
403412
}
@@ -419,7 +428,7 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
419428
return false
420429
}
421430

422-
parent := fn.Parent
431+
parent := utils.ESTreeParent(fn)
423432
if parent == nil {
424433
return false
425434
}
@@ -428,17 +437,16 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
428437
// `isModuleExportsAssignment`.
429438
isMEAssign := false
430439
isModuleExportsAssign := false
440+
var assignedProperty *ast.Node
431441
if parent.Kind == ast.KindBinaryExpression {
432442
bin := parent.AsBinaryExpression()
433-
if bin.OperatorToken != nil && bin.OperatorToken.Kind == ast.KindEqualsToken && bin.Right == fn {
443+
if bin.OperatorToken != nil && bin.OperatorToken.Kind == ast.KindEqualsToken && utils.ESTreeRuntimeExpression(bin.Right) == fn {
434444
left := ast.SkipParentheses(bin.Left)
435-
if left.Kind == ast.KindPropertyAccessExpression {
445+
if object, property := utils.MemberExpressionParts(left); object != nil {
436446
isMEAssign = true
437-
pa := left.AsPropertyAccessExpression()
438-
obj := ast.SkipParentheses(pa.Expression)
439-
name := pa.Name()
440-
if obj.Kind == ast.KindIdentifier && obj.AsIdentifier().Text == "module" &&
441-
name != nil && name.Kind == ast.KindIdentifier && name.AsIdentifier().Text == "exports" {
447+
obj := utils.ESTreeRuntimeExpression(object)
448+
assignedProperty = utils.ESTreeRuntimeExpression(property)
449+
if IdentifierOrPrivateName(obj) == "module" && IdentifierOrPrivateName(assignedProperty) == "exports" {
442450
isModuleExportsAssign = true
443451
}
444452
}
@@ -465,7 +473,7 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
465473
// Branch 3 — early-reject in ReturnStatement / arrow-expression-body
466474
// when not strictly returning JSX.
467475
if parent.Kind == ast.KindReturnStatement ||
468-
(parent.Kind == ast.KindArrowFunction && parent.AsArrowFunction().Body == fn) {
476+
(parent.Kind == ast.KindArrowFunction && utils.ESTreeRuntimeExpression(parent.AsArrowFunction().Body) == fn) {
469477
if !functionReturnsJSXInternal(fn, false, pragma, tc, scopes) {
470478
return false
471479
}
@@ -475,19 +483,10 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
475483
// (handled; Identifier LHS path).
476484
if parent.Kind == ast.KindBinaryExpression && !isMEAssign {
477485
bin := parent.AsBinaryExpression()
478-
if bin.OperatorToken != nil && bin.OperatorToken.Kind == ast.KindEqualsToken && bin.Right == fn {
486+
if bin.OperatorToken != nil && bin.OperatorToken.Kind == ast.KindEqualsToken && utils.ESTreeRuntimeExpression(bin.Right) == fn {
479487
if !functionReturnsJSXInternal(fn, true, pragma, tc, scopes) {
480488
return false
481489
}
482-
// Named FE defers to its own id (matches upstream's final
483-
// `if (node.id)` check, which runs before the lowercase-LHS
484-
// reject in the property-assignment tail).
485-
if fn.Kind == ast.KindFunctionExpression {
486-
name := fn.Name()
487-
if name != nil && name.Kind == ast.KindIdentifier {
488-
return isFirstLetterCapitalized(name.AsIdentifier().Text)
489-
}
490-
}
491490
left := ast.SkipParentheses(bin.Left)
492491
if left.Kind == ast.KindIdentifier {
493492
return isFirstLetterCapitalized(left.AsIdentifier().Text)
@@ -498,13 +497,13 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
498497

499498
// Branches 5 & 6 — nested Arrow whose outer Arrow is itself in an
500499
// AssignmentExpression / PropertyAssignment position.
501-
if parent.Kind == ast.KindArrowFunction && parent.AsArrowFunction().Body == fn {
502-
grand := parent.Parent
500+
if parent.Kind == ast.KindArrowFunction && utils.ESTreeRuntimeExpression(parent.AsArrowFunction().Body) == fn {
501+
grand := utils.ESTreeParent(parent)
503502
if grand != nil && !isMEAssign && functionReturnsJSXInternal(fn, true, pragma, tc, scopes) {
504503
switch grand.Kind {
505504
case ast.KindBinaryExpression:
506505
bin := grand.AsBinaryExpression()
507-
if bin.OperatorToken != nil && bin.OperatorToken.Kind == ast.KindEqualsToken && bin.Right == parent {
506+
if bin.OperatorToken != nil && bin.OperatorToken.Kind == ast.KindEqualsToken && utils.ESTreeRuntimeExpression(bin.Right) == parent {
508507
left := ast.SkipParentheses(bin.Left)
509508
if left.Kind == ast.KindIdentifier {
510509
return isFirstLetterCapitalized(left.AsIdentifier().Text)
@@ -645,17 +644,9 @@ func isStatelessReactComponentCore(fn *ast.Node, pragma string, tc *checker.Chec
645644
}
646645

647646
// Branch 15 — isPropertyAssignment (MemberExpression LHS) but not
648-
// module.exports: reject when rightmost property name is lowercase.
649-
if isMEAssign && !isModuleExportsAssign {
650-
bin := parent.AsBinaryExpression()
651-
left := ast.SkipParentheses(bin.Left)
652-
if left.Kind == ast.KindPropertyAccessExpression {
653-
pa := left.AsPropertyAccessExpression()
654-
name := pa.Name()
655-
if name != nil && name.Kind == ast.KindIdentifier && !isFirstLetterCapitalized(name.AsIdentifier().Text) {
656-
return false
657-
}
658-
}
647+
// module.exports: compare property.name, not a computed string's value.
648+
if isMEAssign && !isModuleExportsAssign && !isFirstLetterCapitalized(IdentifierOrPrivateName(assignedProperty)) {
649+
return false
659650
}
660651

661652
// Branch 16 — Property parent + returns only null ⇒ undefined.
@@ -703,6 +694,8 @@ func functionReturnsOnlyNull(fn *ast.Node) bool {
703694
body = fn.AsMethodDeclaration().Body
704695
case ast.KindGetAccessor:
705696
body = fn.AsGetAccessorDeclaration().Body
697+
case ast.KindSetAccessor:
698+
body = fn.AsSetAccessorDeclaration().Body
706699
}
707700
if body == nil {
708701
return false
@@ -759,7 +752,7 @@ func isExportDefaultAssignment(node *ast.Node) bool {
759752
// `const Hello = (init(), arrow)` — whose comma Sequence sits inside parens —
760753
// still reaches the VariableDeclaration ancestor.
761754
func isInAllowedPositionForComponent(fn *ast.Node) bool {
762-
parent := skipParenParents(fn)
755+
parent := utils.ESTreeParent(fn)
763756
if parent == nil {
764757
return false
765758
}
@@ -779,28 +772,18 @@ func isInAllowedPositionForComponent(fn *ast.Node) bool {
779772
switch bin.OperatorToken.Kind {
780773
case ast.KindEqualsToken:
781774
// AssignmentExpression — always allowed when `fn` is the RHS.
782-
return bin.Right == fn
775+
return utils.ESTreeRuntimeExpression(bin.Right) == fn
783776
case ast.KindCommaToken:
784777
// SequenceExpression — only the last operand inherits its parent's
785778
// allowed-ness.
786-
if bin.Right == fn {
779+
if utils.ESTreeRuntimeExpression(bin.Right) == fn {
787780
return isInAllowedPositionForComponent(parent)
788781
}
789782
}
790783
}
791784
return false
792785
}
793786

794-
// skipParenParents walks up through ParenthesizedExpression wrappers and
795-
// returns the first non-paren ancestor of `node`, or nil.
796-
func skipParenParents(node *ast.Node) *ast.Node {
797-
p := node.Parent
798-
for p != nil && p.Kind == ast.KindParenthesizedExpression {
799-
p = p.Parent
800-
}
801-
return p
802-
}
803-
804787
// isPragmaComponentWrapperCall reports whether `call` is a React
805788
// component-wrapping call — `<pragma>.memo(fn)` / `<pragma>.forwardRef(fn)` /
806789
// bare `memo(fn)` / bare `forwardRef(fn)` — with `fn` as the first argument.

‎internal/plugins/react/reactutil/component_detect_shared.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ import (
66
scopeAnalysis "github.com/web-infra-dev/rslint/internal/utils/scopeanalysis"
77
)
88

9+
// IsDetectedStatelessComponent reports whether Components.detect registers the
10+
// function itself. Wrapper calls have no parameters and replace their inner
11+
// function in the upstream component list; async generators are excluded.
12+
func IsDetectedStatelessComponent(node *ast.Node, pragma string, tc *checker.Checker, wrappers []ComponentWrapperEntry, scopes scopeAnalysis.Provider) bool {
13+
return !IsAsyncGeneratorFunction(node) &&
14+
OutermostComponentWrapperCall(node, pragma, wrappers, tc, scopes) == nil &&
15+
IsStatelessReactComponentWithWrappers(node, pragma, tc, wrappers, scopes)
16+
}
17+
918
// IsAsyncGeneratorFunction reports whether `node` is a function expression /
1019
// declaration / object-literal shorthand method that is BOTH `async` AND a
1120
// generator (`async function*` or `async *Foo() {}`).

‎internal/plugins/react/rules/function_component_definition/function_component_definition.go‎

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ func (w *walker) fileVarType() string {
232232
}
233233

234234
func (w *walker) validate(node *ast.Node, functionType string) {
235-
if !w.isDetectedComponentNode(node) {
235+
if !reactutil.IsDetectedStatelessComponent(node, w.pragma, w.ctx.TypeChecker, w.wrappers, w.scopes) {
236236
return
237237
}
238238
// Upstream `if (node.parent && node.parent.type === 'Property') return;`.
@@ -515,29 +515,6 @@ func isFunctionExpressionWithName(node *ast.Node) bool {
515515
return node.Kind == ast.KindFunctionExpression && identifierText(node.Name()) != ""
516516
}
517517

518-
// isDetectedComponentNode answers upstream's `components.get(node)` for the
519-
// function node the rule is about to validate — that is, whether the
520-
// `Components.detect` pipeline registered THIS node (rather than nothing, or a
521-
// wrapper call around it) with a non-zero confidence.
522-
func (w *walker) isDetectedComponentNode(node *ast.Node) bool {
523-
// Confidence 0: `Components.detect`'s FunctionExpression /
524-
// FunctionDeclaration listeners permanently ban an async generator.
525-
if node.Kind != ast.KindArrowFunction && reactutil.IsAsyncGeneratorFunction(node) {
526-
return false
527-
}
528-
wrapper := reactutil.OutermostComponentWrapperCall(node, w.pragma, w.wrappers, w.ctx.TypeChecker, w.scopes)
529-
if wrapper != nil && reactutil.WrapperWrapsKnownSiblingComponent(wrapper, node) {
530-
return false
531-
}
532-
if !reactutil.IsStatelessReactComponentWithWrappers(node, w.pragma, w.ctx.TypeChecker, w.wrappers, w.scopes) {
533-
return false
534-
}
535-
// `getStatelessComponent` redirects a wrapped function to its outer-most
536-
// wrapper call, so the function node itself never enters the components
537-
// list — `components.get(node)` is null and the rule stays silent.
538-
return wrapper == nil
539-
}
540-
541518
func contains(list []string, value string) bool {
542519
for _, item := range list {
543520
if item == value {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package no_direct_mutation_state
2+
3+
import (
4+
"testing"
5+
6+
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/fixtures"
7+
"github.com/web-infra-dev/rslint/internal/rule_tester"
8+
)
9+
10+
func TestNoDirectMutationStateExtras(t *testing.T) {
11+
rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &NoDirectMutationStateRule, nil, []rule_tester.InvalidTestCase{
12+
// Computed object methods returning JSX are components upstream.
13+
// Verified with eslint-plugin-react v7.37.5 using both parsers.
14+
{Code: `
15+
const obj = {
16+
['Hello']() {
17+
this.state.x = 1;
18+
return <div/>;
19+
},
20+
};
21+
`, Tsx: true,
22+
Errors: []rule_tester.InvalidTestCaseError{
23+
{MessageId: "noDirectMutation", Message: "Do not mutate state directly. Use setState().", Line: 4, Column: 13, EndLine: 4, EndColumn: 23},
24+
}},
25+
})
26+
}

‎internal/plugins/react/rules/no_direct_mutation_state/no_direct_mutation_state_test.go‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -399,17 +399,6 @@ func TestNoDirectMutationStateRule(t *testing.T) {
399399
};
400400
`, Tsx: true},
401401

402-
// ---- Edge: computed key on shorthand method (`[Hello]() {...}`) is
403-
// NOT a component per upstream's `!node.parent.computed` guard. ----
404-
{Code: `
405-
const obj = {
406-
['Hello']() {
407-
this.state.x = 1;
408-
return <div/>;
409-
},
410-
};
411-
`, Tsx: true},
412-
413402
// ---- Edge: object-literal setter `set Hello(v) { ... }` doesn't
414403
// return JSX (no return value), so never a component. ----
415404
{Code: `
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package no_multi_comp
2+
3+
import (
4+
"testing"
5+
6+
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/fixtures"
7+
"github.com/web-infra-dev/rslint/internal/rule_tester"
8+
)
9+
10+
func TestNoMultiCompAssignmentName(t *testing.T) {
11+
// Checked against eslint-plugin-react v7.37.5: an identifier assignment
12+
// uses the binding's name even when its function expression has a name.
13+
rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &NoMultiCompRule, []rule_tester.ValidTestCase{
14+
{Code: `
15+
var helper;
16+
helper = function NamedComp() { return <div /> }
17+
class App extends React.Component { render() { return <div /> } }
18+
`, Tsx: true},
19+
}, nil)
20+
}

‎internal/plugins/react/rules/no_multi_comp/no_multi_comp_test.go‎

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,7 +1120,7 @@ func TestNoMultiCompRule(t *testing.T) {
11201120
class B extends React.Component { render() { return <div /> } }
11211121
class C extends React.Component { render() { return <div /> } }
11221122
`,
1123-
Tsx: true,
1123+
Tsx: true,
11241124
Options: map[string]interface{}{"ignoreStateless": true},
11251125
Settings: map[string]interface{}{
11261126
"componentWrapperFunctions": []interface{}{"myObserver"},
@@ -1320,25 +1320,6 @@ func TestNoMultiCompRule(t *testing.T) {
13201320
},
13211321
},
13221322

1323-
// ---- Branch 4 lock-in: named-FE id takes priority over LHS ----
1324-
// `lower = function CapitalizedFE() { return <div/> }` — even
1325-
// though LHS is lowercase, the FE's named id is capitalized so
1326-
// the FE classifies as a component (Branch 4 in
1327-
// IsStatelessReactComponentWithWrappers explicitly checks
1328-
// `fn.Kind == FunctionExpression && fn.Name() != nil` BEFORE
1329-
// looking at LHS). Pair with sibling class.
1330-
{
1331-
Code: `
1332-
var helper;
1333-
helper = function NamedComp() { return <div /> }
1334-
class App extends React.Component { render() { return <div /> } }
1335-
`,
1336-
Tsx: true,
1337-
Errors: []rule_tester.InvalidTestCaseError{
1338-
{MessageId: onlyOne, Line: 4},
1339-
},
1340-
},
1341-
13421323
// ---- Branch 5 lock-in: nested arrow whose outer arrow is in AssignmentExpression ----
13431324
// `X = () => () => <div/>` — outer arrow in AE with capitalized
13441325
// LHS, inner arrow returns JSX → inner classifies as

0 commit comments

Comments
 (0)