Skip to content

Commit b7d19ac

Browse files
authored
feat: port react-hooks/void-use-memo (#1237)
1 parent 125de9f commit b7d19ac

9 files changed

Lines changed: 1001 additions & 88 deletions

File tree

internal/plugins/react_hooks/all.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/web-infra-dev/rslint/internal/plugins/react_hooks/rules/incompatible_library"
1010
"github.com/web-infra-dev/rslint/internal/plugins/react_hooks/rules/rules_of_hooks"
1111
"github.com/web-infra-dev/rslint/internal/plugins/react_hooks/rules/use_memo"
12+
"github.com/web-infra-dev/rslint/internal/plugins/react_hooks/rules/void_use_memo"
1213
"github.com/web-infra-dev/rslint/internal/rule"
1314
)
1415

@@ -22,5 +23,6 @@ func GetAllRules() []rule.Rule {
2223
immutability.ImmutabilityRule,
2324
incompatible_library.IncompatibleLibraryRule,
2425
use_memo.UseMemoRule,
26+
void_use_memo.VoidUseMemoRule,
2527
}
2628
}

internal/plugins/react_hooks/react_hooksutil/react_hooksutil.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"strings"
1919

2020
"github.com/microsoft/typescript-go/shim/ast"
21+
"github.com/microsoft/typescript-go/shim/checker"
2122
"github.com/web-infra-dev/rslint/internal/utils"
2223
)
2324

@@ -121,6 +122,99 @@ func IsUseIdentifier(node *ast.Node) bool {
121122
return IsReactCalleeNamed(node, "use")
122123
}
123124

125+
// IsManualUseMemoCallee reports whether `node` is a direct `useMemo` or
126+
// `React.useMemo` callee according to React Compiler's manual memoization
127+
// input surface. It intentionally does not recognize import aliases or
128+
// element-access forms; existing React Compiler lint ports rely on the same
129+
// direct-call contract.
130+
func IsManualUseMemoCallee(node *ast.Node, typeChecker *checker.Checker) bool {
131+
node = ast.SkipParentheses(node)
132+
if node == nil {
133+
return false
134+
}
135+
switch node.Kind {
136+
case ast.KindIdentifier:
137+
return isManualMemoIdentifier(node, "useMemo", typeChecker)
138+
case ast.KindPropertyAccessExpression:
139+
if ast.IsOptionalChain(node) {
140+
return false
141+
}
142+
access := node.AsPropertyAccessExpression()
143+
name := access.Name()
144+
if name == nil || name.Kind != ast.KindIdentifier || name.AsIdentifier().Text != "useMemo" {
145+
return false
146+
}
147+
obj := ast.SkipParentheses(access.Expression)
148+
if obj == nil || obj.Kind != ast.KindIdentifier {
149+
return false
150+
}
151+
return isManualMemoIdentifier(obj, "React", typeChecker)
152+
}
153+
return false
154+
}
155+
156+
func isManualMemoIdentifier(id *ast.Node, expected string, typeChecker *checker.Checker) bool {
157+
if id == nil || id.Kind != ast.KindIdentifier || id.AsIdentifier().Text != expected {
158+
return false
159+
}
160+
if typeChecker == nil {
161+
return true
162+
}
163+
sym := utils.GetReferenceSymbol(id, typeChecker)
164+
if sym == nil || len(sym.Declarations) == 0 {
165+
return true
166+
}
167+
for _, decl := range sym.Declarations {
168+
if isManualMemoDeclaration(decl, expected) {
169+
return true
170+
}
171+
}
172+
return false
173+
}
174+
175+
func isManualMemoDeclaration(decl *ast.Node, expected string) bool {
176+
if decl == nil {
177+
return false
178+
}
179+
switch decl.Kind {
180+
case ast.KindImportClause, ast.KindNamespaceImport, ast.KindImportSpecifier:
181+
return true
182+
case ast.KindBindingElement:
183+
return !isInsideParameter(decl)
184+
case ast.KindVariableDeclaration:
185+
name := decl.Name()
186+
if name == nil || name.Kind != ast.KindIdentifier || name.AsIdentifier().Text != expected {
187+
return false
188+
}
189+
initializer := ast.SkipParentheses(decl.AsVariableDeclaration().Initializer)
190+
if initializer == nil {
191+
return true
192+
}
193+
if expected == "useMemo" && ast.IsFunctionExpressionOrArrowFunction(initializer) {
194+
return false
195+
}
196+
if expected == "React" && initializer.Kind == ast.KindObjectLiteralExpression {
197+
return false
198+
}
199+
return true
200+
case ast.KindParameter, ast.KindFunctionDeclaration:
201+
return false
202+
}
203+
return false
204+
}
205+
206+
func isInsideParameter(node *ast.Node) bool {
207+
for cur := node; cur != nil; cur = cur.Parent {
208+
switch cur.Kind {
209+
case ast.KindParameter:
210+
return true
211+
case ast.KindVariableDeclaration:
212+
return false
213+
}
214+
}
215+
return false
216+
}
217+
124218
// IsHookCallee mirrors upstream's `isHook(node)`: the callee is
125219
// either an Identifier whose name is a hook, or a non-computed member
126220
// expression `Namespace.useFoo` whose object is a PascalCase

