Files
Dunqing def47b8093 fix(react-compiler): keep manual memoization as written when its dependencies can't be honoured (#28101)
The compiler could change when a manual `useMemo` or `useCallback` updates by replacing the author's dependency list with the dependencies it inferred. In the reported case, a list sorted with `useMemo(() => sortBy(items, s ?? props.value), [items])` started sorting again when `s` changed, even though the author had left `s` out of the dependency list. Extra dependencies could also be dropped.

Enable exhaustive memo dependency validation by default, matching current React Compiler upstream. When dependencies are missing or extra, skip the whole component or hook and leave its manual memoization intact. Other functions in the file can still compile. ESLint and Oxlint suppressions continue to work as before.

Regression tests cover missing and extra dependencies, partial `useCallback` dependencies, non-inline callbacks, property reads inside callbacks, and sibling compilation after a bailout. Validation passed: the full React compiler test suite, all 1,844 fixture snapshots, Clippy, and all 37 NAPI tests.

Fixes #27113

AI disclosure: written with Claude Code.
2026-10-26 14:31:18 +00:00

564 lines
18 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { transform, transformSync } from "../index";
const fixture = `// @license MIT
import { useState } from "react";
interface Props {
text: string;
}
export function Component(props: Props) {
const [count, setCount] = useState<number>(0);
return <button onClick={() => setCount(count + 1)}>{props.text}: {count}</button>;
}
`;
describe("transformSync", () => {
it("runs React Compiler before TypeScript and JSX transforms", () => {
const result = transformSync("Component.tsx", fixture);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
expect(result.code).not.toContain("interface Props");
expect(result.code).not.toContain("<button");
expect(result.code).toContain("@license MIT");
});
it("honors JSX pragmas after React Compiler adds imports", () => {
const result = transformSync(
"Component.tsx",
`/** @jsxRuntime automatic */
/** @jsxImportSource custom-runtime */
export function Component({ value }: { value: string }) {
return <div>{value}</div>;
}
`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain('from "custom-runtime/jsx-runtime"');
expect(result.code).not.toContain('from "react/jsx-runtime"');
});
it("forwards React Compiler options", () => {
const target = transformSync("Component.tsx", fixture, {
reactCompiler: { target: "18" },
});
expect(target.errors).toEqual([]);
expect(target.code).toContain("react-compiler-runtime");
const gated = transformSync("Component.tsx", fixture, {
reactCompiler: {
gating: {
source: "feature-flags",
importSpecifierName: "isCompilerEnabled",
},
},
});
expect(gated.errors).toEqual([]);
expect(gated.code).toContain("feature-flags");
expect(gated.code).toContain("isCompilerEnabled");
});
it("supports dynamic gating and Meta-internal targets", () => {
const dynamic = transformSync(
"Component.jsx",
`export function Component(props) {
"use memo if(isCompilerEnabled)";
return <div>{props.text}</div>;
}`,
{
reactCompiler: {
dynamicGating: { source: "dynamic-feature-flags" },
},
},
);
expect(dynamic.errors).toEqual([]);
expect(dynamic.code).toContain("dynamic-feature-flags");
expect(dynamic.code).toContain("isCompilerEnabled");
const meta = transformSync("Component.tsx", fixture, {
reactCompiler: {
target: {
kind: "donotuse_meta_internal",
runtimeModule: "custom-react-runtime",
},
},
});
expect(meta.errors).toEqual([]);
expect(meta.code).toContain("custom-react-runtime");
});
it("can override opt-out directives", () => {
const source = `function Component(props) {
"use no memo";
return <div>{props.text}</div>;
}`;
const optedOut = transformSync("Component.jsx", source);
expect(optedOut.errors).toEqual([]);
expect(optedOut.code).not.toContain("_c(");
const compiled = transformSync("Component.jsx", source, {
reactCompiler: { ignoreUseNoForget: true },
});
expect(compiled.errors).toEqual([]);
expect(compiled.code).toContain("_c(");
});
it.each([
["compilationMode", { compilationMode: "bogus" }],
["panicThreshold", { panicThreshold: "bogus" }],
["outputMode", { outputMode: "bogus" }],
["target", { target: "20" }],
[
"environment.validateExhaustiveEffectDependencies",
{
environment: {
validateExhaustiveEffectDependencies: "bogus",
},
},
],
])("reports an invalid %s option without emitting code", (option, options) => {
const result = transformSync("Component.tsx", fixture, {
reactCompiler: options,
} as never);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain(`Invalid React Compiler \`${option}\` option`);
});
it("marks parse errors as fatal", () => {
const result = transformSync("Component.tsx", "function Component(");
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors.length).toBeGreaterThan(0);
});
it("supports source maps and language overrides", () => {
const result = transformSync("Component.vue", fixture, {
lang: "tsx",
sourcemap: true,
});
expect(result.errors).toEqual([]);
expect(result.map).toMatchObject({
sources: ["Component.vue"],
sourcesContent: [fixture],
version: 3,
});
});
it("can filter files with sources", () => {
const result = transformSync("vendor/Component.tsx", fixture, {
reactCompiler: { sources: ["src/"] },
});
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("interface Props");
expect(result.code).not.toContain("<button");
});
it("skips node_modules by default", () => {
for (const options of [undefined, { reactCompiler: {} }]) {
const result = transformSync("node_modules/package/Component.tsx", fixture, options);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("_c(");
}
});
it("allows sources to include node_modules", () => {
const result = transformSync("node_modules/package/Component.tsx", fixture, {
reactCompiler: { sources: ["node_modules/package"] },
});
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
});
it("keeps imports used by compiled computed keys", () => {
const result = transformSync(
"Box.tsx",
`import { CSS_VAR } from "./styles.css";
export function Box({ size }) {
const style = { [CSS_VAR]: size + "px" };
return <div style={style} />;
}`,
);
expect(result.errors).toEqual([]);
expect(result.code).toContain("_c(");
expect(result.code).toContain("[CSS_VAR]");
expect(result.code).toContain("import { CSS_VAR }");
});
it("honors ESLint suppressions when internal validations are enabled", () => {
const result = transformSync(
"Counter.jsx",
`import { useEffect, useRef, useState } from "react";
export function Counter({ step }) {
const [count, setCount] = useState(0);
const ref = useRef(step);
useEffect(() => {
setCount((value) => value + ref.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div>{count}</div>;
}`,
{
reactCompiler: {
environment: {
validateExhaustiveMemoizationDependencies: true,
},
},
},
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).not.toContain("_c(");
});
it("honors ESLint suppressions by default", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
});
it("allows ESLint suppression bailouts to be disabled", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled: number = value * 2;
return <div>{doubled}</div>;
}`,
{
reactCompiler: {
eslintSuppressionRules: [],
},
},
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
expect(result.code).not.toContain(": number");
expect(result.code).not.toContain("<div");
});
it("continues after an incompatible library bailout", () => {
const result = transformSync(
"Components.tsx",
`import { useReactTable } from "@tanstack/react-table";
function Table() {
const table = useReactTable({});
return <div>{table}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).not.toContain("props: { text: string }");
expect(result.code).not.toContain("<span");
});
it.each([
[
"missing dependencies with rest props",
`export function List({ s, ...props }) {
const { items, children } = props;
const values = useMemo(() => sortBy(items, s ?? props.value), [items]);
return children(values);
}`,
],
[
"extra dependencies",
`export function Clock({ tick }) {
const now = useMemo(() => readGlobal(), [tick]);
return <span>{now}</span>;
}`,
],
[
"partial callback dependencies",
`export function Label({ prefix, suffix }) {
const format = useCallback(() => prefix + suffix, [prefix]);
return <Text format={format} suffix={suffix} />;
}`,
],
[
"callback property reads",
`export function Profile({ user, s }) {
const items = useMemo(() => sortBy(user.profile.items, s), [user.id]);
return <span>{items}</span>;
}`,
],
])("skips manual memoization with %s by default", (_name, component) => {
const result = transformSync(
"Component.jsx",
`import { useMemo, useCallback } from "react";
${component}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).not.toContain("react/compiler-runtime");
expect(result.code).toMatch(/use(?:Memo|Callback)\(\(\) =>/);
});
it("compiles a sibling after a manual memoization bailout", () => {
const result = transformSync(
"Components.jsx",
`import { useMemo } from "react";
export function List({ s, ...props }) {
const { items, children } = props;
const values = useMemo(() => sortBy(items, s ?? props.value), [items]);
return children(values);
}
export function Component(props) {
return <span>{props.text}</span>;
}`,
{ reactCompiler: { reportDiagnostics: true } },
);
expect(result.fatal).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toBe("Found missing memoization dependencies");
expect(result.code).toContain("useMemo(() => sortBy(items, s ?? props.value), [items])");
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("_c(");
});
it("compiles an unsuppressed sibling after a suppression bailout", () => {
const result = transformSync(
"Components.tsx",
`function Suppressed({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
);
expect(result.fatal).toBe(false);
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).not.toContain("props: { text: string }");
expect(result.code).not.toContain("<span");
});
it("reports recoverable diagnostics only when reportDiagnostics is enabled", () => {
const source = `function Suppressed({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`;
const silent = transformSync("Components.tsx", source);
expect(silent.fatal).toBe(false);
expect(silent.errors).toEqual([]);
const reported = transformSync("Components.tsx", source, {
reactCompiler: { reportDiagnostics: true },
});
expect(reported.fatal).toBe(false);
expect(reported.code).toBe(silent.code);
expect(reported.errors).toHaveLength(1);
expect(reported.errors[0]).toMatchObject({
severity: "Error",
message: "React rule suppression prevents optimization",
});
expect(reported.errors[0].codeframe).toContain("react-compiler(Suppression)");
const [label] = reported.errors[0].labels;
expect(source.slice(label.start, label.end)).toBe(
"// eslint-disable-next-line react-hooks/exhaustive-deps",
);
});
it("reports every recoverable diagnostic with its own severity", () => {
const result = transformSync(
"Components.tsx",
`import { useReactTable } from "@tanstack/react-table";
function Table() {
const table = useReactTable({});
return <div>{table}</div>;
}
function Suppressed({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return <div>{value}</div>;
}
export function Component(props: { text: string }) {
return <span>{props.text}</span>;
}`,
{ reactCompiler: { reportDiagnostics: true } },
);
expect(result.fatal).toBe(false);
expect(result.code).toContain("_c(");
expect(result.errors.map((error) => [error.severity, error.message])).toEqual([
["Warning", "Use of incompatible library"],
["Error", "React rule suppression prevents optimization"],
]);
});
it("reports lint findings with reportDiagnostics", () => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}`,
{ reactCompiler: { outputMode: "lint", reportDiagnostics: true } },
);
expect(result.fatal).toBe(false);
expect(result.code).not.toContain("_c(");
expect(result.code).not.toContain(": number");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toBe("React rule suppression prevents optimization");
});
it.each(["critical_errors", "all_errors"] as const)(
"makes suppression bailouts fatal at panicThreshold %s",
(panicThreshold) => {
const result = transformSync(
"Component.tsx",
`function Component({ value }: { value: number }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const doubled = value * 2;
return <div>{doubled}</div>;
}`,
{
reactCompiler: {
panicThreshold,
},
},
);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].severity).toBe("Error");
expect(result.errors[0].message).toBe("React rule suppression prevents optimization");
},
);
it("makes warning bailouts fatal at panicThreshold all_errors", () => {
const result = transformSync(
"Table.tsx",
`import { useReactTable } from "@tanstack/react-table";
function Table() {
const table = useReactTable({});
return <div>{table}</div>;
}`,
{ reactCompiler: { panicThreshold: "all_errors" } },
);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toMatchObject({
severity: "Warning",
message: "Use of incompatible library",
});
});
it("can disable and explicitly enable React Compiler", () => {
const disabled = transformSync("Component.tsx", fixture, {
reactCompiler: false,
});
expect(disabled.errors).toEqual([]);
expect(disabled.code).not.toContain("react/compiler-runtime");
expect(disabled.code).not.toContain("interface Props");
expect(disabled.code).not.toContain("<button");
const enabled = transformSync("Component.tsx", fixture, {
reactCompiler: true,
});
expect(enabled.errors).toEqual([]);
expect(enabled.code).toContain("react/compiler-runtime");
});
it("configures and preserves JSX independently of React Compiler", () => {
const configured = transformSync("Component.tsx", fixture, {
reactCompiler: false,
jsx: { importSource: "custom-jsx" },
});
expect(configured.errors).toEqual([]);
expect(configured.code).toContain('from "custom-jsx/jsx-runtime"');
expect(configured.code).not.toContain("<button");
const preserved = transformSync("Component.tsx", fixture, {
jsx: "preserve",
});
expect(preserved.errors).toEqual([]);
expect(preserved.code).toContain("react/compiler-runtime");
expect(preserved.code).not.toContain("interface Props");
expect(preserved.code).toContain("<button");
});
it("reports invalid JSX modes without emitting code", () => {
const result = transformSync("Component.tsx", fixture, {
reactCompiler: false,
jsx: "invalid",
} as never);
expect(result.fatal).toBe(true);
expect(result.code).toBe("");
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain("Invalid `jsx` option");
});
it("supports React Fast Refresh through JSX options", () => {
const result = transformSync("Component.tsx", fixture, {
jsx: { refresh: { emitFullSignatures: true } },
});
expect(result.errors).toEqual([]);
expect(result.code).toContain("react/compiler-runtime");
expect(result.code).toContain("$RefreshSig$");
expect(result.code).toContain("$RefreshReg$");
expect(result.code).toContain("useState{[count, setCount](0)}");
});
});
describe("transform", () => {
it("matches the synchronous transform", async () => {
const sync = transformSync("Component.tsx", fixture);
const asyncResult = await transform("Component.tsx", fixture);
expect(asyncResult).toEqual(sync);
});
});