Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/respect-use-no-memo-directive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"oxlint-plugin-react-doctor": patch
---

fix: respect "use no memo" directive in react-compiler-no-manual-memoization rule

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
Original file line number Diff line number Diff line change
@@ -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 <span>{cachedValue}</span>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>{something}</div>;
}`,
);
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 <button onClick={handler}>Click</button>;
}`,
);
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 <span>{value}</span>;
});`,
);
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 <div>{cached}</div>;
};
return <InnerComponent />;
}`,
);
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0]?.message).toContain("useMemo");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,153 @@ 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 <span>{cachedValue}</span>;
}`,
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 <button onClick={handler} />;
}`,
0,
);
});

it("does not flag `memo` wrapping a component with 'use no memo' directive", () => {
expectDiagnosticCount(
`import { memo } from "react";
const Inner = memo(function Component({ value }) {
"use no memo";
return <span>{value}</span>;
});
export default Inner;`,
0,
);
});

it("does not flag multiple hooks in a component with 'use no memo' directive", () => {
expectDiagnosticCount(
`import { useMemo, useCallback } from "react";
export function Component({ value }) {
"use no memo";
const computed = useMemo(() => value * 2, [value]);
const handler = useCallback(() => console.log(computed), [computed]);
return <button onClick={handler}>{computed}</button>;
}`,
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 <span>{cachedValue}</span>;
}`,
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 <span>{cachedValue}</span>;
}`,
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 <span>{cachedValue}</span>;
};
return <InnerComponent />;
}`,
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 <span>{cachedValue}</span>;
};`,
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 <span>{cachedValue}</span>;
});`,
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 <span>{cachedValue}</span>;
}`,
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 <span>Value</span>;
}
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 <span>{cachedValue}</span>;
}`,
1,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +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 { 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";
Expand Down Expand Up @@ -147,31 +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;
} 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;
}
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,
});
},
};
},
});
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
Loading