1167 lines
46 KiB
TypeScript
1167 lines
46 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { isASAN, isDebug, tempDir } from "harness";
|
|
import { readdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { itBundled } from "../expectBundled";
|
|
|
|
// The React Compiler emits `import { c as _c } from "react/compiler-runtime"` and
|
|
// rewrites component bodies to call `_c(n)` to allocate a memo cache of `n` slots.
|
|
// See vendor/react-compiler/crates/react_compiler/src/entrypoint/imports.rs
|
|
// (`add_memo_cache_import` / `get_react_compiler_runtime_module`).
|
|
|
|
describe("bundler", () => {
|
|
itBundled("react-compiler/SimpleComponent", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function Hello({ name }) {
|
|
return <div>Hello {name}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// Snapshot the full transformed output so changes to the compiler's
|
|
// codegen are reviewable.
|
|
expect(out).toMatchSnapshot();
|
|
// The compiler-runtime memo cache import must be present.
|
|
expect(out).toContain("react/compiler-runtime");
|
|
// The component body should call the memo cache hook (`_c(n)`) with a
|
|
// numeric slot count.
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/ComponentWithHooks", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useState } from "react";
|
|
|
|
export function Counter({ step }) {
|
|
const [count, setCount] = useState(0);
|
|
const doubled = count * 2;
|
|
const onClick = () => setCount(c => c + step);
|
|
return <button onClick={onClick}>{doubled}</button>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("react/compiler-runtime");
|
|
// `onClick` and the JSX return value should be memoized into cache
|
|
// slots; the slot count must be > 1.
|
|
const m = out.match(/\b_c\((\d+)\)/);
|
|
expect(m).not.toBeNull();
|
|
expect(Number(m![1])).toBeGreaterThan(1);
|
|
// Memoized values are read back from the cache array via indexed access.
|
|
expect(out).toMatch(/\$\[\d+\]/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/ObjectPatternRestInProps", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
export function Comp({ a, b, ...rest }: { a: number; b: number }) {
|
|
return <div data-a={a} data-b={b} {...rest} />;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("react/compiler-runtime");
|
|
// Regression: codegen_assignment_target's Object Spread arm wrapped the
|
|
// rest binding in E::Spread *and* set PropertyKind::Spread, so the
|
|
// hoisted destructure printed `......rest`.
|
|
expect(out).not.toContain("......");
|
|
expect(out).toContain("...rest");
|
|
// Output must round-trip through Bun's own parser.
|
|
new Bun.Transpiler({ loader: "js" }).transformSync(out);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/OutputModeDefaultsByTarget-Browser", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useState } from "react";
|
|
export function Counter() {
|
|
const [n] = useState(0);
|
|
return <div>{n}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "api",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// target: "browser" with no reactCompilerOutputMode → client (memoized).
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/OutputModeDefaultsByTarget-Bun", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useState } from "react";
|
|
export function Counter() {
|
|
const [n] = useState(0);
|
|
return <div>{n}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "bun",
|
|
backend: "api",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// target: "bun" with no reactCompilerOutputMode → ssr (no memoization,
|
|
// no compiler-runtime import; useState lowered to its initial value).
|
|
expect(out).not.toContain("react/compiler-runtime");
|
|
expect(out).not.toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
// https://github.com/oven-sh/bun/issues/37022
|
|
// A full-stack build (--target=bun with an HTML import) bundles the browser
|
|
// graph through a separate client transpiler. That transpiler must run the
|
|
// React Compiler in client mode: the SSR pass inlines useState and drops the
|
|
// setter binding, leaving `setIsAuth` as a dangling identifier in the client
|
|
// bundle (ReferenceError at render).
|
|
itBundled("react-compiler/FullstackHtmlImportCompilesClientGraphInClientMode", {
|
|
outdir: "/out",
|
|
entryPoints: ["/server.ts"],
|
|
files: {
|
|
"/server.ts": `
|
|
import index from "./index.html";
|
|
console.log(typeof index);
|
|
`,
|
|
"/index.html": `<!DOCTYPE html><html><head><script type="module" src="./main.jsx"></script></head><body><div id="root"></div></body></html>`,
|
|
"/main.jsx": /* jsx */ `
|
|
import { useState } from "react";
|
|
function Login({ setAuth }) {
|
|
return <button onClick={() => setAuth(true)}>login</button>;
|
|
}
|
|
export function App() {
|
|
const [isAuth, setIsAuth] = useState(false);
|
|
return isAuth === false ? <Login setAuth={setIsAuth} /> : <div>in</div>;
|
|
}
|
|
globalThis.App = App;
|
|
`,
|
|
"/node_modules/react/index.js": `exports.useState = i => [i, () => {}]; exports.default = exports;`,
|
|
"/node_modules/react/jsx-runtime.js": `exports.jsx = (t, p) => ({ t, p }); exports.jsxs = exports.jsx;`,
|
|
"/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (t, p) => ({ t, p });`,
|
|
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "bun",
|
|
backend: "cli",
|
|
onAfterBundle(api) {
|
|
const chunks = readdirSync(api.outdir)
|
|
.filter(f => f.endsWith(".js"))
|
|
.map(f => api.readFile("/out/" + f));
|
|
const chunk = chunks.find(c => c.includes("setIsAuth"));
|
|
expect(chunk).toBeDefined();
|
|
// Client mode keeps the useState destructure, so the setter has a
|
|
// declaration in addition to its prop use. In SSR mode the destructure
|
|
// is inlined away and only the dangling use remains.
|
|
expect(chunk!).toMatch(/\[isAuth,\s*setIsAuth\]/);
|
|
expect(chunk!.match(/\bsetIsAuth\b/g)!.length).toBeGreaterThanOrEqual(2);
|
|
// Client mode memoizes through the compiler runtime; the SSR pass never
|
|
// references the memo cache sentinel.
|
|
expect(chunk!).toContain("react.memo_cache_sentinel");
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/OutputModeExplicitSsrOverridesTarget", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useState } from "react";
|
|
export function Counter() {
|
|
const [n] = useState(0);
|
|
return <div>{n}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
reactCompilerOutputMode: "ssr",
|
|
target: "browser",
|
|
backend: "api",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// reactCompilerOutputMode: "ssr" with reactCompiler: true overrides the
|
|
// target-derived default (browser → client) and skips memoization.
|
|
expect(out).not.toContain("react/compiler-runtime");
|
|
expect(out).not.toMatch(/\b_c\(\d+\)/);
|
|
// useState is still lowered by the SSR pass (the named import may
|
|
// survive, but no call remains).
|
|
expect(out).not.toContain("useState(");
|
|
},
|
|
});
|
|
|
|
// https://github.com/oven-sh/bun/pull/32504#discussion_r3447488111
|
|
itBundled("react-compiler/OutputModeIgnoredWhenCompilerDisabled-Client", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function Hello({ name }) {
|
|
return <div>Hello {name}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: false,
|
|
reactCompilerOutputMode: "client",
|
|
backend: "api",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// reactCompilerOutputMode must not enable the pass on its own.
|
|
expect(out).not.toContain("react/compiler-runtime");
|
|
expect(out).not.toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/OutputModeIgnoredWhenCompilerDisabled-Ssr", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useState } from "react";
|
|
export function Counter() {
|
|
const [n] = useState(0);
|
|
return <div>{n}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: false,
|
|
reactCompilerOutputMode: "ssr",
|
|
backend: "api",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// reactCompilerOutputMode: "ssr" with reactCompiler: false must not run
|
|
// the SSR pass either: useState stays as a runtime call.
|
|
expect(out).not.toContain("react/compiler-runtime");
|
|
expect(out).not.toMatch(/\b_c\(\d+\)/);
|
|
expect(out).toContain("useState(");
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/BundledReactPreservesImportRefs", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import React, { useSyncExternalStore, useContext, createContext } from "react";
|
|
const Ctx = createContext(0);
|
|
const sub = () => () => {};
|
|
const get = () => 1;
|
|
export function Comp() {
|
|
const v = useSyncExternalStore(sub, get);
|
|
const c = useContext(Ctx);
|
|
return <div>{v}{c}</div>;
|
|
}
|
|
`,
|
|
// Stub react packages so we don't depend on node_modules. These just
|
|
// need to satisfy the resolver — the test asserts on import-ref
|
|
// preservation, not runtime behaviour.
|
|
"/node_modules/react/index.js": `
|
|
exports.useSyncExternalStore = () => 0;
|
|
exports.useContext = () => 0;
|
|
exports.createContext = () => ({});
|
|
exports.createElement = () => null;
|
|
exports.default = exports;
|
|
`,
|
|
"/node_modules/react/jsx-runtime.js": `exports.jsx = () => null; exports.jsxs = () => null;`,
|
|
"/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = () => null;`,
|
|
"/node_modules/react/compiler-runtime.js": `exports.c = () => [];`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
// No `external` — react is bundled, so the linker rewrites named-import
|
|
// refs to namespace property accesses.
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("compiler-runtime");
|
|
// Regression: codegen emitted fresh string-named identifiers for free
|
|
// variables (LoadGlobal/imported bindings) instead of preserving the
|
|
// original Ref. With react bundled, the linker resolves the original
|
|
// Ref to `import_reactN.useSyncExternalStore`; a fresh identifier
|
|
// prints bare and ReferenceErrors at runtime.
|
|
const compBody = out.slice(out.indexOf("function Comp("));
|
|
expect(compBody).not.toMatch(
|
|
/(?<![.\w])useSyncExternalStore\(|(?<![.\w])useContext\(|(?<![.\w])React\.createElement\b/,
|
|
);
|
|
// Output must round-trip through Bun's own parser.
|
|
new Bun.Transpiler({ loader: "js" }).transformSync(out);
|
|
},
|
|
});
|
|
|
|
// https://github.com/oven-sh/bun/pull/32504#discussion_r3447488114
|
|
itBundled("react-compiler/BundledCjsCompilerRuntimeSurvivesTreeShaking", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function Hello({ name }) {
|
|
return <div>Hello {name}</div>;
|
|
}
|
|
console.log(typeof Hello({ name: "world" }));
|
|
`,
|
|
// CJS compiler-runtime so the linker must wire a wrapper dependency
|
|
// from the ReactCompiler part to this module via import_record_indices.
|
|
"/node_modules/react/compiler-runtime.js": `
|
|
exports.c = function (n) { return new Array(n).fill(Symbol.for("RC_RUNTIME_SENTINEL")); };
|
|
`,
|
|
"/node_modules/react/index.js": `exports.createElement = () => null;`,
|
|
"/node_modules/react/jsx-runtime.js": `exports.jsx = () => "jsx"; exports.jsxs = () => "jsx";`,
|
|
"/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = () => "jsx";`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "api",
|
|
run: { stdout: "string" },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// The ReactCompiler part must declare its import record so the linker
|
|
// keeps react/compiler-runtime live, orders it before the entry, and
|
|
// wires the CJS wrapper dependency. Without import_record_indices the
|
|
// module body can be dropped or left uninitialized, and _c is undefined
|
|
// at runtime. (The linker may rename `_c`, so assert on the runtime
|
|
// body's sentinel string instead.)
|
|
expect(out).toContain("RC_RUNTIME_SENTINEL");
|
|
new Bun.Transpiler({ loader: "js" }).transformSync(out);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/RequireStringPreservesImportRecord", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function Comp() {
|
|
const mod = require("./other");
|
|
const path = require.resolve("./other");
|
|
return <div>{mod.value}{path}</div>;
|
|
}
|
|
`,
|
|
"/other.js": `exports.value = "BUNDLED_OTHER_SENTINEL";`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
target: "browser",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// Lowering ERequireString to a plain `require("./other")` call drops the
|
|
// import_record_index, so the bundler would stop tracking the dependency
|
|
// and emit a runtime require of the literal path. Round-tripping the
|
|
// import record means the component is memoized AND `./other` is bundled.
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
expect(out).toContain("BUNDLED_OTHER_SENTINEL");
|
|
expect(out).not.toMatch(/require\(["']\.\/other["']\)/);
|
|
expect(out).not.toMatch(/require\.resolve\(["']\.\/other["']\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/BranchBooleanFeatureFlagPreservesDCE", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { feature } from "bun:bundle";
|
|
export function Comp() {
|
|
if (feature("FLAG")) {
|
|
return <div>DEAD_BRANCH_SENTINEL</div>;
|
|
}
|
|
return <span>live</span>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
target: "browser",
|
|
// FLAG is NOT in the enabled feature set, so feature("FLAG") lowers to an
|
|
// EBranchBoolean(false). The visitor folds `if (false)` before the React
|
|
// Compiler runs, so the dead arm is dropped and only the live span is
|
|
// memoized.
|
|
features: [],
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
expect(out).not.toContain("DEAD_BRANCH_SENTINEL");
|
|
expect(out).not.toContain("feature(");
|
|
expect(out).not.toContain("bun:bundle");
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/ForwardRefSiblingFn", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import { useState } from "react";
|
|
export function Comp({ onDone }) {
|
|
const [x] = useState(0);
|
|
async function onSubmit() { done(1); }
|
|
const done = (n) => onDone(n + x);
|
|
return <button onClick={onSubmit}>{x}</button>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/SelfRefConstArrow", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import { useState, useLayoutEffect } from "react";
|
|
export function useTick() {
|
|
const [n, setN] = useState(0);
|
|
useLayoutEffect(() => {
|
|
const tick = () => { setN(1); setTimeout(tick, 10); };
|
|
setTimeout(tick, 10);
|
|
}, []);
|
|
return n;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
// Function outlining hoists the anonymous callback to a module-level
|
|
// `function _temp(s) { ... }` and rewrites the call site to reference it.
|
|
// With minify.syntax on, the single-use `v` is substituted into the `if`
|
|
// test and the mangle pass re-visits the resulting `!useBar(_temp)`. That
|
|
// re-visit must keep the generated `_temp` `Ref` instead of resolving the
|
|
// name through `find_symbol`, which only walks `scope.members` and would
|
|
// mint a fresh unbound symbol that the identifier renamer never sees.
|
|
for (const [minifySyntax, minifyIdentifiers] of [
|
|
[false, true],
|
|
[true, false],
|
|
[true, true],
|
|
] as const) {
|
|
itBundled(`react-compiler/OutlinedFunctionMinify-syntax=${minifySyntax}-identifiers=${minifyIdentifiers}`, {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import { useFoo, useBar } from "ext";
|
|
export function C() {
|
|
useFoo();
|
|
const v = useBar(s => s.x > 0);
|
|
if (!v) return null;
|
|
return <div />;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
minifySyntax,
|
|
minifyIdentifiers,
|
|
external: ["*"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// The outlined function's declaration and its call site must share
|
|
// the same printed name. Match the decl name, then require that
|
|
// same name to appear as a bare identifier argument in a call.
|
|
const decl = out.match(/function\s+([A-Za-z_$][\w$]*)\s*\(\s*[A-Za-z_$][\w$]*\s*\)\s*\{\s*return\b/);
|
|
expect(decl).not.toBeNull();
|
|
const name = decl![1];
|
|
// minifyWhitespace is off, so the call argument is printed as
|
|
// `(<name>)` with no surrounding whitespace. Use a literal
|
|
// substring so a `$` in the minified name cannot be misread as a
|
|
// regex metacharacter.
|
|
expect(out).toContain(`(${name})`);
|
|
if (minifyIdentifiers) {
|
|
// With identifier minification every react-compiler-generated
|
|
// `_temp*` name must be renamed; a surviving literal is an
|
|
// orphaned reference.
|
|
expect(out).not.toMatch(/\b_temp\d*\b/);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// A later declarator that reads an earlier declarator in the same
|
|
// `const a = x, b = f(a)` statement is a plain left-to-right read, not a
|
|
// forward reference. Upstream's BlockStatement hoisting only emits a
|
|
// `DeclareContext` when the reference sits inside a nested function (or the
|
|
// binding is a function declaration), so `meta` must stay a plain local.
|
|
// Bun's port hoisted it unconditionally, turning `meta` into a spurious
|
|
// context variable, which tripped codegen's "MethodCall::property must be an
|
|
// unpromoted + unmemoized MemberExpression" invariant on the `Math.max`
|
|
// property load and silently bailed the whole component out of compilation.
|
|
itBundled("react-compiler/MultiDeclaratorReferencesEarlierDeclarator", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
function Text(p) { return p.children; }
|
|
function width(s) { return s.length; }
|
|
export function T({ label }) {
|
|
const meta = label, room = Math.max(12, width(meta));
|
|
return <Text>{meta}{room}</Text>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
external: ["*"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
// `minify: { syntax: true }` runs the statement mangler on nested blocks
|
|
// (the `if` body here) before the React Compiler sees them, merging the two
|
|
// adjacent `const` declarations into the multi-declarator shape above. The
|
|
// compiler must accept the same set of components with and without
|
|
// minify.syntax; a bailout here is a silent loss of memoization.
|
|
for (const minifySyntax of [false, true] as const) {
|
|
itBundled(`react-compiler/MathCallArgInMinifiedConditionalBranch-syntax=${minifySyntax}`, {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import * as React from "react";
|
|
function Text(p: { children?: React.ReactNode }) { return p.children; }
|
|
function width(s: string): number { return s.length; }
|
|
export function T({ w, label, on }: { w: number; label: string; on: boolean }) {
|
|
let subline: React.ReactNode;
|
|
if (on) {
|
|
const meta = label;
|
|
const room = Math.max(12, w - width(meta) - 3);
|
|
subline = <Text>{meta}{room}</Text>;
|
|
}
|
|
return <Text>{subline}</Text>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
minifySyntax,
|
|
external: ["*"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
}
|
|
|
|
// A user's local `jsx` / `jsxs` / `jsxDEV` / `Fragment` binding in the
|
|
// component body scope must not capture the automatic JSX runtime import
|
|
// when the React Compiler rewrites the component.
|
|
itBundled("react-compiler/AutomaticLocalShadow", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function Comp({ a, b }) {
|
|
let jsx = a
|
|
let jsxs = b
|
|
let jsxDEV = a
|
|
let Fragment = b
|
|
return <><span>{jsx}</span><span>{jsxs}{jsxDEV}{Fragment}</span></>
|
|
}
|
|
console.log(JSON.stringify(Comp({ a: "A", b: "B" })))
|
|
`,
|
|
"/node_modules/react/compiler-runtime.js": `
|
|
exports.c = function (n) { return new Array(n).fill(Symbol.for("react.memo_cache_sentinel")); };
|
|
`,
|
|
"/node_modules/react/index.js": `exports.createElement = () => null;`,
|
|
"/node_modules/react/jsx-runtime.js": `
|
|
exports.jsx = (type, props) => ({ $: "jsx", type: typeof type === "symbol" ? "Fragment" : type, props });
|
|
exports.jsxs = (type, props) => ({ $: "jsxs", type: typeof type === "symbol" ? "Fragment" : type, props });
|
|
exports.Fragment = Symbol.for("fragment");
|
|
`,
|
|
"/node_modules/react/jsx-dev-runtime.js": `
|
|
exports.jsxDEV = (type, props) => ({ $: "jsxDEV", type: typeof type === "symbol" ? "Fragment" : type, props });
|
|
exports.Fragment = Symbol.for("fragment");
|
|
`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "api",
|
|
run: {
|
|
stdout:
|
|
'{"$":"jsxDEV","type":"Fragment","props":{"children":[{"$":"jsxDEV","type":"span","props":{"children":"A"}},{"$":"jsxDEV","type":"span","props":{"children":["B","A","B"]}}]}}',
|
|
},
|
|
});
|
|
|
|
// Regression: codegen.rs PropertyDelete/ComputedDelete/UnaryExpression emitted
|
|
// `E::Unary` with `UnaryFlags::empty()`. The parser sets
|
|
// `WAS_ORIGINALLY_DELETE_OF_IDENTIFIER_OR_PROPERTY_ACCESS` for `delete <dot|index>`;
|
|
// the printer re-wraps any `delete <dot|index>` lacking that flag as
|
|
// `delete (0, obj.prop)`, which evaluates the property to a value and returns
|
|
// `true` without deleting anything.
|
|
itBundled("react-compiler/PropertyDeletePreservesReferenceSemantics", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useMemo } from "react";
|
|
export function useThing(a, b) {
|
|
return useMemo(() => {
|
|
const x = { a, b, c: 3 };
|
|
delete x.b;
|
|
const key = "c";
|
|
delete x[key];
|
|
return x;
|
|
}, [a, b]);
|
|
}
|
|
console.log(JSON.stringify(useThing(1, 2)));
|
|
`,
|
|
"/node_modules/react/index.js": `exports.useMemo = (f) => f();`,
|
|
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
run: { stdout: '{"a":1}' },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// The hook must be compiled (sanity: codegen, not a bailout, is on trial).
|
|
// With react bundled the `_c` import is renamed, so assert on the
|
|
// compiler-runtime body being linked in instead.
|
|
expect(out).toContain("react.memo_cache_sentinel");
|
|
// `delete (0, x.b)` / `delete (0, x[...])` evaluates to a value, not a
|
|
// Reference — must not appear for either the dot or index form.
|
|
expect(out).not.toMatch(/delete\s*\(\s*0\s*,/);
|
|
},
|
|
});
|
|
|
|
// Sibling of the above: `WAS_ORIGINALLY_TYPEOF_IDENTIFIER` was also dropped,
|
|
// so the printer wrapped `typeof undeclared` as `typeof (0, undeclared)`,
|
|
// which throws ReferenceError instead of returning "undefined" — breaking
|
|
// the common `typeof window !== "undefined"` SSR check.
|
|
itBundled("react-compiler/TypeofUnboundIdentifierPreservesFlag", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useMemo } from "react";
|
|
export function useIsBrowser() {
|
|
return useMemo(() => typeof window !== "undefined", []);
|
|
}
|
|
// The folded-conditional form must keep throwing semantics: the visitor
|
|
// wraps it as a real (0, x) comma expression, and codegen must not set
|
|
// the flag just because the operand inlines to an identifier.
|
|
export function useTypeofFolded() {
|
|
return useMemo(() => {
|
|
try {
|
|
return typeof (true ? NotDeclaredAnywhere : Other);
|
|
} catch {
|
|
return "threw";
|
|
}
|
|
}, []);
|
|
}
|
|
console.log(useIsBrowser(), useTypeofFolded());
|
|
`,
|
|
"/node_modules/react/index.js": `exports.useMemo = (f) => f();`,
|
|
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
run: { stdout: "false threw" },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// Both hooks must be compiled (RC drops the `useMemo` wrapper); these
|
|
// particular bodies need 0 memo slots so compiler-runtime is tree-shaken.
|
|
expect(out).not.toContain("useMemo(");
|
|
// `typeof window` must survive as-is; `typeof (true ? ...)` must stay wrapped.
|
|
expect(out).toMatch(/\btypeof window\b(?!\s*\))/);
|
|
expect(out).toMatch(/\btypeof\s*\(\s*0\s*,\s*NotDeclaredAnywhere\s*\)/);
|
|
},
|
|
});
|
|
|
|
// `delete (true ? o.a : o.b)` is a no-op per spec (operand is a value, not a
|
|
// Reference). The visitor folds the conditional to a bare EDot with the
|
|
// delete-flag unset; lowering must not turn that into a real PropertyDelete.
|
|
// Upstream's Babel plugin sees the unfolded ConditionalExpression and bails
|
|
// with "Only object properties can be deleted", so bailing out here matches.
|
|
itBundled("react-compiler/DeleteFoldedConditionalKeepsNoOpSemantics", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function Comp({ a, b }) {
|
|
const o = { a, b };
|
|
const r = delete (true ? o.a : o.b);
|
|
return <div>{r}{JSON.stringify(o)}</div>;
|
|
}
|
|
const el = Comp({ a: 1, b: 2 });
|
|
console.log(el.props.children.join(""));
|
|
`,
|
|
"/node_modules/react/index.js": `module.exports = {};`,
|
|
"/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (t, p) => ({ t, props: p });`,
|
|
"/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (t, p) => ({ t, props: p });`,
|
|
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
backend: "cli",
|
|
run: { stdout: 'true{"a":1,"b":2}' },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// The component bails out of compilation (Babel parity), so the delete
|
|
// stays in its post-visit `delete (0, o.a)` form.
|
|
expect(out).toMatch(/delete\s*\(\s*0\s*,\s*o\.a\s*\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/NonComponentUntouched", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
// Lowercase function name: not a component, not a hook. The React
|
|
// Compiler must leave it alone.
|
|
export function helper(name) {
|
|
return <div>{name}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
// No memo cache import or call should be emitted for a non-component.
|
|
expect(out).not.toContain("react/compiler-runtime");
|
|
expect(out).not.toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/SuppressionInsideTSNamespaceDoesNotLeak", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
namespace N {
|
|
export function Foo() {
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
useState();
|
|
}
|
|
}
|
|
export function Component({ name }: { name: string }) {
|
|
return <div>Hello {name}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// The next-line suppression inside the namespace member must be consumed
|
|
// there and not bail the compiler out of the sibling Component.
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
},
|
|
});
|
|
|
|
// Stub react packages shared by the unbound-ref regression tests below.
|
|
const stubReact = {
|
|
"/node_modules/react/index.js": /* js */ `
|
|
exports.useState = i => [i, () => {}];
|
|
exports.createElement = () => null;
|
|
exports.default = exports;
|
|
`,
|
|
"/node_modules/react/jsx-runtime.js": `exports.jsx = (t, p) => ({ t, p }); exports.jsxs = exports.jsx;`,
|
|
"/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (t, p) => ({ t, p });`,
|
|
"/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`,
|
|
"/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`,
|
|
};
|
|
|
|
// Regression: native RC codegen minted refs for `_c` / `jsx` / `jsxs` via
|
|
// `Host::new_generated` (Kind::Other, not in `is_import_item`) and emitted
|
|
// call sites as `EIdentifier`. The printer's namespace-alias rewrite is only
|
|
// in the `EImportIdentifier` branch, so the bundle had
|
|
// `var react_compiler_runtimeN = __toESM(require_compiler_runtime())` but
|
|
// bare `_cN(...)` with no decl -> ReferenceError at runtime.
|
|
itBundled("react-compiler/CJSBindsMemoCacheImport", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import { useState } from "react";
|
|
function Counter({ label }: { label: string }) {
|
|
const [n] = useState(0);
|
|
return <div>{label}: {n}</div>;
|
|
}
|
|
console.log(JSON.stringify(Counter({ label: "hi" })));
|
|
`,
|
|
...stubReact,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
format: "cjs",
|
|
backend: "api",
|
|
run: { stdout: '{"t":"div","p":{"children":["hi",": ",0]}}' },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
const compBody = out.slice(out.indexOf("function Counter("));
|
|
// The memo-cache call must be a property access on the wrapped CJS
|
|
// namespace (or otherwise bound), never a bare `_c(` / `jsx(`.
|
|
expect(compBody).not.toMatch(/(?<![.\w])_c\d*\s*\(/);
|
|
expect(compBody).not.toMatch(/(?<![.\w])jsxs?\s*\(/);
|
|
new Bun.Transpiler({ loader: "js" }).transformSync(out);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/ESMBindsMemoCacheImport", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import { useState } from "react";
|
|
function Counter({ label }: { label: string }) {
|
|
const [n] = useState(0);
|
|
return <div>{label}: {n}</div>;
|
|
}
|
|
console.log(JSON.stringify(Counter({ label: "hi" })));
|
|
`,
|
|
...stubReact,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
format: "esm",
|
|
backend: "api",
|
|
run: { stdout: '{"t":"div","p":{"children":["hi",": ",0]}}' },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
const compBody = out.slice(out.indexOf("function Counter("));
|
|
expect(compBody).not.toMatch(/(?<![.\w])_c\d*\s*\(/);
|
|
expect(compBody).not.toMatch(/(?<![.\w])jsxs?\s*\(/);
|
|
new Bun.Transpiler({ loader: "js" }).transformSync(out);
|
|
},
|
|
});
|
|
|
|
// Regression: `codegen.rs well_known()` minted fresh `Kind::Other` symbols
|
|
// for `Symbol` / `NaN` / `Infinity`. The renamer treated them as renameable
|
|
// locals, so minified bundles had `tQE.for("react.memo_cache_sentinel")`
|
|
// where `tQE` is renamed-but-never-declared `Symbol`.
|
|
itBundled("react-compiler/WellKnownGlobalsNotRenamed", {
|
|
files: {
|
|
"/entry.tsx": /* tsx */ `
|
|
import { useState } from "react";
|
|
function Counter({ label }: { label: string }) {
|
|
const [n] = useState(0);
|
|
return <div>{label}: {n}</div>;
|
|
}
|
|
console.log(JSON.stringify(Counter({ label: "hi" })));
|
|
`,
|
|
...stubReact,
|
|
},
|
|
reactCompiler: true,
|
|
target: "browser",
|
|
format: "cjs",
|
|
minifyIdentifiers: true,
|
|
backend: "api",
|
|
run: { stdout: '{"t":"div","p":{"children":["hi",": ",0]}}' },
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// RC emits `Symbol.for("react.memo_cache_sentinel")` for the slot init
|
|
// guard; the only `.for("react.memo_cache_sentinel")` callee in the
|
|
// bundle must be the literal global `Symbol`.
|
|
const callees = [...out.matchAll(/([A-Za-z_$][\w$]*)\.for\("react\.memo_cache_sentinel"\)/g)].map(m => m[1]);
|
|
expect(callees.length).toBeGreaterThan(0);
|
|
expect(new Set(callees)).toEqual(new Set(["Symbol"]));
|
|
new Bun.Transpiler({ loader: "js" }).transformSync(out);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/HoistsMemoCacheSentinel", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
export function A() {
|
|
return <div>a</div>;
|
|
}
|
|
export function B() {
|
|
return <span>b</span>;
|
|
}
|
|
export function C() {
|
|
return <p>c</p>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
// Bun routes the memo-cache sentinel through `p.runtime_imports`
|
|
// (`__MEMO_CACHE_SENTINEL`, same mechanism as `__toESM`/`__require`),
|
|
// so the bundler runtime defines `Symbol.for("react.memo_cache_sentinel")`
|
|
// exactly once for the whole bundle (Babel/upstream emits the call inline
|
|
// at every memo-slot comparison). Three components with no-dep scopes ⇒
|
|
// three comparisons, but only one `Symbol.for` call.
|
|
const calls = [...out.matchAll(/Symbol\.for\("react\.memo_cache_sentinel"\)/g)];
|
|
expect(calls).toHaveLength(1);
|
|
// The runtime export is referenced by name at each comparison. Allow the
|
|
// `/* @__PURE__ */` annotation between `=` and `Symbol.for`, and accept a
|
|
// mid-declarator (`, name =`) match since the runtime is printed as one
|
|
// collapsed `var` statement.
|
|
const decl = out.match(
|
|
/[,\s]([A-Za-z_$][\w$]*)\s*=\s*(?:\/\*\s*@__PURE__\s*\*\/\s*)?Symbol\.for\("react\.memo_cache_sentinel"\)/,
|
|
);
|
|
expect(decl).not.toBeNull();
|
|
const sentinel = decl![1];
|
|
const refs = [...out.matchAll(new RegExp(String.raw`\$\[\d+\]\s*===\s*` + sentinel.replace(/\$/g, "\\$"), "g"))];
|
|
expect(refs).toHaveLength(3);
|
|
},
|
|
});
|
|
|
|
// `import()` lowers as a CallExpression whose callee carries the original
|
|
// `EImport` (with `import_record_index`) as a BunOpaque LoadGlobal; codegen
|
|
// reconstructs `E::Import` so the bundler's chunk linkage is preserved.
|
|
// Previously the callee was `UnsupportedNode("Import")`, which bailed at codegen.
|
|
itBundled("react-compiler/DynamicImportInRender", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { use } from "react";
|
|
export function Comp() {
|
|
const M = use(import("./mod"));
|
|
return <div>{M.x}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime", "./mod"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
expect(out).toMatch(/\bimport\("\.\/mod"\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/DynamicImportInEffectClosure", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { useEffect } from "react";
|
|
export function Comp({ x }) {
|
|
useEffect(() => { import("./mod").then(m => m.init()); }, []);
|
|
return <div>{x}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime", "./mod"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toContain("react/compiler-runtime");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
expect(out).toMatch(/\bimport\("\.\/mod"\)/);
|
|
},
|
|
});
|
|
|
|
itBundled("react-compiler/DynamicImportPreservesImportRecord", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { use } from "react";
|
|
export function Comp() {
|
|
const M = use(import("./mod.js"));
|
|
return <div>{M.x}</div>;
|
|
}
|
|
`,
|
|
"/mod.js": `export const x = 42;`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
target: "browser",
|
|
splitting: true,
|
|
outdir: "/out",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out/entry.js");
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
// The bundler chunked /mod.js and rewrote the specifier; if RC dropped
|
|
// `import_record_index`, the rewrite wouldn't apply and the literal
|
|
// "./mod.js" would survive (or the chunk wouldn't be emitted at all).
|
|
expect(out).not.toMatch(/import\("\.\/mod\.js"\)/);
|
|
const m = out.match(/import\("(\.\/[\w-]+\.js)"\)/);
|
|
expect(m).not.toBeNull();
|
|
api.assertFileExists("/out/" + m![1].slice(2));
|
|
},
|
|
});
|
|
|
|
// prune_non_escaping_scopes treats `arr.push(jsx)` args as escaping so the
|
|
// per-element JSX scope isn't merged into the outer mutation scope; this
|
|
// asserts the Fragment's `key` survives the round-trip when the JSX temp is
|
|
// captured into a mutable array inside a wider mutation scope.
|
|
itBundled("react-compiler/KeyedFragmentAsArrayPushArg", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { Fragment } from "react";
|
|
export function Comp({ keys }) {
|
|
const parts = [];
|
|
for (const k of keys) {
|
|
parts.push(<Fragment key={k}><span>{k}</span></Fragment>);
|
|
}
|
|
return <>{parts}</>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
expect(out).toMatch(/\b_c\(\d+\)/);
|
|
// jsx/jsxDEV(Fragment, props, key, ...) — `k` must be the third arg of
|
|
// the inner Fragment call (the only `Fragment,` callee; the outer is
|
|
// `Fragment2,`). The props object closes on its own line so anchor on
|
|
// `\n }, k`.
|
|
expect(out).toMatch(/jsx(?:DEV|s)?\(Fragment, \{\n[\s\S]*?\n {6}\}, k\b/);
|
|
},
|
|
});
|
|
|
|
// 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
|
|
// is React Compiler's purity assumption (module-level functions are pure
|
|
// w.r.t. props/state).
|
|
itBundled("react-compiler/ZeroArgGlobalCallMatchesBabel", {
|
|
files: {
|
|
"/entry.jsx": /* jsx */ `
|
|
import { globalFn } from "./mod";
|
|
export function Comp() {
|
|
const x = globalFn();
|
|
return <div>{x}</div>;
|
|
}
|
|
`,
|
|
},
|
|
reactCompiler: true,
|
|
backend: "cli",
|
|
external: ["react", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime", "./mod"],
|
|
onAfterBundle(api) {
|
|
const out = api.readFile("/out.js");
|
|
expect(out).toMatchSnapshot();
|
|
// Parity with Babel: `globalFn()` is inside the sentinel-only block.
|
|
const m = out.match(/\b_c\((\d+)\)/);
|
|
expect(m).not.toBeNull();
|
|
expect(m![1]).toBe("1");
|
|
expect(out).toMatch(/__MEMO_CACHE_SENTINEL\)\s*\{[^}]*globalFn\(\)/);
|
|
},
|
|
});
|
|
});
|
|
|
|
// validate_locals_not_reassigned_after_render (src/react_compiler/validation)
|
|
// records the locals a component's closures capture while walking the
|
|
// component body, and reports a nested function that assigns to one of them,
|
|
// with a different diagnostic when that function, or one it is nested in, is
|
|
// async. `error` is the headline of the diagnostic, or null when the component
|
|
// compiles.
|
|
//
|
|
// In a normal build a reported component is silently left uncompiled, and the
|
|
// aliasing validator independently reports the same components, so the
|
|
// diagnostic text is the only place this validator's decision is observable.
|
|
// That needs the fixture pragma support, which turns compiler diagnostics into
|
|
// build errors and is compiled out of release builds (see
|
|
// react-compiler-fixtures.test.ts).
|
|
const localReassignmentCases = {
|
|
InComponentBody: {
|
|
error: null,
|
|
source: /* jsx */ `
|
|
export function Comp({ items }) {
|
|
let count = 0;
|
|
count = items.length;
|
|
const onClick = () => console.log(count);
|
|
return <button onClick={onClick}>{count}</button>;
|
|
}
|
|
`,
|
|
},
|
|
InSyncCallback: {
|
|
error: "React Compiler: Error: Cannot reassign variable after render completes",
|
|
source: /* jsx */ `
|
|
import { useEffect } from "react";
|
|
export function Comp({ items }) {
|
|
let count = 0;
|
|
useEffect(() => {
|
|
count = items.length;
|
|
});
|
|
return <div>{count}</div>;
|
|
}
|
|
`,
|
|
},
|
|
InAsyncCallback: {
|
|
error: "React Compiler: Error: Cannot reassign variable in async function",
|
|
source: /* jsx */ `
|
|
export function Comp({ load }) {
|
|
let data = null;
|
|
const onClick = async () => {
|
|
data = await load();
|
|
};
|
|
return <button onClick={onClick}>{data}</button>;
|
|
}
|
|
`,
|
|
},
|
|
InSyncCallbackInsideAsyncCallback: {
|
|
error: "React Compiler: Error: Cannot reassign variable in async function",
|
|
source: /* jsx */ `
|
|
export function Comp({ load }) {
|
|
let data = null;
|
|
const onClick = async () => {
|
|
const store = value => {
|
|
data = value;
|
|
};
|
|
store(await load());
|
|
};
|
|
return <button onClick={onClick}>{data}</button>;
|
|
}
|
|
`,
|
|
},
|
|
};
|
|
|
|
test.skipIf(!isDebug && !isASAN)("react-compiler reports which kind of function reassigned a local", async () => {
|
|
using dir = tempDir(
|
|
"react-compiler-reassign",
|
|
Object.fromEntries(Object.entries(localReassignmentCases).map(([name, { source }]) => [`${name}.jsx`, source])),
|
|
);
|
|
|
|
const results: Record<string, { error: string | null; memoized: boolean }> = {};
|
|
for (const name of Object.keys(localReassignmentCases)) {
|
|
const result = await Bun.build({
|
|
entrypoints: [join(String(dir), `${name}.jsx`)],
|
|
target: "browser",
|
|
external: ["*"],
|
|
reactCompiler: true,
|
|
// @ts-expect-error test-only option, not in bun-types
|
|
reactCompilerParseTestPragmas: true,
|
|
throw: false,
|
|
});
|
|
const errors = result.logs.filter(log => log.level === "error");
|
|
results[name] = {
|
|
error: errors.length === 0 ? null : errors.map(log => String(log.message).split(".")[0]).join("\n"),
|
|
memoized: result.success && /\b_c\(\d+\)/.test(await result.outputs[0].text()),
|
|
};
|
|
}
|
|
|
|
expect(results).toEqual(
|
|
Object.fromEntries(
|
|
Object.entries(localReassignmentCases).map(([name, { error }]) => [name, { error, memoized: error === null }]),
|
|
),
|
|
);
|
|
});
|