diff --git a/test/bundler/transpiler/react-compiler.test.ts b/test/bundler/transpiler/react-compiler.test.ts index 85ee0a405ef6..bf4f2ce72581 100644 --- a/test/bundler/transpiler/react-compiler.test.ts +++ b/test/bundler/transpiler/react-compiler.test.ts @@ -1828,14 +1828,36 @@ describe("bundler", () => { }, }); + // Stub react packages for the three tests below. `c` counts its calls, and + // a compiled function calls it once per render. So the second number of + // each pair that `render` returns is 1 for a compiled function and 0 for a + // function that the compiler skipped. + const stubReactThatCountsCalls = { + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + "/node_modules/react/compiler-runtime.js": ` + let count = 0; + exports.c = size => { + count++; + return new Array(size).fill(Symbol.for("react.memo_cache_sentinel")); + }; + exports.calls = () => count; + `, + }; + const renderAndCountCalls = /* js */ ` + function render(fn, props) { + const before = calls(); + const result = fn(props); + return [result.props ?? result, calls() - before]; + } + `; + // prune_non_escaping_scopes does not visit the test of a `?:`. A reactive // scope that only the test reaches gets no node, but the local that scope // reassigns still refers to it. Once that local has to be memoized, Babel // raises `Invariant: Expected a node for all scopes` and skips that one // function. The last two functions are the controls. - // - // A compiled function calls the fake `c` once per render, so the second - // number of each pair is 1 for a compiled function and 0 for a skipped one. itBundled("react-compiler/AssignmentInConditionalTestSkipsOnlyThatFunction", { files: { "/entry.jsx": /* jsx */ ` @@ -1887,11 +1909,7 @@ describe("bundler", () => { return
Hello {name}
; } - function render(fn, props) { - const before = calls(); - const result = fn(props); - return [result.props ?? result, calls() - before]; - } + ${renderAndCountCalls} console.log(JSON.stringify({ CallInTest: render(CallInTest, { f: () => "x" }), ArrayInTest: render(ArrayInTest, { a: 1 }), @@ -1902,18 +1920,8 @@ describe("bundler", () => { Plain: render(Plain, { name: "bun" }), })); `, - "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + ...stubReactThatCountsCalls, "/node_modules/react/index.js": `exports.createElement = () => null;`, - "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, - "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, - "/node_modules/react/compiler-runtime.js": ` - let count = 0; - exports.c = size => { - count++; - return new Array(size).fill(Symbol.for("react.memo_cache_sentinel")); - }; - exports.calls = () => count; - `, }, reactCompiler: true, target: "browser", @@ -1931,6 +1939,162 @@ describe("bundler", () => { }, }); + // Nothing in these `try` bodies can throw, so PruneMaybeThrows removes the + // catch block. The phi that joins the local after the `try` keeps the operand + // of the removed block, and MergeConsecutiveBlocks has the invariant `Found a + // block with a single predecessor but where a phi has multiple operands`. It + // was an `assert_eq!` that ended the build. Babel 1.0.0 raises it and skips + // that one function. The last two functions are the controls. + itBundled("react-compiler/TryBodyThatCannotThrowSkipsOnlyThatFunction", { + files: { + "/entry.jsx": /* jsx */ ` + import { useMemo } from "react"; + import { calls } from "react/compiler-runtime"; + + function ConstantInTryBody() { + let found = "found"; + try { + const unused = 1; + } catch { + found = "missing"; + } + return {found}; + } + function LiteralsInTryBody(props) { + let ok = true; + try { + const list = [1, 2]; + const object = { a: 1 }; + } catch { + ok = false; + } + return {ok ? props.a : "off"}; + } + function ConstantInTryBodyWithCatchBinding() { + let message = "none"; + try { + const unused = 1; + } catch (e) { + message = e.name; + } + return {message}; + } + function ConstantInTryBodyInUseMemo() { + const found = useMemo(() => { + let found = "found"; + try { + const unused = 1; + } catch { + found = "missing"; + } + return found; + }, []); + return {found}; + } + function CallInTryBody() { + let supported = true; + try { + window.localStorage.getItem("key"); + } catch { + supported = false; + } + return {supported ? "on" : "off"}; + } + function Plain({ name }) { + return
Hello {name}
; + } + + ${renderAndCountCalls} + console.log(JSON.stringify({ + ConstantInTryBody: render(ConstantInTryBody), + LiteralsInTryBody: render(LiteralsInTryBody, { a: "a" }), + ConstantInTryBodyWithCatchBinding: render(ConstantInTryBodyWithCatchBinding), + ConstantInTryBodyInUseMemo: render(ConstantInTryBodyInUseMemo), + CallInTryBody: render(CallInTryBody), + Plain: render(Plain, { name: "bun" }), + })); + `, + ...stubReactThatCountsCalls, + "/node_modules/react/index.js": `exports.useMemo = fn => fn();`, + }, + reactCompiler: true, + target: "browser", + backend: "cli", + run: { + stdout: JSON.stringify({ + ConstantInTryBody: [{ children: "found" }, 0], + LiteralsInTryBody: [{ children: "a" }, 0], + ConstantInTryBodyWithCatchBinding: [{ children: "none" }, 0], + ConstantInTryBodyInUseMemo: [{ children: "found" }, 0], + CallInTryBody: [{ children: "off" }, 1], + Plain: [{ children: ["Hello ", "bun"] }, 1], + }), + }, + }); + + // DropManualMemoization puts a StartMemoize marker after the load of + // `useMemo` / `useCallback` and a FinishMemoize marker after the call. A + // manual memo call in the arguments of another one nests the two pairs, and + // ValidateNoSetStateInRender has the invariant `Unexpected nested StartMemoize + // instructions`. It was an `assert!` that ended the build. Babel leaves each + // of these functions as written: 1.0.0 raises that invariant for the + // third-argument form and rejects a call in a dependency list one pass + // earlier. The last function is the control. + itBundled("react-compiler/NestedManualMemoCallSkipsOnlyThatFunction", { + files: { + "/entry.jsx": /* jsx */ ` + import { useCallback, useMemo } from "react"; + import * as React from "react"; + import { calls } from "react/compiler-runtime"; + + function MemoInDependencyList({ a, b }) { + const x = useMemo(() => a + b, [a, useMemo(() => b * 2, [b])]); + return
{x}
; + } + function MemoInCallbackDependencyList(props) { + const f = useCallback(() => props.a, [useMemo(() => props.a, [props.a])]); + return
{f()}
; + } + function NamespaceMemoInDependencyList({ a, b }) { + const x = React.useMemo(() => a + b, [a, React.useMemo(() => b * 2, [b])]); + return
{x}
; + } + function MemoAsThirdArgument({ a, b }) { + const x = useMemo(() => a, [a], useMemo(() => b, [b])); + return
{x}
; + } + function SequentialMemos({ a, b }) { + const twice = useMemo(() => b * 2, [b]); + const x = useMemo(() => a + twice, [a, twice]); + return
{x}
; + } + + ${renderAndCountCalls} + console.log(JSON.stringify({ + MemoInDependencyList: render(MemoInDependencyList, { a: 1, b: 2 }), + MemoInCallbackDependencyList: render(MemoInCallbackDependencyList, { a: 5 }), + NamespaceMemoInDependencyList: render(NamespaceMemoInDependencyList, { a: 1, b: 2 }), + MemoAsThirdArgument: render(MemoAsThirdArgument, { a: 1, b: 2 }), + SequentialMemos: render(SequentialMemos, { a: 1, b: 2 }), + })); + `, + ...stubReactThatCountsCalls, + "/node_modules/react/index.js": `exports.useMemo = fn => fn(); exports.useCallback = fn => fn;`, + }, + reactCompiler: true, + target: "browser", + backend: "cli", + run: { + stdout: JSON.stringify({ + MemoInDependencyList: [{ children: 3 }, 0], + MemoInCallbackDependencyList: [{ children: 5 }, 0], + NamespaceMemoInDependencyList: [{ children: 3 }, 0], + MemoAsThirdArgument: [{ children: 1 }, 0], + SequentialMemos: [{ children: 5 }, 1], + }), + }, + }); + // A 0-arg call to an unknown import is non-reactive in InferReactivePlaces // (no operand is reactive, callee isn't a hook), so its scope's deps prune // to empty and it becomes a sentinel-only block. Babel does the same; this