From c27cec2005a7f106489b0043e658ca17476ad39b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:57:08 +0000 Subject: [PATCH 1/3] fix: respect 'use no memo' directive in react-compiler-no-manual-memoization rule When a component has the 'use no memo' directive, React Compiler skips optimization for that component, so manual memoization (useMemo, useCallback, memo) is still needed. This change adds support for detecting the 'use no memo' directive and suppresses the react-compiler-no-manual-memoization rule in those cases. - Add hasUseNoMemoDirective utility function - Update rule to check for directive in enclosing function (useMemo/useCallback) - Update rule to check for directive in wrapped component (memo) - Add comprehensive tests including regression tests Fixes #1749 Co-authored-by: Skosh --- ...-no-manual-memoization.regressions.test.ts | 51 +++++++++ ...act-compiler-no-manual-memoization.test.ts | 100 ++++++++++++++++++ .../react-compiler-no-manual-memoization.ts | 4 + .../plugin/utils/has-use-no-memo-directive.ts | 19 ++++ 4 files changed, 174 insertions(+) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.regressions.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.regressions.test.ts index 9df3efcd24..614bc6626c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.regressions.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.regressions.test.ts @@ -168,4 +168,55 @@ export default function ({ onPress }) { ); expect(result.diagnostics).toEqual([]); }); + + it("does not flag useMemo inside a component with 'use no memo' directive (issue #1749)", () => { + const result = run( + `import { useMemo } from "react"; +export function SomeComponent() { + "use no memo"; + const something = useMemo(() => getSomething(), []); + return
{something}
; +}`, + ); + expect(result.diagnostics).toEqual([]); + }); + + it("does not flag useCallback inside a component with 'use no memo' directive", () => { + const result = run( + `import { useCallback } from "react"; +export function LegacyComponent() { + "use no memo"; + const handler = useCallback(() => console.log("click"), []); + return ; +}`, + ); + expect(result.diagnostics).toEqual([]); + }); + + it("does not flag memo wrapping a component with 'use no memo' directive", () => { + const result = run( + `import { memo } from "react"; +const Component = memo(function Inner({ value }) { + "use no memo"; + return {value}; +});`, + ); + expect(result.diagnostics).toEqual([]); + }); + + it("still flags useMemo in a nested component without 'use no memo' inside a parent with the directive", () => { + const result = run( + `import { useMemo } from "react"; +export function OuterComponent() { + "use no memo"; + const InnerComponent = () => { + const cached = useMemo(() => 1, []); + return
{cached}
; + }; + return ; +}`, + ); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]?.message).toContain("useMemo"); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts index d0f0fd48a4..aee324648e 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts @@ -315,4 +315,104 @@ export default Wrapped;`, 0, ); }); + + it("does not flag `useMemo` in a component with 'use no memo' directive", () => { + expectDiagnosticCount( + `import { useMemo } from "react"; +export function Component() { + "use no memo"; + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; +}`, + 0, + ); + }); + + it("does not flag `useCallback` in a component with 'use no memo' directive", () => { + expectDiagnosticCount( + `import { useCallback } from "react"; +export function Component() { + "use no memo"; + const handler = useCallback(() => undefined, []); + return ; +}`, + 0, + ); + }); + + it("does not flag `React.useMemo` in a component with 'use no memo' directive (namespace import)", () => { + expectDiagnosticCount( + `import * as React from "react"; +export function Component() { + "use no memo"; + const cachedValue = React.useMemo(() => 1, []); + return {cachedValue}; +}`, + 0, + ); + }); + + it("does not flag when 'use no memo' appears in single quotes", () => { + expectDiagnosticCount( + `import { useMemo } from "react"; +export function Component() { + 'use no memo'; + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; +}`, + 0, + ); + }); + + it("still flags `useMemo` in a nested function without 'use no memo' inside a component that has the directive", () => { + expectDiagnosticCount( + `import { useMemo } from "react"; +export function OuterComponent() { + "use no memo"; + const InnerComponent = () => { + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; + }; + return ; +}`, + 1, + ); + }); + + it("does not flag `useMemo` in arrow function component with 'use no memo'", () => { + expectDiagnosticCount( + `import { useMemo } from "react"; +export const Component = () => { + "use no memo"; + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; +};`, + 0, + ); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts index af686ced3f..cfc44d6e51 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts @@ -6,6 +6,7 @@ import { getImportedNameFromModule, isImportedFromModule, } from "../../utils/find-import-source-for-name.js"; +import { hasUseNoMemoDirective } from "../../utils/has-use-no-memo-directive.js"; import { isCanonicalReactNamespaceName } from "../../utils/is-canonical-react-namespace-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { isReactComponentOrHookName } from "../../utils/is-react-component-or-hook-name.js"; @@ -158,6 +159,8 @@ export const reactCompilerNoManualMemoization = defineRule({ if (apiName === "memo") { const comparatorArgument = node.arguments?.[1]; if (comparatorArgument && !isNullishComparatorArgument(comparatorArgument)) return; + const wrappedComponent = stripParenExpression(node.arguments?.[0]); + if (wrappedComponent && hasUseNoMemoDirective(wrappedComponent)) return; } else { // `useMemo` / `useCallback` are only redundant inside a function // the compiler will actually compile. Inside a function it skips @@ -165,6 +168,7 @@ export const reactCompilerNoManualMemoization = defineRule({ // helper) nothing is auto-cached, so the manual memoization stays. const enclosingFunction = findEnclosingFunction(node); if (!enclosingFunction || !isCompilerInferableFunction(enclosingFunction)) return; + if (hasUseNoMemoDirective(enclosingFunction)) return; } const removalMessage = REMOVAL_MESSAGE_BY_REACT_API_NAME.get(apiName); if (!removalMessage) return; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts new file mode 100644 index 0000000000..8839353ae7 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts @@ -0,0 +1,19 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { isNodeOfType } from "./is-node-of-type.js"; + +export const hasUseNoMemoDirective = (node: EsTreeNode): boolean => { + if ( + !isNodeOfType(node, "FunctionDeclaration") && + !isNodeOfType(node, "FunctionExpression") && + !isNodeOfType(node, "ArrowFunctionExpression") + ) { + return false; + } + if (!isNodeOfType(node.body, "BlockStatement")) return false; + return Boolean( + node.body.body?.some( + (statement) => + isNodeOfType(statement, "ExpressionStatement") && statement.directive === "use no memo", + ), + ); +}; From 7be017bfc101a66883e70a712311efa77b111f6a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:59:45 +0000 Subject: [PATCH 2/3] chore: add changeset for use no memo directive fix Co-authored-by: Skosh --- .changeset/respect-use-no-memo-directive.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/respect-use-no-memo-directive.md diff --git a/.changeset/respect-use-no-memo-directive.md b/.changeset/respect-use-no-memo-directive.md new file mode 100644 index 0000000000..da5946dec4 --- /dev/null +++ b/.changeset/respect-use-no-memo-directive.md @@ -0,0 +1,9 @@ +--- +"oxlint-plugin-react-doctor": patch +--- + +fix: respect "use no memo" directive in react-compiler-no-manual-memoization rule + +When a component has the "use no memo" directive, React Compiler skips optimization for that component, so manual memoization (useMemo, useCallback, memo) is still needed. The rule now detects this directive and suppresses warnings in such cases. + +Fixes #1749 From 845ddff1744b3525e41a5d3e0558cccd12052754 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 5 Sep 2026 01:41:43 -0700 Subject: [PATCH 3/3] fix: respect React Compiler opt-out directives --- .changeset/respect-use-no-memo-directive.md | 2 +- ...emoization--compiler-opt-out-directive.tsx | 12 +++ ...act-compiler-no-manual-memoization.test.ts | 49 ++++++++++++ .../react-compiler-no-manual-memoization.ts | 79 +++++++++++-------- .../src/plugin/utils/has-directive.ts | 17 +++- .../has-react-compiler-opt-out-directive.ts | 7 ++ .../plugin/utils/has-use-no-memo-directive.ts | 19 ----- 7 files changed, 131 insertions(+), 54 deletions(-) create mode 100644 packages/fuzz/corpus/regressions/react-compiler-no-manual-memoization--compiler-opt-out-directive.tsx create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-compiler-opt-out-directive.ts delete mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts diff --git a/.changeset/respect-use-no-memo-directive.md b/.changeset/respect-use-no-memo-directive.md index da5946dec4..a3764f2243 100644 --- a/.changeset/respect-use-no-memo-directive.md +++ b/.changeset/respect-use-no-memo-directive.md @@ -4,6 +4,6 @@ fix: respect "use no memo" directive in react-compiler-no-manual-memoization rule -When a component has the "use no memo" directive, React Compiler skips optimization for that component, so manual memoization (useMemo, useCallback, memo) is still needed. The rule now detects this directive and suppresses warnings in such cases. +When a function or module has a React Compiler opt-out directive, the compiler skips optimization, so manual memoization can still be necessary. The rule now respects `"use no memo"`, its `"use no forget"` alias, and local components passed to `memo`. Fixes #1749 diff --git a/packages/fuzz/corpus/regressions/react-compiler-no-manual-memoization--compiler-opt-out-directive.tsx b/packages/fuzz/corpus/regressions/react-compiler-no-manual-memoization--compiler-opt-out-directive.tsx new file mode 100644 index 0000000000..a55ef34eea --- /dev/null +++ b/packages/fuzz/corpus/regressions/react-compiler-no-manual-memoization--compiler-opt-out-directive.tsx @@ -0,0 +1,12 @@ +// verdict: pass +// rule: react-compiler-no-manual-memoization +// weakness: framework-gating +// source: GitHub issue #1749 + +import { useMemo } from "react"; + +export const LegacyComponent = () => { + "use no memo"; + const cachedValue = useMemo(() => getValue(), []); + return {cachedValue}; +}; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts index aee324648e..cfddec6114 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.test.ts @@ -415,4 +415,53 @@ export const Component = () => { 0, ); }); + + it("does not flag manual memoization when the module has a compiler opt-out directive", () => { + expectDiagnosticCount( + `"use no memo"; +import { memo, useMemo } from "react"; +export const Component = memo(function Component() { + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; +});`, + 0, + ); + }); + + it("does not flag manual memoization with the 'use no forget' alias", () => { + expectDiagnosticCount( + `import { useMemo } from "react"; +export function Component() { + "use no forget"; + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; +}`, + 0, + ); + }); + + it("does not flag memo around a local component with a compiler opt-out directive", () => { + expectDiagnosticCount( + `import { memo } from "react"; +function Component() { + "use no memo"; + return Value; +} +export default memo(Component);`, + 0, + ); + }); + + it("still flags manual memoization when the string is outside the directive prologue", () => { + expectDiagnosticCount( + `import { useMemo } from "react"; +export function Component() { + prepare(); + "use no memo"; + const cachedValue = useMemo(() => 1, []); + return {cachedValue}; +}`, + 1, + ); + }); }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts index cfc44d6e51..8b6933220a 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/architecture/react-compiler-no-manual-memoization.ts @@ -2,11 +2,12 @@ import { defineRule } from "../../utils/define-rule.js"; import type { EsTreeNode } from "../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js"; import { findEnclosingFunction } from "../../utils/find-enclosing-function.js"; +import { findVariableInitializer } from "../../utils/find-variable-initializer.js"; import { getImportedNameFromModule, isImportedFromModule, } from "../../utils/find-import-source-for-name.js"; -import { hasUseNoMemoDirective } from "../../utils/has-use-no-memo-directive.js"; +import { hasReactCompilerOptOutDirective } from "../../utils/has-react-compiler-opt-out-directive.js"; import { isCanonicalReactNamespaceName } from "../../utils/is-canonical-react-namespace-name.js"; import { isNodeOfType } from "../../utils/is-node-of-type.js"; import { isReactComponentOrHookName } from "../../utils/is-react-component-or-hook-name.js"; @@ -148,34 +149,50 @@ export const reactCompilerNoManualMemoization = defineRule({ requires: ["react-compiler"], recommendation: "Profile compiler-managed code and remove `useMemo`, `useCallback`, or `memo` only when the manual cache no longer carries behavioral or performance intent.", - create: (context: RuleContext) => ({ - CallExpression(node: EsTreeNodeOfType<"CallExpression">) { - const apiName = resolveReactApiNameForCallee(node.callee, context); - if (!apiName) return; - // `memo(Component, areEqual)` with a custom comparator encodes - // bespoke equality the compiler can't replicate, so it isn't - // redundant — leave it alone. A nullish second arg is no comparator - // at all, so it doesn't earn the exemption. - if (apiName === "memo") { - const comparatorArgument = node.arguments?.[1]; - if (comparatorArgument && !isNullishComparatorArgument(comparatorArgument)) return; - const wrappedComponent = stripParenExpression(node.arguments?.[0]); - if (wrappedComponent && hasUseNoMemoDirective(wrappedComponent)) return; - } else { - // `useMemo` / `useCallback` are only redundant inside a function - // the compiler will actually compile. Inside a function it skips - // (an anonymous arrow handed to a non-React HOC, a non-component - // helper) nothing is auto-cached, so the manual memoization stays. - const enclosingFunction = findEnclosingFunction(node); - if (!enclosingFunction || !isCompilerInferableFunction(enclosingFunction)) return; - if (hasUseNoMemoDirective(enclosingFunction)) return; - } - const removalMessage = REMOVAL_MESSAGE_BY_REACT_API_NAME.get(apiName); - if (!removalMessage) return; - context.report({ - node, - message: removalMessage, - }); - }, - }), + create: (context: RuleContext) => { + let doesModuleOptOutOfReactCompiler = false; + return { + Program(node: EsTreeNodeOfType<"Program">) { + doesModuleOptOutOfReactCompiler = hasReactCompilerOptOutDirective(node); + }, + CallExpression(node: EsTreeNodeOfType<"CallExpression">) { + if (doesModuleOptOutOfReactCompiler) return; + const apiName = resolveReactApiNameForCallee(node.callee, context); + if (!apiName) return; + // `memo(Component, areEqual)` with a custom comparator encodes + // bespoke equality the compiler can't replicate, so it isn't + // redundant — leave it alone. A nullish second arg is no comparator + // at all, so it doesn't earn the exemption. + if (apiName === "memo") { + const comparatorArgument = node.arguments?.[1]; + if (comparatorArgument && !isNullishComparatorArgument(comparatorArgument)) return; + let wrappedComponent = stripParenExpression(node.arguments?.[0]); + if (wrappedComponent && isNodeOfType(wrappedComponent, "Identifier")) { + const componentBinding = findVariableInitializer( + wrappedComponent, + wrappedComponent.name, + ); + if (componentBinding?.initializer) { + wrappedComponent = stripParenExpression(componentBinding.initializer); + } + } + if (wrappedComponent && hasReactCompilerOptOutDirective(wrappedComponent)) return; + } else { + // `useMemo` / `useCallback` are only redundant inside a function + // the compiler will actually compile. Inside a function it skips + // (an anonymous arrow handed to a non-React HOC, a non-component + // helper) nothing is auto-cached, so the manual memoization stays. + const enclosingFunction = findEnclosingFunction(node); + if (!enclosingFunction || !isCompilerInferableFunction(enclosingFunction)) return; + if (hasReactCompilerOptOutDirective(enclosingFunction)) return; + } + const removalMessage = REMOVAL_MESSAGE_BY_REACT_API_NAME.get(apiName); + if (!removalMessage) return; + context.report({ + node, + message: removalMessage, + }); + }, + }; + }, }); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-directive.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-directive.ts index 7d37fe5b05..66ded44124 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-directive.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-directive.ts @@ -1,9 +1,20 @@ import type { EsTreeNode } from "./es-tree-node.js"; import { isNodeOfType } from "./is-node-of-type.js"; -export const hasDirective = (programNode: EsTreeNode, directive: string): boolean => { - if (!isNodeOfType(programNode, "Program")) return false; - for (const statement of programNode.body) { +export const hasDirective = (node: EsTreeNode, directive: string): boolean => { + let statements: EsTreeNode[] | null = null; + if (isNodeOfType(node, "Program")) { + statements = node.body; + } else if ( + (isNodeOfType(node, "FunctionDeclaration") || + isNodeOfType(node, "FunctionExpression") || + isNodeOfType(node, "ArrowFunctionExpression")) && + isNodeOfType(node.body, "BlockStatement") + ) { + statements = node.body.body; + } + if (statements === null) return false; + for (const statement of statements) { if (!isNodeOfType(statement, "ExpressionStatement") || statement.directive === undefined) { return false; } diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-compiler-opt-out-directive.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-compiler-opt-out-directive.ts new file mode 100644 index 0000000000..fd1940e593 --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-react-compiler-opt-out-directive.ts @@ -0,0 +1,7 @@ +import type { EsTreeNode } from "./es-tree-node.js"; +import { hasDirective } from "./has-directive.js"; + +const REACT_COMPILER_OPT_OUT_DIRECTIVES = new Set(["use no memo", "use no forget"]); + +export const hasReactCompilerOptOutDirective = (node: EsTreeNode): boolean => + [...REACT_COMPILER_OPT_OUT_DIRECTIVES].some((directive) => hasDirective(node, directive)); diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts b/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts deleted file mode 100644 index 8839353ae7..0000000000 --- a/packages/oxlint-plugin-react-doctor/src/plugin/utils/has-use-no-memo-directive.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { EsTreeNode } from "./es-tree-node.js"; -import { isNodeOfType } from "./is-node-of-type.js"; - -export const hasUseNoMemoDirective = (node: EsTreeNode): boolean => { - if ( - !isNodeOfType(node, "FunctionDeclaration") && - !isNodeOfType(node, "FunctionExpression") && - !isNodeOfType(node, "ArrowFunctionExpression") - ) { - return false; - } - if (!isNodeOfType(node.body, "BlockStatement")) return false; - return Boolean( - node.body.body?.some( - (statement) => - isNodeOfType(statement, "ExpressionStatement") && statement.directive === "use no memo", - ), - ); -};