internal/plugins/react_hooks/rules/use_memo/use_memo.go

Lines changed: 1 addition & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ var UseMemoRule = rule.Rule{
5353

5454
func (state *useMemoState) processCallExpression(node *ast.Node) {
5555
call := node.AsCallExpression()
56-
if call == nil || !state.isUseMemoCallee(call.Expression) {
56+
if call == nil || !react_hooksutil.IsManualUseMemoCallee(call.Expression, state.ctx.TypeChecker) {
5757
return
5858
}
5959

@@ -80,93 +80,6 @@ func (state *useMemoState) processCallExpression(node *ast.Node) {
8080
state.checkDependencyList(args.Nodes)
8181
}
8282

83-
func (state *useMemoState) isUseMemoCallee(node *ast.Node) bool {
84-
node = ast.SkipParentheses(node)
85-
if node == nil {
86-
return false
87-
}
88-
switch node.Kind {
89-
case ast.KindIdentifier:
90-
return state.isManualMemoIdentifier(node, "useMemo")
91-
case ast.KindPropertyAccessExpression:
92-
if ast.IsOptionalChain(node) {
93-
return false
94-
}
95-
name := node.AsPropertyAccessExpression().Name()
96-
if name == nil || name.Kind != ast.KindIdentifier || name.AsIdentifier().Text != "useMemo" {
97-
return false
98-
}
99-
obj := ast.SkipParentheses(node.AsPropertyAccessExpression().Expression)
100-
if obj == nil || obj.Kind != ast.KindIdentifier {
101-
return false
102-
}
103-
return state.isManualMemoIdentifier(obj, "React")
104-
}
105-
return false
106-
}
107-
108-
func (state *useMemoState) isManualMemoIdentifier(id *ast.Node, expected string) bool {
109-
if id == nil || id.Kind != ast.KindIdentifier || id.AsIdentifier().Text != expected {
110-
return false
111-
}
112-
if state.ctx.TypeChecker == nil {
113-
return true
114-
}
115-
sym := utils.GetReferenceSymbol(id, state.ctx.TypeChecker)
116-
if sym == nil || len(sym.Declarations) == 0 {
117-
return true
118-
}
119-
for _, decl := range sym.Declarations {
120-
if isManualMemoDeclaration(decl, expected) {
121-
return true
122-
}
123-
}
124-
return false
125-
}
126-
127-
func isManualMemoDeclaration(decl *ast.Node, expected string) bool {
128-
if decl == nil {
129-
return false
130-
}
131-
switch decl.Kind {
132-
case ast.KindImportClause, ast.KindNamespaceImport, ast.KindImportSpecifier:
133-
return true
134-
case ast.KindBindingElement:
135-
return !isInsideParameter(decl)
136-
case ast.KindVariableDeclaration:
137-
name := decl.Name()
138-
if name == nil || name.Kind != ast.KindIdentifier || name.AsIdentifier().Text != expected {
139-
return false
140-
}
141-
initializer := ast.SkipParentheses(decl.AsVariableDeclaration().Initializer)
142-
if initializer == nil {
143-
return true
144-
}
145-
if expected == "useMemo" && ast.IsFunctionExpressionOrArrowFunction(initializer) {
146-
return false
147-
}
148-
if expected == "React" && initializer.Kind == ast.KindObjectLiteralExpression {
149-
return false
150-
}
151-
return true
152-
case ast.KindParameter, ast.KindFunctionDeclaration:
153-
return false
154-
}
155-
return false
156-
}
157-
158-
func isInsideParameter(node *ast.Node) bool {
159-
for cur := node; cur != nil; cur = cur.Parent {
160-
switch cur.Kind {
161-
case ast.KindParameter:
162-
return true
163-
case ast.KindVariableDeclaration:
164-
return false
165-
}
166-
}
167-
return false
168-
}
169-
17083
func (state *useMemoState) checkCallback(callback *ast.Node) {
17184
params := callback.Parameters()
17285
if len(params) > 0 {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package void_use_memo
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/microsoft/typescript-go/shim/ast"
7+
"github.com/web-infra-dev/rslint/internal/plugins/react_hooks/react_hooksutil"
8+
"github.com/web-infra-dev/rslint/internal/rule"
9+
)
10+
11+
const (
12+
missingReturnReason = "useMemo() callbacks must return a value"
13+
missingReturnDescription = "This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects"
14+
unusedResultReason = "useMemo() result is unused"
15+
unusedResultDescription = "This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects"
16+
)
17+
18+
type voidUseMemoState struct {
19+
ctx rule.RuleContext
20+
}
21+
22+
// VoidUseMemoRule is the rslint port of upstream `react-hooks/void-use-memo`.
23+
//
24+
// Upstream emits this rule from React Compiler's VoidUseMemo diagnostic
25+
// category. This port validates the published local contract: useMemo
26+
// callbacks must contain an explicit or implicit return, and a returned
27+
// useMemo value must not be discarded as a bare expression statement.
28+
var VoidUseMemoRule = rule.Rule{
29+
Name: "react-hooks/void-use-memo",
30+
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
31+
state := &voidUseMemoState{ctx: ctx}
32+
return rule.RuleListeners{
33+
ast.KindCallExpression: state.processCallExpression,
34+
}
35+
},
36+
}
37+
38+
func (state *voidUseMemoState) processCallExpression(node *ast.Node) {
39+
call := node.AsCallExpression()
40+
if call == nil || !react_hooksutil.IsManualUseMemoCallee(call.Expression, state.ctx.TypeChecker) {
41+
return
42+
}
43+
args := call.Arguments
44+
if args == nil || len(args.Nodes) == 0 {
45+
return
46+
}
47+
48+
callback := ast.SkipParentheses(args.Nodes[0])
49+
if callback == nil || callback.Kind == ast.KindSpreadElement || !ast.IsFunctionExpressionOrArrowFunction(callback) {
50+
return
51+
}
52+
53+
if !hasUseMemoReturn(callback) {
54+
state.ctx.ReportNode(callback, buildVoidUseMemoMessage("missingReturn", missingReturnReason, missingReturnDescription))
55+
return
56+
}
57+
if isUnusedUseMemoResult(node) {
58+
state.ctx.ReportNode(reportableCallee(call.Expression), buildVoidUseMemoMessage("unusedResult", unusedResultReason, unusedResultDescription))
59+
}
60+
}
61+
62+
func hasUseMemoReturn(callback *ast.Node) bool {
63+
if callback == nil {
64+
return false
65+
}
66+
body := react_hooksutil.GetFunctionBody(callback)
67+
if body == nil {
68+
return false
69+
}
70+
// React Compiler treats arrow expression bodies as implicit returns.
71+
if callback.Kind == ast.KindArrowFunction {
72+
if body.Kind != ast.KindBlock {
73+
return true
74+
}
75+
}
76+
77+
// React Compiler's HIR-based terminal scan does not count returns from the
78+
// try block of a try/finally statement or from its finally block.
79+
return ast.ForEachReturnStatement(body, func(stmt *ast.Node) bool {
80+
return !isIgnoredTryFinallyReturn(stmt, callback)
81+
})
82+
}
83+
84+
func isIgnoredTryFinallyReturn(ret *ast.Node, callback *ast.Node) bool {
85+
child := ret
86+
for parent := ret.Parent; parent != nil && parent != callback; parent = parent.Parent {
87+
if react_hooksutil.IsFunctionLikeContainer(parent) {
88+
return false
89+
}
90+
if parent.Kind == ast.KindTryStatement {
91+
tryStmt := parent.AsTryStatement()
92+
if tryStmt != nil && tryStmt.FinallyBlock != nil {
93+
if tryStmt.TryBlock != nil && tryStmt.TryBlock.AsNode() == child {
94+
return true
95+
}
96+
if tryStmt.FinallyBlock.AsNode() == child {
97+
return true
98+
}
99+
}
100+
}
101+
child = parent
102+
}
103+
return false
104+
}
105+
106+
func isUnusedUseMemoResult(call *ast.Node) bool {
107+
current := call
108+
for {
109+
child, parent := walkUpResultWrappers(current, current == call)
110+
if parent == nil {
111+
return false
112+
}
113+
switch parent.Kind {
114+
case ast.KindExpressionStatement:
115+
return current == call
116+
case ast.KindBinaryExpression:
117+
// In a comma expression, every operand except the final one is
118+
// discarded even when the comma expression's final result is used.
119+
binary := parent.AsBinaryExpression()
120+
if binary == nil || binary.OperatorToken == nil || binary.OperatorToken.Kind != ast.KindCommaToken {
121+
return false
122+
}
123+
if binary.Left == child {
124+
return true
125+
}
126+
if binary.Right == child {
127+
current = parent
128+
continue
129+
}
130+
return false
131+
default:
132+
return false
133+
}
134+
}
135+
}
136+
137+
func walkUpResultWrappers(node *ast.Node, allowNonNull bool) (*ast.Node, *ast.Node) {
138+
child := node
139+
parent := child.Parent
140+
kinds := ast.OEKParentheses
141+
if allowNonNull {
142+
kinds |= ast.OEKNonNullAssertions
143+
}
144+
for parent != nil && ast.IsOuterExpression(parent, kinds) {
145+
child = parent
146+
parent = child.Parent
147+
}
148+
return child, parent
149+
}
150+
151+
func reportableCallee(callee *ast.Node) *ast.Node {
152+
if unwrapped := ast.SkipParentheses(callee); unwrapped != nil {
153+
return unwrapped
154+
}
155+
return callee
156+
}
157+
158+
func buildVoidUseMemoMessage(id, reason, description string) rule.RuleMessage {
159+
return rule.RuleMessage{
160+
Id: id,
161+
Description: fmt.Sprintf("Error: %s\n\n%s.", reason, description),
162+
Data: map[string]string{
163+
"description": description,
164+
"reason": reason,
165+
},
166+
}
167+
}

0 commit comments

Comments
 (0)