Skip to content

Commit 75e29ba

Browse files
committed
Fix no-leaked-event-listener false positive when using React Native BackHandler, closes #1323
1 parent 742681c commit 75e29ba

File tree

7 files changed

+109
-46
lines changed

7 files changed

+109
-46
lines changed

packages/core/docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
- [isFunctionOfRenderMethod](functions/isFunctionOfRenderMethod.md)
133133
- [isFunctionOfUseEffectCleanup](functions/isFunctionOfUseEffectCleanup.md)
134134
- [isFunctionOfUseEffectSetup](functions/isFunctionOfUseEffectSetup.md)
135+
- [isInitializedFromReact](functions/isInitializedFromReact.md)
135136
- [isJsxFragmentElement](functions/isJsxFragmentElement.md)
136137
- [isJsxHostElement](functions/isJsxHostElement.md)
137138
- [isJsxLike](functions/isJsxLike.md)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[@eslint-react/core](../README.md) / isInitializedFromReact
2+
3+
# Function: isInitializedFromReact()
4+
5+
```ts
6+
function isInitializedFromReact(
7+
name: string,
8+
importSource: string,
9+
initialScope: Scope): boolean;
10+
```
11+
12+
Check if an identifier name is initialized from react
13+
14+
## Parameters
15+
16+
| Parameter | Type | Description |
17+
| ------ | ------ | ------ |
18+
| `name` | `string` | The top-level identifier's name |
19+
| `importSource` | `string` | The import source to check against |
20+
| `initialScope` | `Scope` | Initial scope to search for the identifier |
21+
22+
## Returns
23+
24+
`boolean`
25+
26+
Whether the identifier name is initialized from react

packages/core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./get-instance-id";
22
export * from "./is-from-react";
3+
export * from "./is-from-source";
34
export * from "./is-instance-id-equal";
45
export * from "./is-react-api";
Lines changed: 3 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,18 @@
1-
import * as AST from "@eslint-react/ast";
2-
import { identity } from "@eslint-react/eff";
3-
import { findVariable } from "@eslint-react/var";
41
import type { Scope } from "@typescript-eslint/scope-manager";
5-
import type { TSESTree } from "@typescript-eslint/types";
6-
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
7-
import { P, match } from "ts-pattern";
82

9-
/**
10-
* Get the arguments of a require expression
11-
* @param node The node to match
12-
* @returns The require expression arguments or undefined if the node is not a require expression
13-
*/
14-
function getRequireExpressionArguments(node: TSESTree.Node) {
15-
return match<typeof node, TSESTree.CallExpressionArgument[] | null>(node)
16-
// require("source")
17-
.with({ type: T.CallExpression, arguments: P.select(), callee: { type: T.Identifier, name: "require" } }, identity)
18-
// require("source").variable
19-
.with({ type: T.MemberExpression, object: P.select() }, getRequireExpressionArguments)
20-
.otherwise(() => null);
21-
}
3+
import { isInitializedFromSource } from "./is-from-source";
224

