From 75e29ba2353661b668438d6ece749166f919798a Mon Sep 17 00:00:00 2001 From: Rel1cx Date: Wed, 3 Dec 2025 16:08:20 +0800 Subject: [PATCH 1/3] Fix `no-leaked-event-listener` false positive when using React Native `BackHandler`, closes #1323 --- packages/core/docs/README.md | 1 + .../docs/functions/isInitializedFromReact.md | 26 ++++++++ packages/core/src/utils/index.ts | 1 + packages/core/src/utils/is-from-react.ts | 49 +-------------- packages/core/src/utils/is-from-source.ts | 60 +++++++++++++++++++ .../rules/no-leaked-event-listener.spec.ts | 10 ++++ .../src/rules/no-leaked-event-listener.ts | 8 +++ 7 files changed, 109 insertions(+), 46 deletions(-) create mode 100644 packages/core/docs/functions/isInitializedFromReact.md create mode 100644 packages/core/src/utils/is-from-source.ts diff --git a/packages/core/docs/README.md b/packages/core/docs/README.md index 1a9075d93..a0d9e2061 100644 --- a/packages/core/docs/README.md +++ b/packages/core/docs/README.md @@ -132,6 +132,7 @@ - [isFunctionOfRenderMethod](functions/isFunctionOfRenderMethod.md) - [isFunctionOfUseEffectCleanup](functions/isFunctionOfUseEffectCleanup.md) - [isFunctionOfUseEffectSetup](functions/isFunctionOfUseEffectSetup.md) +- [isInitializedFromReact](functions/isInitializedFromReact.md) - [isJsxFragmentElement](functions/isJsxFragmentElement.md) - [isJsxHostElement](functions/isJsxHostElement.md) - [isJsxLike](functions/isJsxLike.md) diff --git a/packages/core/docs/functions/isInitializedFromReact.md b/packages/core/docs/functions/isInitializedFromReact.md new file mode 100644 index 000000000..8d5bc14e7 --- /dev/null +++ b/packages/core/docs/functions/isInitializedFromReact.md @@ -0,0 +1,26 @@ +[@eslint-react/core](../README.md) / isInitializedFromReact + +# Function: isInitializedFromReact() + +```ts +function isInitializedFromReact( + name: string, + importSource: string, + initialScope: Scope): boolean; +``` + +Check if an identifier name is initialized from react + +## Parameters + +| Parameter | Type | Description | +| ------ | ------ | ------ | +| `name` | `string` | The top-level identifier's name | +| `importSource` | `string` | The import source to check against | +| `initialScope` | `Scope` | Initial scope to search for the identifier | + +## Returns + +`boolean` + +Whether the identifier name is initialized from react diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 9fecfb369..949b30ead 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./get-instance-id"; export * from "./is-from-react"; +export * from "./is-from-source"; export * from "./is-instance-id-equal"; export * from "./is-react-api"; diff --git a/packages/core/src/utils/is-from-react.ts b/packages/core/src/utils/is-from-react.ts index d6484befa..fa35554dd 100644 --- a/packages/core/src/utils/is-from-react.ts +++ b/packages/core/src/utils/is-from-react.ts @@ -1,24 +1,6 @@ -import * as AST from "@eslint-react/ast"; -import { identity } from "@eslint-react/eff"; -import { findVariable } from "@eslint-react/var"; import type { Scope } from "@typescript-eslint/scope-manager"; -import type { TSESTree } from "@typescript-eslint/types"; -import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; -import { P, match } from "ts-pattern"; -/** - * Get the arguments of a require expression - * @param node The node to match - * @returns The require expression arguments or undefined if the node is not a require expression - */ -function getRequireExpressionArguments(node: TSESTree.Node) { - return match(node) - // require("source") - .with({ type: T.CallExpression, arguments: P.select(), callee: { type: T.Identifier, name: "require" } }, identity) - // require("source").variable - .with({ type: T.MemberExpression, object: P.select() }, getRequireExpressionArguments) - .otherwise(() => null); -} +import { isInitializedFromSource } from "./is-from-source"; /** * Check if an identifier name is initialized from react @@ -26,36 +8,11 @@ function getRequireExpressionArguments(node: TSESTree.Node) { * @param importSource The import source to check against * @param initialScope Initial scope to search for the identifier * @returns Whether the identifier name is initialized from react - * @internal */ export function isInitializedFromReact( name: string, importSource: string, initialScope: Scope, -): boolean { - if (name.toLowerCase() === "react") return true; - const latestDef = findVariable(name, initialScope)?.defs.at(-1); - if (latestDef == null) return false; - const { node, parent } = latestDef; - if (node.type === T.VariableDeclarator && node.init != null) { - const { init } = node; - // check for: `variable = React.variable` - if (init.type === T.MemberExpression && init.object.type === T.Identifier) { - return isInitializedFromReact(init.object.name, importSource, initialScope); - } - // check for: `{ variable } = React` - if (init.type === T.Identifier) { - return isInitializedFromReact(init.name, importSource, initialScope); - } - // check for: `variable = require('react')` or `variable = require('react').variable` - const args = getRequireExpressionArguments(init); - const arg0 = args?.[0]; - if (arg0 == null || !AST.isLiteral(arg0, "string")) { - return false; - } - // check for: `require('react')` or `require('react/...')` - return arg0.value === importSource || arg0.value.startsWith(`${importSource}/`); - } - // latest definition is an import declaration: import { variable } from 'react' - return parent?.type === T.ImportDeclaration && parent.source.value === importSource; +) { + return name.toLowerCase() === "react" || isInitializedFromSource(name, importSource, initialScope); } diff --git a/packages/core/src/utils/is-from-source.ts b/packages/core/src/utils/is-from-source.ts new file mode 100644 index 000000000..c4de24037 --- /dev/null +++ b/packages/core/src/utils/is-from-source.ts @@ -0,0 +1,60 @@ +import * as AST from "@eslint-react/ast"; +import { identity } from "@eslint-react/eff"; +import { findVariable } from "@eslint-react/var"; +import type { Scope } from "@typescript-eslint/scope-manager"; +import type { TSESTree } from "@typescript-eslint/types"; +import { AST_NODE_TYPES as T } from "@typescript-eslint/types"; +import { P, match } from "ts-pattern"; + +/** + * Get the arguments of a require expression + * @param node The node to match + * @returns The require expression arguments or undefined if the node is not a require expression + */ +function getRequireExpressionArguments(node: TSESTree.Node) { + return match(node) + // require("source") + .with({ type: T.CallExpression, arguments: P.select(), callee: { type: T.Identifier, name: "require" } }, identity) + // require("source").variable + .with({ type: T.MemberExpression, object: P.select() }, getRequireExpressionArguments) + .otherwise(() => null); +} + +/** + * Check if an identifier name is initialized from source + * @param name The top-level identifier's name + * @param source The import source to check against + * @param initialScope Initial scope to search for the identifier + * @returns Whether the identifier name is initialized from source + * @internal + */ +export function isInitializedFromSource( + name: string, + source: string, + initialScope: Scope, +) { + const latestDef = findVariable(name, initialScope)?.defs.at(-1); + if (latestDef == null) return false; + const { node, parent } = latestDef; + if (node.type === T.VariableDeclarator && node.init != null) { + const { init } = node; + // check for: `variable = React.variable` + if (init.type === T.MemberExpression && init.object.type === T.Identifier) { + return isInitializedFromSource(init.object.name, source, initialScope); + } + // check for: `{ variable } = React` + if (init.type === T.Identifier) { + return isInitializedFromSource(init.name, source, initialScope); + } + // check for: `variable = require('react')` or `variable = require('react').variable` + const args = getRequireExpressionArguments(init); + const arg0 = args?.[0]; + if (arg0 == null || !AST.isLiteral(arg0, "string")) { + return false; + } + // check for: `require('react')` or `require('react/...')` + return arg0.value === source || arg0.value.startsWith(`${source}/`); + } + // latest definition is an import declaration: import { variable } from 'react' + return parent?.type === T.ImportDeclaration && parent.source.value === source; +} diff --git a/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts b/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts index 380766fc7..0af250a28 100644 --- a/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts +++ b/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts @@ -1182,5 +1182,15 @@ ruleTester.run(RULE_NAME, rule, { return null; }; `, + tsx` + import { BackHandler } from "react-native"; + + useEffect(() => { + const { remove } = BackHandler.addEventListener("hardwareBackPress", onBackPress); + return () => { + remove(); + } + }); + `, ], }); diff --git a/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.ts b/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.ts index fa2087cfd..2a2005d05 100644 --- a/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.ts +++ b/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.ts @@ -3,6 +3,7 @@ import { type ComponentPhaseKind, ComponentPhaseRelevance, getPhaseKindOfFunction, + isInitializedFromSource, isInversePhase, } from "@eslint-react/core"; import { unit } from "@eslint-react/eff"; @@ -245,6 +246,13 @@ export function create(context: RuleContext): RuleListener { } match(getCallKind(node)) .with("addEventListener", (callKind) => { + // https://github.com/Rel1cx/eslint-react/issues/1323 + const isFromReactNative = node.callee.type === T.MemberExpression + && node.callee.object.type === T.Identifier + && isInitializedFromSource(node.callee.object.name, "react-native", context.sourceCode.getScope(node)); + if (isFromReactNative) { + return; + } const [type, listener, options] = node.arguments; if (type == null || listener == null) { return; From 9c5fd3dcdb1bb5a74408e0cb9b3bb4bda48eb608 Mon Sep 17 00:00:00 2001 From: REL1CX Date: Wed, 3 Dec 2025 16:18:42 +0800 Subject: [PATCH 2/3] Update packages/core/src/utils/is-from-source.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: REL1CX --- packages/core/src/utils/is-from-source.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/is-from-source.ts b/packages/core/src/utils/is-from-source.ts index c4de24037..e37e0dd91 100644 --- a/packages/core/src/utils/is-from-source.ts +++ b/packages/core/src/utils/is-from-source.ts @@ -38,23 +38,23 @@ export function isInitializedFromSource( const { node, parent } = latestDef; if (node.type === T.VariableDeclarator && node.init != null) { const { init } = node; - // check for: `variable = React.variable` + // check for: variable = Source.variable if (init.type === T.MemberExpression && init.object.type === T.Identifier) { return isInitializedFromSource(init.object.name, source, initialScope); } - // check for: `{ variable } = React` + // check for: { variable } = Source if (init.type === T.Identifier) { return isInitializedFromSource(init.name, source, initialScope); } - // check for: `variable = require('react')` or `variable = require('react').variable` + // check for: variable = require('source') or variable = require('source').variable const args = getRequireExpressionArguments(init); const arg0 = args?.[0]; if (arg0 == null || !AST.isLiteral(arg0, "string")) { return false; } - // check for: `require('react')` or `require('react/...')` + // check for: require('source') or require('source/...') return arg0.value === source || arg0.value.startsWith(`${source}/`); } - // latest definition is an import declaration: import { variable } from 'react' + // latest definition is an import declaration: import { variable } from 'source' return parent?.type === T.ImportDeclaration && parent.source.value === source; } From 9df57bd19e704a6e294459886239322766c9f0dc Mon Sep 17 00:00:00 2001 From: REL1CX Date: Wed, 3 Dec 2025 16:18:59 +0800 Subject: [PATCH 3/3] Update packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: REL1CX --- .../src/rules/no-leaked-event-listener.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts b/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts index 0af250a28..a118cdb0a 100644 --- a/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts +++ b/packages/plugins/eslint-plugin-react-web-api/src/rules/no-leaked-event-listener.spec.ts @@ -1186,7 +1186,7 @@ ruleTester.run(RULE_NAME, rule, { import { BackHandler } from "react-native"; useEffect(() => { - const { remove } = BackHandler.addEventListener("hardwareBackPress", onBackPress); + const { remove } = BackHandler.addEventListener("hardwareBackPress", onBackPress); return () => { remove(); }