Skip to content

Commit b289093

Browse files
authored
feat: port rule react/checked-requires-onchange-or-readonly (#1030)
1 parent b2d99fd commit b289093

10 files changed

Lines changed: 1036 additions & 53 deletions

File tree

internal/plugins/react/all.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ package react_plugin
22

33
import (
44
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/boolean_prop_naming"
5-
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/destructuring_assignment"
65
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/button_has_type"
6+
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/checked_requires_onchange_or_readonly"
7+
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/destructuring_assignment"
78
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/display_name"
89
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/forbid_component_props"
910
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/forbid_dom_props"
@@ -79,6 +80,7 @@ func GetAllRules() []rule.Rule {
7980
return []rule.Rule{
8081
boolean_prop_naming.BooleanPropNamingRule,
8182
button_has_type.ButtonHasTypeRule,
83+
checked_requires_onchange_or_readonly.CheckedRequiresOnchangeOrReadonlyRule,
8284
destructuring_assignment.DestructuringAssignmentRule,
8385
display_name.DisplayNameRule,
8486
forbid_component_props.ForbidComponentPropsRule,

internal/plugins/react/reactutil/create_element.go

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,18 @@ import (
1212
// Pass an empty pragma to default to "React"; pass GetReactPragma(ctx.Settings)
1313
// to honor the user's `settings.react.pragma` configuration.
1414
//
15-
// Parentheses AND TS expression wrappers (`as` / `satisfies` / `<T>x` / `x!`)
16-
// are transparently skipped on both the callee itself and the pragma
17-
// identifier (e.g. `(React).createElement` / `(React as any).createElement`).
18-
// Optional-chain calls (`React?.createElement(...)`) are NOT recognized
19-
// (upstream's `node.callee.object.name` access fails on the OptionalCall
20-
// shape).
15+
// Parentheses are transparently skipped on both the callee and the pragma
16+
// identifier (e.g. `(React).createElement` / `(React.createElement)(...)`),
17+
// matching ESLint's paren flattening. TS expression wrappers
18+
// (`as` / `satisfies` / `<T>x` / `x!`) are NOT skipped, so
19+
// `(React as any).createElement` is NOT recognized — mirroring upstream, where
20+
// `node.callee.object.name` is undefined on a wrapped receiver.
21+
// Optional-chain pragma access (`React?.createElement(...)`, and the optional
22+
// call `React.createElement?.(...)`) IS recognized, matching upstream: espree
23+
// exposes `node.callee` as a `MemberExpression` (optional flag set). The one
24+
// exception is a *parenthesized* optional chain — `(React?.createElement)(...)`
25+
// — where the parens freeze the chain into a `ChainExpression` callee that
26+
// upstream does not match; this variant rejects it too.
2127
//
2228
// This non-checker variant only recognizes the member-access form. To
2329
// recognize bare `createElement(...)` calls (with the
@@ -39,7 +45,7 @@ func IsCreateElementCallWithChecker(callee *ast.Node, pragma string, tc *checker
3945
}
4046

4147
func isCreateElementCallCore(callee *ast.Node, pragma string, tc *checker.Checker) bool {
42-
return isPragmaFactoryCallCore(callee, pragma, tc, createElementOnly, false)
48+
return isPragmaFactoryCallCore(callee, pragma, tc, createElementOnly)
4349
}
4450

4551
// IsCreateOrCloneElementCall reports whether the callee resolves to
@@ -58,7 +64,7 @@ func isCreateElementCallCore(callee *ast.Node, pragma string, tc *checker.Checke
5864
// skipped — that would over-match relative to ESLint's JS-only AST and
5965
// is a divergence we deliberately avoid.
6066
func IsCreateOrCloneElementCall(callee *ast.Node, pragma string, tc *checker.Checker) bool {
61-
return isPragmaFactoryCallCore(callee, pragma, tc, createOrCloneElement, true)
67+
return isPragmaFactoryCallCore(callee, pragma, tc, createOrCloneElement)
6268
}
6369

6470
type pragmaFactoryNames int
@@ -78,22 +84,39 @@ func (k pragmaFactoryNames) matches(name string) bool {
7884
return false
7985
}
8086

81-
func isPragmaFactoryCallCore(callee *ast.Node, pragma string, tc *checker.Checker, names pragmaFactoryNames, allowOptionalChain bool) bool {
87+
func isPragmaFactoryCallCore(callee *ast.Node, pragma string, tc *checker.Checker, names pragmaFactoryNames) bool {
8288
if callee == nil {
8389
return false
8490
}
8591
if pragma == "" {
8692
pragma = DefaultReactPragma
8793
}
88-
// `IsCreateElementCall` (the public-named variant used by other rules)
89-
// historically peels TS expression wrappers off the callee itself —
90-
// keep that branch intact for backwards compatibility.
91-
// `IsCreateOrCloneElementCall`, used by `no-array-index-key`, mirrors
92-
// ESLint's JS-only AST and only skips parentheses on the callee.
93-
if names == createElementOnly {
94-
callee = SkipExpressionWrappers(callee)
95-
} else {
96-
callee = ast.SkipParentheses(callee)
94+
// Only parentheses are transparent on the callee, matching ESLint's JS-only
95+
// AST (ESTree flattens parens). TS expression wrappers
96+
// (`as` / `satisfies` / `<T>x` / `x!`) are NOT peeled: upstream reads
97+
// `node.callee.object.name`, which is undefined on a `TSAsExpression` /
98+
// `TSNonNullExpression` receiver, so `(React as any).createElement` is not
99+
// recognized — and neither is it here.
100+
// A *parenthesized* optional chain — `(React?.createElement)(...)` — has its
101+
// chain terminated by the parens: ESTree freezes it into a `ChainExpression`,
102+
// so upstream's `node.callee.type === 'MemberExpression'` check fails and the
103+
// call is NOT recognized. tsgo keeps the explicit ParenthesizedExpression
104+
// wrapper, so record whether the callee was parenthesized before flattening,
105+
// then reject it when the flattened callee is an optional chain. A
106+
// non-optional `(React.createElement)(...)` stays transparent and IS
107+
// recognized; a bare `React?.createElement(...)` / `React.createElement?.(...)`
108+
// has no wrapping paren and is likewise recognized — all matching upstream.
109+
wasParenthesized := callee.Kind == ast.KindParenthesizedExpression
110+
callee = ast.SkipParentheses(callee)
111+
if callee == nil {
112+
// Only reachable when the parenthesized inner is empty (`()`), which is
113+
// itself a syntax error (such files aren't linted); guard anyway so a
114+
// recovered AST can never nil-deref through `IsOptionalChain` or the
115+
// `callee.Kind` checks below.
116+
return false
117+
}
118+
if wasParenthesized && ast.IsOptionalChain(callee) {
119+
return false
97120
}
98121

99122
// Bare callee: `createElement(arg)` / `cloneElement(arg)` — recognized
@@ -106,26 +129,21 @@ func isPragmaFactoryCallCore(callee *ast.Node, pragma string, tc *checker.Checke
106129
return IsDestructuredFromPragmaImport(callee, pragma, tc)
107130
}
108131

109-
// Member-access callee: `<pragma>.<name>(arg)`.
132+
// Member-access callee: `<pragma>.<name>(arg)`. Optional-chain access
133+
// (`React?.createElement(...)`) is accepted — upstream's `isCreateElement`
134+
// / `isCreateCloneElement` see `node.callee` as a (possibly optional)
135+
// MemberExpression and match it just the same.
110136
if callee.Kind != ast.KindPropertyAccessExpression {
111137
return false
112138
}
113-
if !allowOptionalChain && ast.IsOptionalChain(callee) {
114-
return false
115-
}
116139
prop := callee.AsPropertyAccessExpression()
117140
nameNode := prop.Name()
118141
if nameNode.Kind != ast.KindIdentifier || !names.matches(nameNode.AsIdentifier().Text) {
119142
return false
120143
}
121-
// Pragma sub-expression: `IsCreateElementCall` historically peels TS
122-
// wrappers; `IsCreateOrCloneElementCall` only peels parens to match
123-
// ESLint's JS-only AST exactly.
124-
var pragmaExpr *ast.Node
125-
if names == createElementOnly {
126-
pragmaExpr = SkipExpressionWrappers(prop.Expression)
127-
} else {
128-
pragmaExpr = ast.SkipParentheses(prop.Expression)
129-
}
144+
// Pragma sub-expression: only parens are transparent (see above). A TS
145+
// wrapper on the receiver leaves a non-Identifier node, so it won't match —
146+
// exactly like ESLint reading `.object.name` off a wrapped receiver.
147+
pragmaExpr := ast.SkipParentheses(prop.Expression)
130148
return pragmaExpr.Kind == ast.KindIdentifier && pragmaExpr.AsIdentifier().Text == pragma
131149
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package checked_requires_onchange_or_readonly
2+
3+
import (
4+
"github.com/microsoft/typescript-go/shim/ast"
5+
"github.com/web-infra-dev/rslint/internal/plugins/react/reactutil"
6+
"github.com/web-infra-dev/rslint/internal/rule"
7+
"github.com/web-infra-dev/rslint/internal/utils"
8+
)
9+
10+
const (
11+
msgMissingProperty = "`checked` should be used with either `onChange` or `readOnly`."
12+
msgExclusiveCheckedAttribute = "Use either `checked` or `defaultChecked`, but not both."
13+
)
14+
15+
// targetProps mirrors upstream's `targetPropSet`. Only these four names are
16+
// tracked; every other attribute / property is ignored. Note the rule does
17+
// NOT inspect the `type` attribute — any `<input>` (or `createElement('input')`)
18+
// carrying `checked` is checked, regardless of `type="checkbox"` vs `"radio"`
19+
// vs anything else.
20+
var targetProps = map[string]struct{}{
21+
"checked": {},
22+
"onChange": {},
23+
"readOnly": {},
24+
"defaultChecked": {},
25+
}
26+
27+
type options struct {
28+
ignoreMissingProperties bool
29+
ignoreExclusiveCheckedAttribute bool
30+
}
31+
32+
func parseOptions(opts any) options {
33+
o := options{}
34+
optsMap := utils.GetOptionsMap(opts)
35+
if optsMap == nil {
36+
return o
37+
}
38+
if v, ok := optsMap["ignoreMissingProperties"].(bool); ok {
39+
o.ignoreMissingProperties = v
40+
}
41+
if v, ok := optsMap["ignoreExclusiveCheckedAttribute"].(bool); ok {
42+
o.ignoreExclusiveCheckedAttribute = v
43+
}
44+
return o
45+
}
46+
47+
var CheckedRequiresOnchangeOrReadonlyRule = rule.Rule{
48+
Name: "react/checked-requires-onchange-or-readonly",
49+
Run: func(ctx rule.RuleContext, opts any) rule.RuleListeners {
50+
o := parseOptions(opts)
51+
pragma := reactutil.GetReactPragma(ctx.Settings)
52+
53+
// checkAndReport mirrors upstream's `checkAttributesAndReport`: report
54+
// only when `checked` is present, then emit the exclusive-attribute and
55+
// missing-property diagnostics in that order (so a node carrying both
56+
// `checked` and `defaultChecked` reports exclusiveCheckedAttribute first).
57+
checkAndReport := func(node *ast.Node, propSet map[string]struct{}) {
58+
if _, hasChecked := propSet["checked"]; !hasChecked {
59+
return
60+
}
61+
if _, hasDefaultChecked := propSet["defaultChecked"]; !o.ignoreExclusiveCheckedAttribute && hasDefaultChecked {
62+
ctx.ReportNode(node, rule.RuleMessage{
63+
Id: "exclusiveCheckedAttribute",
64+
Description: msgExclusiveCheckedAttribute,
65+
})
66+
}
67+
_, hasOnChange := propSet["onChange"]
68+
_, hasReadOnly := propSet["readOnly"]
69+
if !o.ignoreMissingProperties && !hasOnChange && !hasReadOnly {
70+
ctx.ReportNode(node, rule.RuleMessage{
71+
Id: "missingProperty",
72+
Description: msgMissingProperty,
73+
})
74+
}
75+
}
76+
77+
// checkJsxElement handles the upstream `JSXOpeningElement` listener. In
78+
// tsgo a self-closing `<input/>` is a JsxSelfClosingElement and a paired
79+
// `<input></input>` produces a JsxOpeningElement, so both kinds route
80+
// here. The element node itself is the report target, matching upstream's
81+
// `report(..., { node })` on the JSXOpeningElement (whose loc is the
82+
// opening tag, not the whole element).
83+
checkJsxElement := func(node *ast.Node) {
84+
if reactutil.GetJsxElementTypeString(node) != "input" {
85+
return
86+
}
87+
checkAndReport(node, jsxPropSet(reactutil.GetJsxElementAttributes(node)))
88+
}
89+
90+
return rule.RuleListeners{
91+
ast.KindJsxOpeningElement: checkJsxElement,
92+
ast.KindJsxSelfClosingElement: checkJsxElement,
93+
ast.KindCallExpression: func(node *ast.Node) {
94+
call := node.AsCallExpression()
95+
if !reactutil.IsCreateElementCall(call.Expression, pragma) {
96+
return
97+
}
98+
// Upstream requires `arguments[0]` to be a string Literal "input"
99+
// and `arguments[1]` to be an ObjectExpression; anything else
100+
// (missing args, non-literal tag, non-object props) bails out.
101+
if call.Arguments == nil || len(call.Arguments.Nodes) < 2 {
102+
return
103+
}
104+
firstArg := ast.SkipParentheses(call.Arguments.Nodes[0])
105+
if firstArg.Kind != ast.KindStringLiteral || firstArg.AsStringLiteral().Text != "input" {
106+
return
107+
}
108+
secondArg := ast.SkipParentheses(call.Arguments.Nodes[1])
109+
if secondArg.Kind != ast.KindObjectLiteralExpression {
110+
return
111+
}
112+
checkAndReport(node, objectPropSet(secondArg.AsObjectLiteralExpression()))
113+
},
114+
}
115+
},
116+
}
117+
118+
// jsxPropSet collects the subset of `targetProps` present as JSX attributes,
119+
// mirroring upstream `extractTargetProps(node.attributes, 'name')`.
120+
// reactutil.GetJsxPropName returns the plain attribute name for an identifier
121+
// attribute, "ns:name" for a namespaced one, and "spread" for a spread
122+
// attribute — none of which (besides a plain identifier) can land in
123+
// targetProps, exactly like upstream's `attr.name.name` access where a
124+
// spread has no `.name` and a namespaced `.name` is a node, not a string.
125+
func jsxPropSet(attrs []*ast.Node) map[string]struct{} {
126+
set := make(map[string]struct{})
127+
for _, attr := range attrs {
128+
name := reactutil.GetJsxPropName(attr)
129+
if _, ok := targetProps[name]; ok {
130+
set[name] = struct{}{}
131+
}
132+
}
133+
return set
134+
}
135+
136+
// objectPropSet collects the subset of `targetProps` present as object keys,
137+
// mirroring upstream `extractTargetProps(secondArg.properties, 'key')`.
138+
func objectPropSet(obj *ast.ObjectLiteralExpression) map[string]struct{} {
139+
set := make(map[string]struct{})
140+
if obj.Properties == nil {
141+
return set
142+
}
143+
for _, prop := range obj.Properties.Nodes {
144+
name, ok := objectKeyName(prop)
145+
if !ok {
146+
continue
147+
}
148+
if _, isTarget := targetProps[name]; isTarget {
149+
set[name] = struct{}{}
150+
}
151+
}
152+
return set
153+
}
154+
155+
// objectKeyName reproduces upstream's `prop.key.name` access on an object
156+
// property. Upstream reads `.name` without gating on `computed`, so:
157+
// - `{ checked: … }` → "checked" (Identifier key)
158+
// - `{ checked }` → "checked" (shorthand)
159+
// - `{ [checked]: … }` → "checked" (computed Identifier — `.name` exists)
160+
// - `{ "checked": … }` → not matched (string Literal key has `.value`, not `.name`)
161+
// - `{ ["checked"]: … }` → not matched (computed string Literal key)
162+
// - `{ ...rest }` → not matched (SpreadAssignment has no `.key`)
163+
func objectKeyName(prop *ast.Node) (string, bool) {
164+
var nameNode *ast.Node
165+
switch prop.Kind {
166+
case ast.KindPropertyAssignment:
167+
nameNode = prop.AsPropertyAssignment().Name()
168+
case ast.KindShorthandPropertyAssignment:
169+
nameNode = prop.AsShorthandPropertyAssignment().Name()
170+
default:
171+
return "", false
172+
}
173+
if nameNode == nil {
174+
return "", false
175+
}
176+
switch nameNode.Kind {
177+
case ast.KindIdentifier:
178+
return nameNode.AsIdentifier().Text, true
179+
case ast.KindComputedPropertyName:
180+
inner := ast.SkipParentheses(nameNode.AsComputedPropertyName().Expression)
181+
if inner != nil && inner.Kind == ast.KindIdentifier {
182+
return inner.AsIdentifier().Text, true
183+
}
184+
}
185+
return "", false
186+
}

0 commit comments

Comments
 (0)