235
/**
246
* Check if an identifier name is initialized from react
257
* @param name The top-level identifier's name
268
* @param importSource The import source to check against
279
* @param initialScope Initial scope to search for the identifier
2810
* @returns Whether the identifier name is initialized from react
29-
* @internal
3011
*/
3112
export function isInitializedFromReact(
3213
name: string,
3314
importSource: string,
3415
initialScope: Scope,
35-
): boolean {
36-
if (name.toLowerCase() === "react") return true;
37-
const latestDef = findVariable(name, initialScope)?.defs.at(-1);
38-
if (latestDef == null) return false;
39-
const { node, parent } = latestDef;
40-
if (node.type === T.VariableDeclarator && node.init != null) {
41-
const { init } = node;
42-
// check for: `variable = React.variable`
43-
if (init.type === T.MemberExpression && init.object.type === T.Identifier) {
44-
return isInitializedFromReact(init.object.name, importSource, initialScope);
45-
}
46-
// check for: `{ variable } = React`
47-
if (init.type === T.Identifier) {
48-
return isInitializedFromReact(init.name, importSource, initialScope);
49-
}
50-
// check for: `variable = require('react')` or `variable = require('react').variable`
51-
const args = getRequireExpressionArguments(init);
52-
const arg0 = args?.[0];
53-
if (arg0 == null || !AST.isLiteral(arg0, "string")) {
54-
return false;
55-
}
56-
// check for: `require('react')` or `require('react/...')`
57-
return arg0.value === importSource || arg0.value.startsWith(`${importSource}/`);
58-
}
59-
// latest definition is an import declaration: import { variable } from 'react'
60-
return parent?.type === T.ImportDeclaration && parent.source.value === importSource;
16+
) {
17+
return name.toLowerCase() === "react" || isInitializedFromSource(name, importSource, initialScope);
6118
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as AST from "@eslint-react/ast";
2+
import { identity } from "@eslint-react/eff";
3+
import { findVariable } from "@eslint-react/var";
4+
import type { Scope } from "@typescript-eslint/scope-manager";
5+
import type { TSESTree } from "@typescript-eslint/types";
6+
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
7+
import { P, match } from "ts-pattern";
8+
9+
/**
10+
* Get the arguments of a require expression
11+
* @param node The node to match
12+
* @returns The require expression arguments or undefined if the node is not a require expression
13+
*/
14+
function getRequireExpressionArguments(node: TSESTree.Node) {
15+
return match<typeof node, TSESTree.CallExpressionArgument[] | null>(node)
16+
// require("source")
17+
.with({ type: T.CallExpression, arguments: P.select(), callee: { type: T.Identifier, name: "require" } }, identity)
18+
// require("source").variable
19+
.with({ type: T.MemberExpression, object: P.select() }, getRequireExpressionArguments)
20+
.otherwise(() => null);
21+
}
22+
23+
/**
24+
* Check if an identifier name is initialized from source
25+
* @param name The top-level identifier's name
26+
* @param source The import source to check against
27+
* @param initialScope Initial scope to search for the identifier
28+
* @returns Whether the identifier name is initialized from source
29+
* @internal
30+
*/
31+
export function isInitializedFromSource(
32+
name: string,
33+
source: string,
34+
initialScope: Scope,
35+
) {
36+
const latestDef = findVariable(name, initialScope)?.defs.at(-1);
37+
if (latestDef == null) return false;
38+
const { node, parent } = latestDef;
39+
if (node.type === T.VariableDeclarator && node.init != null) {
40+
const { init } = node;
41+
// check for: `variable = React.variable`
42+
if (init.type === T.MemberExpression && init.object.type === T.Identifier) {
43+
return isInitializedFromSource(init.object.name, source, initialScope);
44+
}
45+
// check for: `{ variable } = React`
46+
if (init.type === T.Identifier) {
47+
return isInitializedFromSource(init.name, source, initialScope);
48+
}
49+
// check for: `variable = require('react')` or `variable = require('react').variable`
50+
const args = getRequireExpressionArguments(init);
51+
const arg0 = args?.[0];
52+
if (arg0 == null || !AST.isLiteral(arg0, "string")) {
53+
return false;
54+
}
55+
// check for: `require('react')` or `require('react/...')`
56+
return arg0.value === source || arg0.value.startsWith(`${source}/`);
57+
}
58+
// latest definition is an import declaration: import { variable } from 'react'
59+
return parent?.type === T.ImportDeclaration && parent.source.value === source;
60+
}

packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,5 +1182,15 @@ ruleTester.run(RULE_NAME, rule, {
11821182
return null;
11831183
};
11841184
`,
1185+
tsx`
1186+
import { BackHandler } from "react-native";
1187+
1188+
useEffect(() => {
1189+
const { remove } = BackHandler.addEventListener("hardwareBackPress", onBackPress);
1190+
return () => {
1191+
remove();
1192+
}
1193+
});
1194+
`,
11851195
],
11861196
});

packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type ComponentPhaseKind,
44
ComponentPhaseRelevance,
55
getPhaseKindOfFunction,
6+
isInitializedFromSource,
67
isInversePhase,
78
} from "@eslint-react/core";
89
import { unit } from "@eslint-react/eff";
@@ -245,6 +246,13 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
245246
}
246247
match(getCallKind(node))
247248
.with("addEventListener", (callKind) => {
249+
// https://github.com/Rel1cx/eslint-react/issues/1323
250+
const isFromReactNative = node.callee.type === T.MemberExpression
251+
&& node.callee.object.type === T.Identifier
252+
&& isInitializedFromSource(node.callee.object.name, "react-native", context.sourceCode.getScope(node));
253+
if (isFromReactNative) {
254+
return;
255+
}
248256
const [type, listener, options] = node.arguments;
249257
if (type == null || listener == null) {
250258
return;

0 commit comments

Comments
 (0)