diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index 00af550c2fe2..944900f8cdf8 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -132,7 +132,7 @@ jobs: # Skips a cold Babel pass over the whole source tree; seedJestPerfCache.yml writes the entry # on main and explains the key. Restore-only, so nothing an untrusted PR runs can end up in an # entry a later run executes, and no restore-keys: a prefix fallback could serve output built - # by an older babel-plugin-react-compiler and move the render counts gated at + # by an older React Compiler and move the render counts gated at # COUNT_DEVIATION: 0. Keep the key identical to the other three. - name: Restore Jest transform cache id: restoreJestCache @@ -187,7 +187,7 @@ jobs: # Skips a cold Babel pass over the whole source tree; seedJestPerfCache.yml writes the entry # on main and explains the key. Restore-only, so nothing an untrusted PR runs can end up in an # entry a later run executes, and no restore-keys: a prefix fallback could serve output built - # by an older babel-plugin-react-compiler and move the render counts gated at + # by an older React Compiler and move the render counts gated at # COUNT_DEVIATION: 0. Keep the key identical to the other three. - name: Restore Jest transform cache id: restoreJestCache diff --git a/CLAUDE.md b/CLAUDE.md index 746f1398332a..75b9f50947d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Use the `/react-native-best-practices` skill when working on performance-sensiti ### Memoization -React Compiler auto-memoizes code in components and hooks (excluding `tests/`). Two different compilers run it: `babel-plugin-react-compiler` on native (see `babel.config.js`) and `oxc-transform-react` on web (see `config/rsbuild/`) and under Jest (see `config/babel/oxcJestTransformer.js`). They do not behave identically. The compliance check and the ESLint processor run BOTH compilers via the shared helpers in `config/reactCompiler/` and only relax manual-memoization rules when both compilers memoize the file. +React Compiler auto-memoizes code in components and hooks (excluding `tests/`). `oxc-transform-react` runs it on web (see `config/rsbuild/`), on native (see `config/repack/`) and under Jest (see `config/babel/oxcJestTransformer.js`), all with the options in `config/babel/oxcReactCompilerConfig.js`. The compliance check and the ESLint processor use the same compiler via `config/reactCompiler/checkWithOxc.mjs`, and the ESLint processor only relaxes manual-memoization rules when it memoizes the file. Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that compile with React Compiler. @@ -71,7 +71,7 @@ Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that 1. **ESLint**: Run `npm run lint-changed` to catch lint errors early. 2. **TypeScript**: Run `npm run typecheck` after changes that may affect typing (types, interfaces, or function signatures). It runs the TypeScript 7 native compiler and is the required merge gate in CI. -3. **React Compiler**: If you added new React components/hooks or modified existing ones, run `npm run react-compiler-compliance-check check-changed` to verify they compile with React Compiler. This applies the same rules as CI, evaluated against BOTH the Babel and OXC compilers: new components/hooks must compile, existing compiled files must not regress, and changes must not introduce new memoization divergence (one compiler memoizing a file while the other does not). See `contributingGuides/REACT_COMPILER.md` for details and common fixes. +3. **React Compiler**: If you added new React components/hooks or modified existing ones, run `npm run react-compiler-compliance-check check-changed` to verify they compile with React Compiler. This applies the same rules as CI: new components/hooks must compile, and existing compiled files must not regress. See `contributingGuides/REACT_COMPILER.md` for details and common fixes. 4. **Spelling**: Run `npm run spell-changed` to catch spelling errors (it discovers changed files itself; pass an explicit file list only if you want to check specific files instead). CI validates with cspell, which remains the required merge gate. ### Testing diff --git a/babel.config.js b/babel.config.js index 3f80cb755e54..0def4f929370 100644 --- a/babel.config.js +++ b/babel.config.js @@ -5,14 +5,8 @@ require('dotenv').config(); */ process.env.EXPO_PUBLIC_USE_RN_FETCH = process.env.EXPO_PUBLIC_USE_RN_FETCH ?? '1'; -const BaseReactCompilerConfig = require('./config/babel/reactCompilerConfig'); const {expoInlineEnvVars} = require('babel-preset-expo/build/plugins/inline-env-vars'); -const ReactCompilerConfig = { - ...BaseReactCompilerConfig, - sources: (filename) => !filename.includes('tests/') && !filename.includes('node_modules/'), -}; - /** * Custom plugin that prints a file name when it's being processed by babel. * Disabled by default. To enable, set DEBUG_BABEL_TRACE=true in the environment. @@ -32,8 +26,6 @@ const isTestEnv = process.env.BABEL_ENV === 'test' || process.env.NODE_ENV === ' const metro = { presets: [require('@react-native/babel-preset')], plugins: [ - ['babel-plugin-react-compiler', ReactCompilerConfig], // must run first! - // This is needed due to a react-native bug: https://github.com/facebook/react-native/issues/29084#issuecomment-1030732709 // It is included in metro-react-native-babel-preset but needs to be before plugin-proposal-class-properties or FlatList will break '@babel/plugin-transform-flow-strip-types', @@ -189,5 +181,10 @@ module.exports = (api) => { return repack; } - return ['metro', 'babel-jest'].includes(runningIn) ? metro : {}; + // React Compiler now runs only in OXC (Re.Pack and Jest), so a Metro bundle would ship without it. + if (runningIn === 'metro') { + throw new Error('Metro bundles no longer run React Compiler. Build with Re.Pack instead (unset BUNDLER=metro).'); + } + + return runningIn === 'babel-jest' ? metro : {}; }; diff --git a/config/babel/oxcJestTransformer.js b/config/babel/oxcJestTransformer.js index b36f35e90b63..2e1ed64bafba 100644 --- a/config/babel/oxcJestTransformer.js +++ b/config/babel/oxcJestTransformer.js @@ -80,6 +80,17 @@ const REACT_COMPILER_CONFIG_KEY = JSON.stringify(oxcReactCompilerConfig()); const REACT_COMPILER_OPTIONS = oxcReactCompilerConfig(); +const COVERAGE_PACKAGES = ['@babel/core', 'babel-plugin-istanbul', '@babel/plugin-transform-modules-commonjs', '@babel/plugin-transform-dynamic-import']; + +let coverageVersionsKey; +// The coverage pass output depends on these packages, so a bump must miss the Jest cache. +function getCoverageVersionsKey() { + if (!coverageVersionsKey) { + coverageVersionsKey = COVERAGE_PACKAGES.map((name) => `${name}@${require(`${name}/package.json`).version}`).join(','); + } + return coverageVersionsKey; +} + function getLang(filename) { const ext = path.extname(filename).slice(1); if (ext === 'tsx') { @@ -91,19 +102,13 @@ function getLang(filename) { return 'jsx'; } -/** - * Coverage runs stay on babel-jest: it adds coverage in the pass it already makes, while the OXC path - * would need a third pass (OXC, esbuild, then istanbul). On CI that was slower and used more memory. - */ -function shouldUseOxc(filename, transformOptions) { - if (transformOptions?.instrument) { - return false; - } +function shouldUseOxc(filename) { return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename) && !TEST_FILE_RE.test(filename); } -function processWithOxc(sourceText, sourcePath) { - const {esbuild, transformSync} = getOxcPipeline(); +/** Compile with OXC (TS, JSX and React Compiler). Returns null on a fatal error so the caller falls back to babel-jest. */ +function compileWithOxc(sourceText, sourcePath) { + const {transformSync} = getOxcPipeline(); const oxcResult = transformSync(sourcePath, sourceText, { lang: getLang(sourcePath), sourcemap: true, @@ -114,7 +119,44 @@ function processWithOxc(sourceText, sourcePath) { if (oxcResult.fatal || !oxcResult.code) { return null; } + return oxcResult; +} + +/** + * Coverage runs: OXC compiles as usual, then one light Babel pass converts to CommonJS and adds the + * istanbul counters, reading OXC's source map so coverage points at the original lines. That replaces + * esbuild rather than adding a third pass after it. + */ +function processWithOxcForCoverage(sourceText, sourcePath, transformOptions) { + const oxcResult = compileWithOxc(sourceText, sourcePath); + if (!oxcResult) { + return null; + } + + const result = require('@babel/core').transformSync(oxcResult.code, { + filename: sourcePath, + configFile: false, + babelrc: false, + inputSourceMap: oxcResult.map, + sourceMaps: true, + auxiliaryCommentBefore: ' istanbul ignore next ', + plugins: [ + [require.resolve('babel-plugin-istanbul'), {cwd: transformOptions.config.cwd, exclude: []}], + [require.resolve('@babel/plugin-transform-modules-commonjs'), {strictMode: false}], + require.resolve('@babel/plugin-transform-dynamic-import'), + ], + }); + + return result?.code ? {code: result.code, map: result.map} : null; +} + +function processWithOxc(sourceText, sourcePath) { + const oxcResult = compileWithOxc(sourceText, sourcePath); + if (!oxcResult) { + return null; + } + const {esbuild} = getOxcPipeline(); // esbuild chains an inline input map, so its output maps straight back to the original file. const inputMap = Buffer.from(JSON.stringify(oxcResult.map)).toString('base64'); const cjs = esbuild.transformSync(`${oxcResult.code}\n//# sourceMappingURL=data:application/json;base64,${inputMap}`, { @@ -129,10 +171,10 @@ function processWithOxc(sourceText, sourcePath) { } module.exports = { - // Coverage runs go to babel-jest, which instruments them itself, so Jest must not instrument again. + // Coverage is instrumented here (istanbul plugin in the OXC coverage pass), so Jest must not instrument again. canInstrument: true, getCacheKey(sourceText, sourcePath, transformOptions) { - if (!shouldUseOxc(sourcePath, transformOptions)) { + if (!shouldUseOxc(sourcePath)) { return babelTransformer.getCacheKey(sourceText, sourcePath, transformOptions); } @@ -147,11 +189,12 @@ module.exports = { .update(REACT_COMPILER_CONFIG_KEY) .update(esbuild.version) .update(oxcVersion) + .update(transformOptions?.instrument ? `instrument:${getCoverageVersionsKey()}` : '') .digest('hex'); }, process(sourceText, sourcePath, transformOptions) { - if (shouldUseOxc(sourcePath, transformOptions)) { - const result = processWithOxc(sourceText, sourcePath); + if (shouldUseOxc(sourcePath)) { + const result = transformOptions?.instrument ? processWithOxcForCoverage(sourceText, sourcePath, transformOptions) : processWithOxc(sourceText, sourcePath); if (result) { return result; } diff --git a/config/babel/oxcReactCompilerConfig.js b/config/babel/oxcReactCompilerConfig.js index c89a3fdddfac..1f1978309f1b 100644 --- a/config/babel/oxcReactCompilerConfig.js +++ b/config/babel/oxcReactCompilerConfig.js @@ -4,11 +4,9 @@ * without two of them, so components carrying `react-hooks` suppressions shipped with no memoization * while the web build and the checker memoized the same files. */ -const BaseReactCompilerConfig = require('./reactCompilerConfig'); - /** - * `eslintSuppressionRules: []` matches the Babel/Metro lane. Without it OXC treats a `react-hooks` - * suppression as an opt-out of compilation. `isDev` is absent because OXC has no such option, so + * `eslintSuppressionRules: []` keeps a `react-hooks` suppression from opting the file out of + * compilation, which OXC otherwise does. `isDev` is absent because OXC has no such option, so * passing it changes nothing. * * Overrides are limited to `sources` (web compiles node_modules too) and `panicThreshold` (the checker @@ -19,7 +17,10 @@ const BaseReactCompilerConfig = require('./reactCompilerConfig'); */ function oxcReactCompilerConfig(overrides = {}) { return { - ...BaseReactCompilerConfig, + target: '19', + environment: { + enableTreatRefLikeIdentifiersAsRefs: true, + }, eslintSuppressionRules: [], panicThreshold: 'none', ...overrides, diff --git a/config/babel/reactCompilerConfig.js b/config/babel/reactCompilerConfig.js deleted file mode 100644 index 7858e3de29cf..000000000000 --- a/config/babel/reactCompilerConfig.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Shared React Compiler configuration used across: - * - babel.config.js (build pipeline, extends with `sources` filter) - * - react-compiler-compliance-check (CI and local checking) - * - * ESLint uses oxc-transform-react via config/reactCompiler/checkWithOxc.mjs instead. - * - * Intentionally omits `sources` since that's only relevant for the Babel build pipeline. - */ -const ReactCompilerConfig = { - target: '19', - environment: { - enableTreatRefLikeIdentifiersAsRefs: true, - }, -}; - -module.exports = ReactCompilerConfig; diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index ba61f3b2ac04..1759e8d8c46c 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -553,7 +553,7 @@ const config = defineConfig([ { files: ['src/**/*.ts', 'src/**/*.tsx'], rules: { - // The lint pipeline keeps this diagnostic only when both compilers memoize the file. + // The lint pipeline keeps this diagnostic only when React Compiler memoizes the file. 'rulesdir/no-manual-memoization': 'error', 'rulesdir/prefer-locale-compare-from-context': 'error', 'rulesdir/no-object-keys-includes': 'error', diff --git a/config/reactCompiler/checkBoth.d.mts b/config/reactCompiler/checkBoth.d.mts deleted file mode 100644 index 81b0507edce1..000000000000 --- a/config/reactCompiler/checkBoth.d.mts +++ /dev/null @@ -1,13 +0,0 @@ -import type {CompilationResult as BabelCompilationResult} from './checkWithBabel.d.mts'; -import type {CompilationResult as OxcCompilationResult} from './checkWithOxc.d.mts'; - -type DualCompilationResult = { - babel: BabelCompilationResult; - oxc: OxcCompilationResult; - isDivergent: boolean; -}; - -declare function checkBothCompilers(source: string, filename: string): DualCompilationResult; -declare function didBothCompilersMemoizeFile(source: string, filename: string): boolean; - -export {checkBothCompilers, didBothCompilersMemoizeFile}; diff --git a/config/reactCompiler/checkBoth.mjs b/config/reactCompiler/checkBoth.mjs deleted file mode 100644 index c5b33002e70a..000000000000 --- a/config/reactCompiler/checkBoth.mjs +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Runs both React Compilers (Babel + OXC) against a single source file and reports - * whether they disagree on memoization. - * - * The web build uses OXC (oxc-transform-react) while Metro/Jest use babel-plugin-react-compiler. - * When one compiler memoizes a file and the other does not, that file ships without - * memoization on one platform. `isDivergent` captures exactly that gap by comparing the - * `memoized` signal (did the compiler actually emit a `_c(...)` cache?) rather than the - * looser compile-health status, since a file can "compile" without being memoized. - */ -import checkReactCompilerWithBabel from './checkWithBabel.mjs'; -import checkReactCompilerWithOxc from './checkWithOxc.mjs'; - -/** - * @returns {{babel: {status: string, memoized: boolean, errors: unknown[]}, oxc: {status: string, memoized: boolean, errors: unknown[]}, isDivergent: boolean}} - */ -function checkBothCompilers(source, filename) { - const babel = checkReactCompilerWithBabel(source, filename); - const oxc = checkReactCompilerWithOxc(source, filename); - const isDivergent = babel.memoized !== oxc.memoized; - return {babel, oxc, isDivergent}; -} - -/** - * True only when BOTH compilers fully compile and memoize the file. Used to decide when - * manual-memoization lint rules can be safely suppressed: if either platform's compiler skips - * memoization (or bails on a Rules-of-React violation), the manual memoization is still needed there, - * so the rules must stay active. - * - * Babel's `memoized` can be true while its `status` is `failed` (one function fails to compile while - * another still emits a memo cache); in that case the failed function is NOT auto-memoized, so we must - * not suppress the rules. This matches the pre-OXC processor, which suppressed only when Babel fully - * compiled the file. - * - * OXC is checked first and short-circuits, so the (heavier) Babel analysis is skipped for files OXC - * does not memoize. This keeps the ESLint processor, which calls this for every linted file, cheap. - * (OXC's `memoized` is only true when its status is `compiled`, so no separate OXC status check is needed.) - */ -function didBothCompilersMemoizeFile(source, filename) { - if (!checkReactCompilerWithOxc(source, filename).memoized) { - return false; - } - const babel = checkReactCompilerWithBabel(source, filename); - return babel.memoized && babel.status !== 'failed'; -} - -export {checkBothCompilers, didBothCompilersMemoizeFile}; diff --git a/config/reactCompiler/checkWithBabel.d.mts b/config/reactCompiler/checkWithBabel.d.mts deleted file mode 100644 index 0375c66e494c..000000000000 --- a/config/reactCompiler/checkWithBabel.d.mts +++ /dev/null @@ -1,22 +0,0 @@ -type SourceLocation = { - start: {line: number; column: number}; - end: {line: number; column: number}; -}; - -type CompilerError = { - reason: string; - severity: string; - loc?: SourceLocation; - fnLoc?: SourceLocation; -}; - -type CompilationResult = { - status: 'compiled' | 'failed' | 'no-components'; - memoized: boolean; - errors: CompilerError[]; -}; - -declare function checkReactCompilerWithBabel(source: string, filename: string): CompilationResult; - -export type {CompilationResult}; -export default checkReactCompilerWithBabel; diff --git a/config/reactCompiler/checkWithBabel.mjs b/config/reactCompiler/checkWithBabel.mjs deleted file mode 100644 index 3e4ee4a83c1e..000000000000 --- a/config/reactCompiler/checkWithBabel.mjs +++ /dev/null @@ -1,98 +0,0 @@ -/** - * React Compiler analysis via babel-plugin-react-compiler (sync). - * - * Shared API mirroring the Metro/Jest build pipeline (babel.config.js). Consumed by the - * CI compliance check and (via checkBoth) the ESLint processor so both tools agree on - * what Babel does with a file. - * - * Returns two independent signals: - * - `status` ('compiled' | 'failed' | 'no-components'): compile health, driven by the - * compiler's CompileSuccess/CompileError events. This mirrors "does it obey the Rules of React". - * - `memoized` (boolean): whether the compiler actually memoized anything, derived from the - * `memoBlocks` count on the CompileSuccess event. A file can be `compiled` without being - * `memoized` (e.g. a trivial hook that just calls useContext), so this is the signal that - * matters when comparing against OXC to find cross-platform memoization gaps. - * - * Analysis runs with `noEmit: true` (no code generation) so it stays cheap enough to run on - * every file in the repo from the ESLint processor. - */ -import {transformSync} from '@babel/core'; -import {createRequire} from 'node:module'; - -const require = createRequire(import.meta.url); - -const ReactCompilerConfig = require('../babel/reactCompilerConfig'); - -/** - * Check a source string with the Babel React Compiler. - * @returns {{status: 'compiled' | 'failed' | 'no-components', memoized: boolean, errors: Array<{reason: string, severity: string, loc?: unknown, fnLoc?: unknown}>}} - */ -function checkReactCompilerWithBabel(source, filename) { - let hasError = false; - let hasSuccess = false; - let memoBlocks = 0; - const errors = []; - - try { - transformSync(source, { - filename, - ast: false, - code: false, - configFile: false, - babelrc: false, - parserOpts: { - plugins: ['typescript', 'jsx'], - }, - plugins: [ - [ - 'babel-plugin-react-compiler', - { - ...ReactCompilerConfig, - panicThreshold: 'none', - noEmit: true, - logger: { - logEvent(_filename, event) { - if (event.kind === 'CompileError') { - hasError = true; - if (event.detail?.reason) { - errors.push({ - reason: event.detail.reason ?? 'Unknown compiler error', - severity: event.detail.severity ?? 'Error', - loc: event.detail.loc, - fnLoc: event.fnLoc, - }); - } - } - if (event.kind === 'CompileSuccess') { - hasSuccess = true; - memoBlocks += event.memoBlocks ?? 0; - } - }, - }, - }, - ], - ], - }); - } catch (error) { - hasError = true; - errors.push({ - reason: error instanceof Error ? error.message : String(error), - severity: 'Error', - }); - } - - const memoized = memoBlocks > 0; - - let status; - if (hasError) { - status = 'failed'; - } else if (hasSuccess) { - status = 'compiled'; - } else { - status = 'no-components'; - } - - return {status, memoized, errors: status === 'failed' ? errors : []}; -} - -export default checkReactCompilerWithBabel; diff --git a/config/reactCompiler/checkWithOxc.d.mts b/config/reactCompiler/checkWithOxc.d.mts index dda41583af83..48c2f6b3155e 100644 --- a/config/reactCompiler/checkWithOxc.d.mts +++ b/config/reactCompiler/checkWithOxc.d.mts @@ -17,5 +17,5 @@ type CompilationResult = { declare function checkReactCompilerWithOxc(source: string, filename: string): CompilationResult; -export type {CompilationResult}; +export type {CompilationResult, CompilerError}; export default checkReactCompilerWithOxc; diff --git a/config/reactCompiler/checkWithOxc.mjs b/config/reactCompiler/checkWithOxc.mjs index b4b81131d9d2..6056c39878aa 100644 --- a/config/reactCompiler/checkWithOxc.mjs +++ b/config/reactCompiler/checkWithOxc.mjs @@ -12,7 +12,7 @@ * * A file where one function violates the Rules of React reports `memoized: false`, * rather than the partial memoization the compiler would otherwise emit for its remaining functions. - * `didBothCompilersMemoizeFile` relies on that, since it will only suppress manual-memoization lint rules when the whole file is memoized. + * The ESLint processor relies on that, since it will only suppress manual-memoization lint rules when the whole file is memoized. * * `status` and `memoized` answer different questions and are derived separately. `memoized` comes * only from the emitted memo cache: targeting React 19 the compiler can memoize solely by importing @@ -20,7 +20,7 @@ * additionally counts a file the compiler rewrote without memoizing it as `compiled`. Deriving * `memoized` from that same rewrite would overstate it -- the compiler perturbs output for reasons * that have nothing to do with memoization, such as expanding a concise arrow body or dropping a - * comment, and Babel reports no memo blocks for every file in `src/` where it does. + * comment. */ import path from 'node:path'; import {transformSync} from 'oxc-transform-react'; diff --git a/config/repack/rspack.config.mjs b/config/repack/rspack.config.mjs index e93a3fcbf207..749ec0a9e86c 100644 --- a/config/repack/rspack.config.mjs +++ b/config/repack/rspack.config.mjs @@ -53,7 +53,6 @@ export default Repack.defineRspackConfig((env) => { // Without this the cache reuses stale transform output after a babel, loader or .env change. buildDependencies: [ path.resolve(projectRoot, 'babel.config.js'), - path.resolve(projectRoot, 'config/babel/reactCompilerConfig.js'), path.resolve(projectRoot, 'config/babel/oxcReactCompilerConfig.js'), path.resolve(projectRoot, 'config/repack/rspack.config.mjs'), path.resolve(projectRoot, 'config/repack/cjs-inline-requires-loader.mjs'), diff --git a/config/rsbuild/loaders/oxc-react-compiler-loader.mjs b/config/rsbuild/loaders/oxc-react-compiler-loader.mjs index 5be7f34e53d7..2b077f5e7a10 100644 --- a/config/rsbuild/loaders/oxc-react-compiler-loader.mjs +++ b/config/rsbuild/loaders/oxc-react-compiler-loader.mjs @@ -6,7 +6,6 @@ * optimizing the offending component and reports the reason as a `Warning`. Everything that * genuinely stops the file being transformed (parse errors, semantic analysis, invalid options) * arrives as an `Error` and sets `fatal` on the result, which is what fails the build here. - * That mirrors babel-plugin-react-compiler's behaviour on the Metro/Jest side. */ import remapping from '@jridgewell/remapping'; diff --git a/contributingGuides/LINTING.md b/contributingGuides/LINTING.md index bf3fb6cf5fe9..b88658f07b67 100644 --- a/contributingGuides/LINTING.md +++ b/contributingGuides/LINTING.md @@ -153,6 +153,6 @@ The env-var names and TSV format are unchanged from the previous `eslint-seatbel - [Ratcheting errors](https://www.notion.com/blog/how-we-evolved-our-code-notions-ratcheting-system-using-custom-eslint-rules). - [`CONSISTENCY-5`: Justify ESLint rule disables](../.claude/skills/app-coding-standards/rules/consistency-5-justify-eslint-disable.md) — when `eslint-disable` is acceptable, and how to document it. -- `rulesdir/no-manual-memoization` forbids `React.memo`, `useMemo`, and `useCallback` when both React Compilers memoize the file. Callbacks passed to `useFocusEffect` may retain `useCallback`, which React Navigation requires. +- `rulesdir/no-manual-memoization` forbids `React.memo`, `useMemo`, and `useCallback` when React Compiler memoizes the file. Callbacks passed to `useFocusEffect` may retain `useCallback`, which React Navigation requires. - [`STYLE.md`](./STYLE.md) — the coding style rules many of our ESLint rules enforce. - [`REACT_COMPILER.md`](./REACT_COMPILER.md) — separate compiler-compliance CI check that runs alongside lint. diff --git a/contributingGuides/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index 15dfe546b1fb..ed79da605767 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -4,53 +4,40 @@ [React Compiler](https://react.dev/learn/react-compiler) is a tool designed to enhance the performance of React applications by automatically memoizing components that lack optimizations. -React Compiler is enabled in the web build pipeline via `oxc-transform-react` (the Rust React Compiler) and in the Metro (mobile) build pipeline via `babel-plugin-react-compiler`. Because these are two different implementations, they don't always memoize a file the same way: a file can be auto-memoized on native but not on web, or vice versa. To catch that, both the CI check and the ESLint processor run BOTH compilers, via the shared helpers in `config/reactCompiler/` (`checkWithBabel.mjs`, `checkWithOxc.mjs`, and `checkBoth.mjs`). +React Compiler runs in every build lane via `oxc-transform-react` (the Rust React Compiler): the web build (Rsbuild), the native build (Re.Pack) and Jest all compile app source with it. They share one set of options, `config/babel/oxcReactCompilerConfig.js`, and the CI check and the ESLint processor use the same compiler through `config/reactCompiler/checkWithOxc.mjs`. ## Manual memoization -`rulesdir/no-manual-memoization` disallows `React.memo`, `useMemo`, and `useCallback` in files fully compiled and memoized by both compilers. Derive values directly in render and use plain functions instead of maintaining dependency arrays. The lint pipeline allows manual memoization when either compiler cannot memoize the file. +`rulesdir/no-manual-memoization` disallows `React.memo`, `useMemo`, and `useCallback` in files fully compiled and memoized by React Compiler. Derive values directly in render and use plain functions instead of maintaining dependency arrays. The lint pipeline allows manual memoization when the compiler cannot memoize the file. Callbacks passed to React Navigation's `useFocusEffect` are the exception: keep their `useCallback` wrapper because React Navigation requires a stable callback. ## React Compiler CI check -A CI check runs on every PR that modifies `.ts` or `.tsx` files. For each changed file it runs both `babel-plugin-react-compiler` and `oxc-transform-react` and reports, for each, whether the file compiles and whether it is actually memoized. +A CI check runs on every PR that modifies `.ts` or `.tsx` files. For each changed file it runs `oxc-transform-react` and reports whether the file compiles and whether it is actually memoized. ### What the CI check enforces -The check enforces three rules, evaluated against **both** compilers: +The check enforces two rules: -1. **New files must compile.** If you add a new file containing React components or hooks, they must all compile with both React Compilers. This prevents new Rules of React violations from entering the codebase. -2. **No regressions.** If a file compiles on `main` with a given compiler and your PR breaks that, the check fails. This prevents introducing violations into files that were previously compliant. -3. **No new memoization divergence.** If your change makes a file divergent -- one compiler memoizes it while the other does not -- and it was not already divergent on `main`, the check fails. Divergent files ship without memoization on one platform, which reintroduces the performance issues React Compiler is meant to fix. +1. **New files must compile.** If you add a new file containing React components or hooks, they must all compile with the React Compiler. This prevents new Rules of React violations from entering the codebase. +2. **No regressions.** If a file compiles on `main` and your PR breaks that, the check fails. This prevents introducing violations into files that were previously compliant. The check does **not** fail if: - A file has no React components or hooks (utilities, types, constants are silently skipped) - A file was already failing to compile on `main` and still fails on your branch (not a regression) -- A file was already divergent on `main` (existing divergences are grandfathered; fix them opportunistically by adding manual memoization) ### What to do when the check fails -The CI output shows which files failed, which compiler flagged them, and the reason. Look for lines like: +The CI output shows which files failed and the reason. Look for lines like: ``` -FAILED src/components/MyComponent.tsx (new file must compile, oxc) - [oxc] 42:8: Hooks must always be called in a consistent order... - -FAILED src/components/MyComponent.tsx (introduces new memoization divergence) - Babel memoizes this file but OXC does not (oxc=no-components) -- it will not be memoized on web +FAILED src/components/MyComponent.tsx (new file must compile) + 42:8: Hooks must always be called in a consistent order... ``` Compile-failure messages come directly from React Compiler and describe which [Rule of React](https://react.dev/reference/rules) was violated; see the ["How to fix a particular problem?"](#how-to-fix-a-particular-problem) section below for common fixes. -A **memoization divergence** failure means one compiler auto-memoizes the file and the other does not. Fix it by adding the missing manual memoization so the file behaves the same on both platforms: - -- Wrap objects/arrays/callbacks passed as props (or into hook dependency arrays) in `useMemo`/`useCallback` -- especially React Context `value` objects. -- Extract inline `useOnyx` selectors into a stable `useCallback`. -- If a whole file bails out under OXC (`oxc=failed`), fixing the underlying Rule of React violation usually restores memoization on web. - -You can reproduce the exact list locally with `bun scripts/react-compiler-divergence-audit.ts`, which runs both compilers over `src` and lists every divergent file grouped by direction. - ### Local usage You can run the same check locally before pushing: diff --git a/eslint-plugin-local-rules/no-manual-memoization.js b/eslint-plugin-local-rules/no-manual-memoization.js index b332591e16f7..c5055da8cc0b 100644 --- a/eslint-plugin-local-rules/no-manual-memoization.js +++ b/eslint-plugin-local-rules/no-manual-memoization.js @@ -5,7 +5,7 @@ const REACT_NAVIGATION_MODULES = ['@react-navigation/native', '@react-navigation const meta = { type: 'problem', docs: { - description: 'Disallow manual memoization when both React Compilers memoize the file.', + description: 'Disallow manual memoization when React Compiler memoizes the file.', recommended: 'error', }, schema: [], diff --git a/knip.json b/knip.json index f03420b50cbb..d21797f34977 100644 --- a/knip.json +++ b/knip.json @@ -78,7 +78,6 @@ "@babel/plugin-proposal-private-property-in-object", "@babel/plugin-transform-export-namespace-from", "babel-plugin-module-resolver", - "babel-plugin-react-compiler", "babel-plugin-transform-remove-console", "html-entities", "@fullstory/babel-plugin-react-native", diff --git a/package-lock.json b/package-lock.json index 80ce7d965ed9..8899d9bc42c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -165,7 +165,9 @@ "@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@callstack/reassure-compare": "^1.0.0-rc.4", "@callstack/repack": "5.4.1", "@callstack/repack-plugin-expo-modules": "5.4.1", @@ -235,8 +237,8 @@ "@vitest/mocker": "4.0.16", "@welldone-software/why-did-you-render": "7.0.1", "babel-jest": "29.7.0", + "babel-plugin-istanbul": "^6.1.1", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "^57.0.3", "bun": "^1.4.4", @@ -2738,11 +2740,13 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.9", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -22030,9 +22034,9 @@ } }, "node_modules/babel-plugin-react-compiler": { - "version": "0.0.0-experimental-a1856f3-20260507", - "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-0.0.0-experimental-a1856f3-20260507.tgz", - "integrity": "sha512-NH/o9ojGrQ5xQhkLry8KulKssfWuM68myEGVUpIJhExt1/WVWBOTBPSPGiSRoJtfB9yP8Kj4UXZpLXKFCXineg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" diff --git a/package.json b/package.json index 7902cf5d9165..228d4d6286c6 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "gh-actions-unused-styles": "bun scripts/findUnusedStyles.ts", "setup-https": "mkcert -install && mkcert -cert-file config/rsbuild/certificate.pem -key-file config/rsbuild/key.pem dev.new.expensify.com localhost 127.0.0.1", "react-compiler-compliance-check": "bun scripts/react-compiler-compliance-check.ts", - "react-compiler-divergence-audit": "bun scripts/react-compiler-divergence-audit.ts", "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy src/libs/SearchParser/baseRules.peggy", "generate-autocomplete-parser": "peggy --format es -o src/libs/SearchParser/autocompleteParser.js src/libs/SearchParser/autocompleteParser.peggy src/libs/SearchParser/baseRules.peggy && ./scripts/parser-workletization.sh src/libs/SearchParser/autocompleteParser.js", "web:dist": "concurrently \"npm:web-proxy\" \"http-server ./dist --cors --port 8080 -P http://localhost:9000\"", @@ -242,7 +241,9 @@ "@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@callstack/reassure-compare": "^1.0.0-rc.4", "@callstack/repack": "5.4.1", "@callstack/repack-plugin-expo-modules": "5.4.1", @@ -312,8 +313,8 @@ "@vitest/mocker": "4.0.16", "@welldone-software/why-did-you-render": "7.0.1", "babel-jest": "29.7.0", + "babel-plugin-istanbul": "^6.1.1", "babel-plugin-module-resolver": "^5.0.0", - "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "babel-plugin-transform-remove-console": "^6.9.4", "babel-preset-expo": "^57.0.3", "bun": "^1.4.4", @@ -407,7 +408,6 @@ "serialize-javascript": "7.1.2", "adm-zip": "0.6.1", "tinypool": "2.1.2", - "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "react": "19.2.3", "react-dom": "19.2.3", "eslint-config-expensify": { diff --git a/rock.config.mjs b/rock.config.mjs index 42b54180ee2d..b8f3b3b8c4a8 100644 --- a/rock.config.mjs +++ b/rock.config.mjs @@ -5,9 +5,8 @@ import {pluginRepack} from '@rock-js/plugin-repack'; import {providerS3} from '@rock-js/provider-s3'; const isHybrid = process.env.IS_HYBRID_APP === 'true'; -// `BUNDLER=metro` reverts the native build to Metro. Keep it while HybridApp still bundles with -// Metro: its release builds go through the Gradle plugin's own Metro task, so deleting Metro here is -// blocked on moving hybrid bundling onto Rock in the wrapper repo first. +// `BUNDLER=metro` reverts the native build to Metro. Metro no longer runs React Compiler, so +// babel.config.js refuses Metro builds; the flag stays only until Metro is removed entirely. const useMetro = process.env.BUNDLER === 'metro'; const isPublicAccess = !process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY; diff --git a/scripts/check-both-compilers-runner.mjs b/scripts/check-both-compilers-runner.mjs deleted file mode 100644 index 37acfe372657..000000000000 --- a/scripts/check-both-compilers-runner.mjs +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env node -/** - * CLI wrapper for checkBothCompilers used by Jest tests (oxc-transform-react is ESM-only and - * cannot be loaded directly in the Jest environment). - * - * Usage: node scripts/check-both-compilers-runner.mjs - */ -import fs from 'node:fs'; - -import {checkBothCompilers} from '../config/reactCompiler/checkBoth.mjs'; - -const [filename, sourcePath] = process.argv.slice(2); -const source = fs.readFileSync(sourcePath, 'utf8'); -process.stdout.write(JSON.stringify(checkBothCompilers(source, filename))); diff --git a/scripts/lint/processors/ReactCompilerFilter.ts b/scripts/lint/processors/ReactCompilerFilter.ts index ceb9a0626235..4e5f0dd6379a 100644 --- a/scripts/lint/processors/ReactCompilerFilter.ts +++ b/scripts/lint/processors/ReactCompilerFilter.ts @@ -11,11 +11,7 @@ const MANUAL_MEMOIZATION_RULE = 'rulesdir/no-manual-memoization'; const EXHAUSTIVE_DEPS_USECALLBACK_USEMEMO_PATTERN = /\buseCallback\(\) Hook\b|\buseMemo\(\) Hook\b/; const CACHE_DIR = 'node_modules/.cache/react-compiler'; const REACT_COMPILER_FINGERPRINT_FILES = [ - 'babel.config.js', - 'config/babel/reactCompilerConfig.js', 'config/babel/oxcReactCompilerConfig.js', - 'config/reactCompiler/checkBoth.mjs', - 'config/reactCompiler/checkWithBabel.mjs', 'config/reactCompiler/checkWithOxc.mjs', 'config/rsbuild/rsbuild.common.ts', 'package-lock.json', @@ -75,8 +71,8 @@ async function readCache(cacheFilePath: string): Promise { const CACHE_HIT = '1'; const CACHE_MISS = '0'; -async function writeCache(cacheFilePath: string, bothMemoized: boolean): Promise { - await Bun.write(cacheFilePath, bothMemoized ? CACHE_HIT : CACHE_MISS); +async function writeCache(cacheFilePath: string, memoized: boolean): Promise { + await Bun.write(cacheFilePath, memoized ? CACHE_HIT : CACHE_MISS); } type Candidate = { @@ -86,29 +82,29 @@ type Candidate = { type CompilerWorkerResponse = { filename: string; - bothMemoized: boolean; + memoized: boolean; cacheable: boolean; }; function conservativeFallback(candidate: Candidate): CompilerWorkerResponse { - return {filename: candidate.filename, bothMemoized: false, cacheable: false}; + return {filename: candidate.filename, memoized: false, cacheable: false}; } function shouldPersistCompilerCache(verdict: CompilerWorkerResponse | undefined): boolean { return verdict?.cacheable === true; } -async function checkCandidatesWithPool(candidates: Candidate[], checkBoth: CompilerCheck | undefined, workerCount: number): Promise> { +async function checkCandidatesWithPool(candidates: Candidate[], checkCompiler: CompilerCheck | undefined, workerCount: number): Promise> { const verdicts = new Map(); if (candidates.length === 0) { return verdicts; } - if (checkBoth) { + if (checkCompiler) { await Promise.all( candidates.map(async (candidate) => { try { - verdicts.set(candidate.filename, {filename: candidate.filename, bothMemoized: await checkBoth(candidate.source, candidate.filename), cacheable: true}); + verdicts.set(candidate.filename, {filename: candidate.filename, memoized: await checkCompiler(candidate.source, candidate.filename), cacheable: true}); } catch { // Conservative: keep the message rather than aborting the whole lint. verdicts.set(candidate.filename, conservativeFallback(candidate)); @@ -127,8 +123,8 @@ async function checkCandidatesWithPool(candidates: Candidate[], checkBoth: Compi } /** - * Drop redundant messages and enforce no-manual-memoization only after both - * compilers memoize the file. Files with no compiler-dependent message are skipped + * Drop redundant messages and enforce no-manual-memoization only after the + * compiler memoizes the file. Files with no compiler-dependent message are skipped * entirely (~60× fewer compiler invocations than the ESLint processor). * * Cache is one file per compiler fingerprint and content hash so concurrent @@ -138,21 +134,21 @@ class ReactCompilerFilter extends Processor { readonly name = 'react-compiler-filter'; constructor( - private readonly checkBoth?: CompilerCheck, + private readonly checkCompiler?: CompilerCheck, private readonly workerCount = navigator.hardwareConcurrency || 4, ) { super(); } process(messages: LintMessage[], context: ProcessorContext): Promise { - return filterReactCompilerMessages(messages, context.projectRoot, this.checkBoth, this.workerCount); + return filterReactCompilerMessages(messages, context.projectRoot, this.checkCompiler, this.workerCount); } } async function filterReactCompilerMessages( messages: LintMessage[], projectRoot: string, - checkBoth?: CompilerCheck, + checkCompiler?: CompilerCheck, workerCount = navigator.hardwareConcurrency || 4, ): Promise { const byFile = new Map(); @@ -178,12 +174,12 @@ async function filterReactCompilerMessages( const uncached: Candidate[] = []; const memoized = new Map(); - const reactCompilerFingerprint = checkBoth ? undefined : await getReactCompilerFingerprint(projectRoot); + const reactCompilerFingerprint = checkCompiler ? undefined : await getReactCompilerFingerprint(projectRoot); await Promise.all( candidateNames.map(async (filename) => { let source = ''; - if (!checkBoth) { + if (!checkCompiler) { try { source = await file(filename).text(); } catch { @@ -204,16 +200,16 @@ async function filterReactCompilerMessages( }), ); - const computed = await checkCandidatesWithPool(uncached, checkBoth, workerCount); + const computed = await checkCandidatesWithPool(uncached, checkCompiler, workerCount); await Promise.all( uncached.map(async (candidate) => { const verdict = computed.get(candidate.filename) ?? conservativeFallback(candidate); - memoized.set(candidate.filename, verdict.bothMemoized); - if (checkBoth || !shouldPersistCompilerCache(verdict)) { + memoized.set(candidate.filename, verdict.memoized); + if (checkCompiler || !shouldPersistCompilerCache(verdict)) { return; } try { - await writeCache(cachePath(projectRoot, cacheKey(reactCompilerFingerprint, candidate.filename, candidate.source)), verdict.bothMemoized); + await writeCache(cachePath(projectRoot, cacheKey(reactCompilerFingerprint, candidate.filename, candidate.source)), verdict.memoized); } catch { // Conservative: skip the cache write rather than abort the lint. } diff --git a/scripts/lint/processors/ReactCompilerWorker.ts b/scripts/lint/processors/ReactCompilerWorker.ts index 6211852e144c..0eacb2d3d474 100644 --- a/scripts/lint/processors/ReactCompilerWorker.ts +++ b/scripts/lint/processors/ReactCompilerWorker.ts @@ -1,6 +1,6 @@ // The compiler helper is ESM (.mjs); Bun resolves it without the extension. // eslint-disable-next-line import/extensions -import {didBothCompilersMemoizeFile} from '../../../config/reactCompiler/checkBoth.mjs'; +import checkReactCompilerWithOxc from '../../../config/reactCompiler/checkWithOxc.mjs'; type WorkerRequest = { filename: string; @@ -9,7 +9,7 @@ type WorkerRequest = { type WorkerResponse = { filename: string; - bothMemoized: boolean; + memoized: boolean; cacheable: boolean; }; @@ -18,13 +18,13 @@ declare const self: Worker; self.onmessage = (event: MessageEvent) => { const {filename, source} = event.data; try { - const response: WorkerResponse = {filename, bothMemoized: didBothCompilersMemoizeFile(source, filename), cacheable: true}; + const response: WorkerResponse = {filename, memoized: checkReactCompilerWithOxc(source, filename).memoized, cacheable: true}; postMessage(response); } catch { // Conservative: treat a compiler crash as "not memoized" so this file // keeps its suppressible messages instead of aborting the whole lint. // Do not cache this — a transient OOM/crash must not stick as a miss. - const response: WorkerResponse = {filename, bothMemoized: false, cacheable: false}; + const response: WorkerResponse = {filename, memoized: false, cacheable: false}; postMessage(response); } }; diff --git a/scripts/react-compiler-compliance-check.ts b/scripts/react-compiler-compliance-check.ts index 461793cbb652..b00d42a43924 100644 --- a/scripts/react-compiler-compliance-check.ts +++ b/scripts/react-compiler-compliance-check.ts @@ -2,183 +2,45 @@ /** * React Compiler Compliance Check * - * Checks how React components and hooks fare under BOTH React Compilers: Babel - * (babel-plugin-react-compiler, used by Metro/Jest) and OXC (oxc-transform-react, used - * by the web build). Two modes: - * - `check ` -- check specific files, report per-file dual status + * Checks how React components and hooks fare under the OXC React Compiler (oxc-transform-react), + * which every build lane uses. Two modes: + * - `check ` -- check specific files, report per-file status * - `check-changed` -- check files changed in a PR, enforce: - * 1. New files with components/hooks must compile under both compilers - * 2. Modified files must not regress (compiled on main -> must compile on PR) under either compiler - * 3. Changed files must not introduce new memoization divergence (one compiler memoizes, the - * other does not) that did not already exist on main - * - * The Babel analysis lives inline here (kept OXC-free at module scope) because this module's - * `checkReactCompilerCompliance` export is imported by a Jest unit test, and oxc-transform-react is - * excluded from Jest's transform. The OXC analysis is loaded via a dynamic import that only runs - * in the (bun) CLI paths, so importing this module in Jest never pulls in oxc-transform-react. + * 1. New files with components/hooks must compile + * 2. Modified files must not regress (compiled on main -> must compile on PR) */ -import {transformSync} from '@babel/core'; import CLI from 'expensify-common/CLI'; import fs from 'fs'; import path from 'path'; +// eslint-disable-next-line import/extensions +import type {CompilationResult, CompilerError} from '../config/reactCompiler/checkWithOxc.mjs'; + +// The checker is ESM (.mjs); Bun resolves it with the extension. +// eslint-disable-next-line import/extensions +import checkReactCompilerCompliance from '../config/reactCompiler/checkWithOxc.mjs'; import FileUtils from './utils/FileUtils'; import Git from './utils/Git'; import {error as logError, errorDetail as logErrorDetail, info as logInfo, success as logSuccess, warn as logWarn} from './utils/Logger'; -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const ReactCompilerConfig = require('../config/babel/reactCompilerConfig'); - -type SourceLocation = { - start: {line: number; column: number}; - end: {line: number; column: number}; -}; - -type CompilerError = { - reason: string; - severity: string; - loc?: SourceLocation; - fnLoc?: SourceLocation; -}; - -type CompilationResult = { - status: 'compiled' | 'failed' | 'no-components'; - memoized: boolean; - errors: CompilerError[]; -}; - -type CompilerLogEvent = { - kind: string; - fnLoc?: SourceLocation; - fnName?: string; - memoBlocks?: number; - detail?: { - severity?: string; - reason?: string; - loc?: SourceLocation; - }; -}; - -type OxcChecker = (source: string, filename: string) => CompilationResult; - -type DualResult = { - babel: CompilationResult; - oxc: CompilationResult; - isDivergent: boolean; -}; - const FILE_EXTENSIONS = ['.ts', '.tsx']; const IS_CI = process.env.CI === 'true'; -/** - * Check a source string with the Babel React Compiler. - * Returns the compile-health status, whether the compiler actually memoized anything, and any errors. - */ -function checkReactCompilerCompliance(source: string, filename: string): CompilationResult { - let hasError = false; - let hasSuccess = false; - let memoBlocks = 0; - const errors: CompilerError[] = []; - - try { - // Analyze with noEmit (no code generation) -- cheap, and the CompileSuccess event's - // memoBlocks count tells us whether memoization was actually applied. With panicThreshold - // 'none', Rules-of-React violations are reported via the logger rather than thrown. - transformSync(source, { - filename, - ast: false, - code: false, - configFile: false, - babelrc: false, - parserOpts: { - plugins: ['typescript', 'jsx'], - }, - plugins: [ - [ - 'babel-plugin-react-compiler', - { - ...ReactCompilerConfig, - panicThreshold: 'none', - noEmit: true, - logger: { - logEvent(_filename: string, event: CompilerLogEvent) { - if (event.kind === 'CompileError') { - hasError = true; - if (event.detail?.reason) { - errors.push({ - reason: event.detail.reason ?? 'Unknown compiler error', - severity: event.detail.severity ?? 'Error', - loc: event.detail.loc, - fnLoc: event.fnLoc, - }); - } - } - if (event.kind === 'CompileSuccess') { - hasSuccess = true; - memoBlocks += event.memoBlocks ?? 0; - } - }, - }, - }, - ], - ], - }); - } catch (e) { - hasError = true; - errors.push({ - reason: e instanceof Error ? e.message : String(e), - severity: 'Error', - }); - } - - const memoized = memoBlocks > 0; - - if (hasError) { - return {status: 'failed', memoized, errors}; - } - if (hasSuccess) { - return {status: 'compiled', memoized, errors: []}; - } - return {status: 'no-components', memoized, errors: []}; -} - -/** - * Run both compilers against a single file and report whether they disagree on memoization. - * A file is divergent when exactly one compiler emits memoization (`memoized` XOR). - */ -function checkBoth(source: string, filename: string, checkOxc: OxcChecker): DualResult { - const babel = checkReactCompilerCompliance(source, filename); - const oxc = checkOxc(source, filename); - return { - babel, - oxc, - isDivergent: babel.memoized !== oxc.memoized, - }; -} - -function divergenceLabel(result: DualResult): string { - if (result.babel.memoized) { - return `Babel memoizes this file but OXC does not (oxc=${result.oxc.status}) -- it will not be memoized on web`; - } - return `OXC memoizes this file but Babel does not (babel=${result.babel.status}) -- it will not be memoized on native`; -} - function formatErrorLocation(filename: string, error: CompilerError): string { - const loc = error.loc ?? error.fnLoc; - if (loc) { - return `${loc.start.line}:${loc.start.column}`; + if (error.loc) { + return `${error.loc.start.line}:${error.loc.start.column}`; } return filename; } -function printErrors(filename: string, label: string, errors: CompilerError[]): void { +function printErrors(filename: string, errors: CompilerError[]): void { if (IS_CI) { - console.log(`::group::${filename} ${label} (${errors.length} error${errors.length === 1 ? '' : 's'})`); + console.log(`::group::${filename} (${errors.length} error${errors.length === 1 ? '' : 's'})`); } for (const error of errors) { const location = formatErrorLocation(filename, error); - logErrorDetail(`${label} ${location}: ${error.reason}`); + logErrorDetail(`${location}: ${error.reason}`); } if (IS_CI) { console.log('::endgroup::'); @@ -186,9 +48,9 @@ function printErrors(filename: string, label: string, errors: CompilerError[]): } /** - * Check specific files and report per-file status for both compilers. + * Check specific files and report per-file status. */ -function checkFiles(inputs: string[], verbose: boolean, checkOxc: OxcChecker): boolean { +function checkFiles(inputs: string[], verbose: boolean): boolean { const files = FileUtils.resolveFilePaths(inputs, FILE_EXTENSIONS); if (files.length === 0) { @@ -197,31 +59,19 @@ function checkFiles(inputs: string[], verbose: boolean, checkOxc: OxcChecker): b } let hasFailure = false; - for (const file of files) { const source = fs.readFileSync(file, 'utf8'); - const {babel, oxc, isDivergent} = checkBoth(source, file, checkOxc); + const result = checkReactCompilerCompliance(source, file); - if (babel.status === 'failed') { - logError(`FAILED (babel) ${file}`); - printErrors(file, '[babel]', babel.errors); - hasFailure = true; - } - if (oxc.status === 'failed') { - logError(`FAILED (oxc) ${file}`); - printErrors(file, '[oxc]', oxc.errors); + if (result.status === 'failed') { + logError(`FAILED ${file}`); + printErrors(file, result.errors); hasFailure = true; - } - - if (babel.status !== 'failed' && oxc.status !== 'failed') { - if (isDivergent) { - logWarn(`DIVERGENT ${file}: ${divergenceLabel({babel, oxc, isDivergent})}`); - } else if (verbose) { - if (babel.status === 'compiled' || oxc.status === 'compiled') { - logSuccess(`COMPILED ${file} (babel=${babel.status}, oxc=${oxc.status})`); - } else { - logInfo(`SKIPPED ${file} (no components or hooks)`); - } + } else if (verbose) { + if (result.status === 'compiled') { + logSuccess(`COMPILED ${file}`); + } else { + logInfo(`SKIPPED ${file} (no components or hooks)`); } } } @@ -241,12 +91,11 @@ function getMainSource(ref: string, mainPath: string): string | undefined { } /** - * Check files changed in a PR for React Compiler compliance under both compilers. - * Rule 1: New files with components/hooks must compile under both compilers. - * Rule 2: Modified files must not regress (compiled on main -> must compile on PR) under either compiler. - * Rule 3: Changed files must not introduce new memoization divergence that did not exist on main. + * Check files changed in a PR for React Compiler compliance. + * Rule 1: New files with components/hooks must compile. + * Rule 2: Modified files must not regress (compiled on main -> must compile on PR). */ -async function checkChangedFiles(remote: string, verbose: boolean, checkOxc: OxcChecker): Promise { +async function checkChangedFiles(remote: string, verbose: boolean): Promise { const mainBaseCommitHash = await Git.getMainBranchCommitHash(remote); const changedFiles = await Git.getChangedFilesWithStatus(mainBaseCommitHash, undefined, true, FILE_EXTENSIONS); @@ -257,7 +106,7 @@ async function checkChangedFiles(remote: string, verbose: boolean, checkOxc: Oxc return true; } - logInfo(`Checking ${reactFiles.length} changed React files with both compilers (Babel + OXC)...`); + logInfo(`Checking ${reactFiles.length} changed React files...`); const failures: Array<{file: string; reason: string}> = []; @@ -268,57 +117,34 @@ async function checkChangedFiles(remote: string, verbose: boolean, checkOxc: Oxc } const source = fs.readFileSync(absolutePath, 'utf8'); - const branchResult = checkBoth(source, absolutePath, checkOxc); - - // Resolve the file's state on the base branch once, reused by the regression + divergence rules. - // Use the resolved base commit hash (which honors GITHUB_BASE_REF) rather than a hardcoded `main`, - // so grandfathering/regression detection works for PRs targeting any base branch. - const mainPath = previousFilename ?? filename; - const mainSource = status === 'added' ? undefined : getMainSource(mainBaseCommitHash, mainPath); - const mainResult = mainSource !== undefined ? checkBoth(mainSource, mainPath, checkOxc) : undefined; - - // Rule 1 + 2: compile failures. - for (const [compiler, result] of [ - ['babel', branchResult.babel], - ['oxc', branchResult.oxc], - ] as const) { - if (result.status !== 'failed') { - continue; - } + const branchResult = checkReactCompilerCompliance(source, absolutePath); - if (status === 'added') { - failures.push({file: filename, reason: `New file fails to compile with the ${compiler} React Compiler`}); - logError(`FAILED ${filename} (new file must compile, ${compiler})`); - printErrors(filename, `[${compiler}]`, result.errors); - continue; + if (branchResult.status !== 'failed') { + if (verbose) { + logSuccess(`OK ${filename} (${branchResult.status})`); } + continue; + } - // Modified/renamed: only a regression from the base branch counts. - const mainCompiledHere = compiler === 'babel' ? mainResult?.babel.status === 'compiled' : mainResult?.oxc.status === 'compiled'; - - if (mainCompiledHere) { - failures.push({file: filename, reason: `File compiled on main but fails to compile on this branch with the ${compiler} React Compiler (regression)`}); - logError(`FAILED ${filename} (regression: compiled on main, ${compiler})`); - printErrors(filename, `[${compiler}]`, result.errors); - } else if (verbose) { - logWarn(`WARNING ${filename} (fails to compile with ${compiler}, but also failed on main)`); - } + if (status === 'added') { + failures.push({file: filename, reason: 'New file fails to compile with the React Compiler'}); + logError(`FAILED ${filename} (new file must compile)`); + printErrors(filename, branchResult.errors); + continue; } - // Rule 3: newly-introduced memoization divergence. Existing base-branch divergences are grandfathered. - if (branchResult.isDivergent) { - const wasDivergentOnMain = mainResult?.isDivergent ?? false; - - if (!wasDivergentOnMain) { - const introduced = status === 'added' ? 'new file is memoization-divergent' : 'introduces new memoization divergence'; - failures.push({file: filename, reason: `${introduced}: ${divergenceLabel(branchResult)}`}); - logError(`FAILED ${filename} (${introduced})`); - logErrorDetail(divergenceLabel(branchResult)); - } else if (verbose) { - logWarn(`WARNING ${filename} (memoization-divergent, but was already divergent on main)`); - } + // Modified/renamed: only a regression from the base branch counts. Use the resolved base commit hash + // (which honors GITHUB_BASE_REF) rather than a hardcoded `main`, so PRs targeting any base branch work. + const mainPath = previousFilename ?? filename; + const mainSource = getMainSource(mainBaseCommitHash, mainPath); + const mainResult: CompilationResult | undefined = mainSource !== undefined ? checkReactCompilerCompliance(mainSource, mainPath) : undefined; + + if (mainResult?.status === 'compiled') { + failures.push({file: filename, reason: 'File compiled on main but fails to compile on this branch (regression)'}); + logError(`FAILED ${filename} (regression: compiled on main)`); + printErrors(filename, branchResult.errors); } else if (verbose) { - logSuccess(`OK ${filename} (babel=${branchResult.babel.status}, oxc=${branchResult.oxc.status})`); + logWarn(`WARNING ${filename} (fails to compile, but also failed on main)`); } } @@ -375,12 +201,6 @@ async function main() { const {remote} = cli.namedArgs; const {verbose} = cli.flags; - // Dynamically import the OXC checker so that importing this module in Jest (where oxc-transform-react - // is not transformable) never pulls it in -- the CLI paths below are never executed under Jest. - // The explicit .mjs extension is required for Node/bun ESM resolution of this JS module. - // eslint-disable-next-line import/extensions - const checkReactCompilerWithOxc = ((await import('../config/reactCompiler/checkWithOxc.mjs')) as {default: OxcChecker}).default; - let passed = false; switch (command) { @@ -389,10 +209,10 @@ async function main() { logError('No paths specified. Usage: npm run react-compiler-compliance-check check '); process.exit(1); } - passed = checkFiles(files, verbose, checkReactCompilerWithOxc); + passed = checkFiles(files, verbose); break; case 'check-changed': - passed = await checkChangedFiles(remote ?? 'origin', verbose, checkReactCompilerWithOxc); + passed = await checkChangedFiles(remote ?? 'origin', verbose); break; default: logError(`Unknown command: ${String(command)}`); diff --git a/scripts/react-compiler-divergence-audit.ts b/scripts/react-compiler-divergence-audit.ts deleted file mode 100644 index 1b4d8f4cd288..000000000000 --- a/scripts/react-compiler-divergence-audit.ts +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bun -/** - * React Compiler Divergence Audit - * - * Runs BOTH React Compilers (Babel via babel-plugin-react-compiler, OXC via oxc-transform-react) - * over the source tree and reports every file where the two disagree on memoization. - * - * A file is "divergent" when exactly one compiler memoizes it (`compiled` XOR not-`compiled`). - * Divergent files ship without memoization on one platform (web uses OXC, mobile/Jest use Babel), - * which is the class of bug this audit exists to surface. The output is a work list for the - * manual-memoization sweep, grouped by direction, and is reusable for verification afterwards. - * - * Usage: - * bun scripts/react-compiler-divergence-audit.ts [paths...] [--out ] - * bun scripts/react-compiler-divergence-audit.ts # audits src/ by default - */ -import CLI from 'expensify-common/CLI'; -import fs from 'fs'; -import path from 'path'; - -// The explicit .mjs extension is required for Node/bun ESM resolution of this JS module. -// eslint-disable-next-line import/extensions -import {checkBothCompilers} from '../config/reactCompiler/checkBoth.mjs'; -import FileUtils from './utils/FileUtils'; -import {bold, error as logError, info as logInfo, note as logNote, success as logSuccess} from './utils/Logger'; - -type CompilerStatus = 'compiled' | 'failed' | 'no-components'; - -type FileResult = { - file: string; - babel: CompilerStatus; - oxc: CompilerStatus; - babelMemoized: boolean; - oxcMemoized: boolean; -}; - -const FILE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx']; -const DEFAULT_INPUTS = ['src']; - -function isSkipped(file: string): boolean { - return file.includes('/tests/') || file.includes('node_modules/'); -} - -/** - * Direction of the memoization gap: which compiler memoized the file while the other did not. - * The status of the non-memoizing compiler is included for context (e.g. `failed` vs `no-components`). - */ -function directionLabel(result: FileResult): string { - if (result.babelMemoized) { - return `babel memoized / oxc did NOT (oxc=${result.oxc})`; - } - return `oxc memoized / babel did NOT (babel=${result.babel})`; -} - -function main(): void { - const cli = new CLI({ - positionalArgs: [ - { - name: 'paths', - description: 'File paths, directories, or glob patterns to audit (default: src)', - variadic: true, - default: DEFAULT_INPUTS, - }, - ], - namedArgs: { - out: { - description: 'Path to write the JSON report (default: react-compiler-divergence-report.json)', - required: false, - default: 'react-compiler-divergence-report.json', - }, - }, - flags: {}, - }); - - const inputs = cli.positionalArgs.paths; - const {out} = cli.namedArgs; - - const files = FileUtils.resolveFilePaths(inputs.length > 0 ? inputs : DEFAULT_INPUTS, FILE_EXTENSIONS).filter((file) => !isSkipped(file)); - - if (files.length === 0) { - logError('No source files found to audit.'); - process.exit(1); - } - - logInfo(`Auditing ${files.length} files with both React Compilers (Babel + OXC)...`); - - const divergent: FileResult[] = []; - let checked = 0; - - for (const absolutePath of files) { - const source = fs.readFileSync(absolutePath, 'utf8'); - const {babel, oxc, isDivergent} = checkBothCompilers(source, absolutePath); - checked += 1; - if (isDivergent) { - divergent.push({ - file: path.relative(process.cwd(), absolutePath), - babel: babel.status, - oxc: oxc.status, - babelMemoized: babel.memoized, - oxcMemoized: oxc.memoized, - }); - } - } - - // Group by direction for a readable work list. - const groups = new Map(); - for (const result of divergent) { - const label = directionLabel(result); - const bucket = groups.get(label) ?? []; - bucket.push(result); - groups.set(label, bucket); - } - - console.log(); - bold(`React Compiler divergence: ${divergent.length} of ${checked} files disagree between Babel and OXC.`); - console.log(); - - for (const [label, bucket] of [...groups.entries()].sort((a, b) => b[1].length - a[1].length)) { - logInfo(`${label} (${bucket.length})`); - for (const result of bucket.sort((a, b) => a.file.localeCompare(b.file))) { - logNote(` ${result.file}`); - } - console.log(); - } - - const report = { - generatedAt: new Date().toISOString(), - totalChecked: checked, - totalDivergent: divergent.length, - files: divergent.sort((a, b) => a.file.localeCompare(b.file)), - }; - fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`); - logSuccess(`Wrote report to ${out}`); -} - -if (require.main === module) { - main(); -} diff --git a/tests/tooling/ReactCompilerComplianceCheck.test.ts b/tests/tooling/ReactCompilerComplianceCheck.test.ts index f00e99968e50..01c79d89dc94 100644 --- a/tests/tooling/ReactCompilerComplianceCheck.test.ts +++ b/tests/tooling/ReactCompilerComplianceCheck.test.ts @@ -102,7 +102,8 @@ function BadComponent({condition}) { expect(result.memoized).toBe(false); }); - it('includes function location in error details', () => { + it('points the error at the line that breaks the rule', () => { + // Given a component that calls a hook inside a condition on line 4 const source = ` import {useState} from 'react'; function BadComponent({condition}) { @@ -112,11 +113,12 @@ function BadComponent({condition}) { return
Bad
; } `.trim(); + + // When the compliance check runs on it const result = checkReactCompilerCompliance(source, 'BadComponent.tsx'); - expect(result.errors.length).toBeGreaterThan(0); + + // Then the error names that line, so CI output leads straight to the offending hook call const error = result.errors.at(0); - expect(error).toBeDefined(); - expect(error?.fnLoc).toBeDefined(); - expect(error?.fnLoc?.start.line).toBe(2); + expect(error?.loc?.start.line).toBe(4); }); }); diff --git a/tests/tooling/lintPipeline.test.ts b/tests/tooling/lintPipeline.test.ts index 888469f7c5da..a96731877eee 100644 --- a/tests/tooling/lintPipeline.test.ts +++ b/tests/tooling/lintPipeline.test.ts @@ -120,11 +120,11 @@ describe('normalizeESLintResults', () => { }); describe('filterReactCompilerMessages', () => { - it('enforces manual memoization only when both compilers memoize the file', async () => { - // Given a diagnostic that depends on automatic memoization being available on both platforms. + it('enforces manual memoization only when the compiler memoizes the file', async () => { + // Given a diagnostic that depends on automatic memoization being available. const messages = [makeMessage({ruleID: 'rulesdir/no-manual-memoization'})]; - // When both compilers memoize the file, or one of them does not. + // When the compiler memoizes the file, or does not. const compiled = await filterReactCompilerMessages(messages, '/tmp', () => true); const skipped = await filterReactCompilerMessages(messages, '/tmp', () => false); @@ -170,7 +170,7 @@ describe('filterReactCompilerMessages', () => { expect(result).toEqual(messages); }); - it('drops suppressible messages when both compilers memoize the file', async () => { + it('drops suppressible messages when the compiler memoizes the file', async () => { const messages = [ makeMessage({ruleID: 'react/jsx-no-constructed-context-values'}), makeMessage({ruleID: 'react-hooks/exhaustive-deps', message: 'React Hook useCallback() Hook is missing a dependency'}), @@ -207,9 +207,9 @@ describe('filterReactCompilerMessages', () => { }); it('does not persist fallback compiler failures to cache', () => { - expect(shouldPersistCompilerCache({filename: 'a.tsx', bothMemoized: false, cacheable: false})).toBe(false); - expect(shouldPersistCompilerCache({filename: 'a.tsx', bothMemoized: false, cacheable: true})).toBe(true); - expect(shouldPersistCompilerCache({filename: 'a.tsx', bothMemoized: true, cacheable: true})).toBe(true); + expect(shouldPersistCompilerCache({filename: 'a.tsx', memoized: false, cacheable: false})).toBe(false); + expect(shouldPersistCompilerCache({filename: 'a.tsx', memoized: false, cacheable: true})).toBe(true); + expect(shouldPersistCompilerCache({filename: 'a.tsx', memoized: true, cacheable: true})).toBe(true); expect(shouldPersistCompilerCache(undefined)).toBe(false); }); }); diff --git a/tests/tooling/noManualMemoization.test.ts b/tests/tooling/noManualMemoization.test.ts index a739e0d60462..6c40221486c1 100644 --- a/tests/tooling/noManualMemoization.test.ts +++ b/tests/tooling/noManualMemoization.test.ts @@ -4,7 +4,7 @@ import type {Rule} from 'eslint'; import {Linter} from 'eslint'; -import {didBothCompilersMemoizeFile} from '../../config/reactCompiler/checkBoth.mjs'; +import checkReactCompilerWithOxc from '../../config/reactCompiler/checkWithOxc.mjs'; import * as rule from '../../eslint-plugin-local-rules/no-manual-memoization'; import {normalizeESLintResults} from '../../scripts/lint/eslint/ESLintLinter'; import {filterReactCompilerMessages} from '../../scripts/lint/processors/ReactCompilerFilter'; @@ -35,17 +35,17 @@ describe('no-manual-memoization', () => { shouldForbid: false, }, ])('enforces the real compiler verdict: $shouldForbid', async ({source, shouldForbid}) => { - // Given a hook that either compiles on both platforms or violates the rules for refs. + // Given a hook that either compiles or violates the rules for refs. const filename = '/repo/src/useExample.ts'; const [{messages}] = normalizeESLintResults([{filePath: filename, messages: lint(source)}]); expect(messages).toHaveLength(1); - // When the production compiler helpers determine whether manual memoization is redundant. - const bothMemoized = didBothCompilersMemoizeFile(source, filename); - const filtered = await filterReactCompilerMessages(messages, '/repo', () => bothMemoized); + // When the production compiler helper determines whether manual memoization is redundant. + const memoized = checkReactCompilerWithOxc(source, filename).memoized; + const filtered = await filterReactCompilerMessages(messages, '/repo', () => memoized); // Then the lint pipeline enforces the restriction only for automatically memoized code. - expect(bothMemoized).toBe(shouldForbid); + expect(memoized).toBe(shouldForbid); expect(filtered).toHaveLength(shouldForbid ? 1 : 0); }); diff --git a/tests/unit/CheckBothCompilersTest.ts b/tests/unit/CheckBothCompilersTest.ts deleted file mode 100644 index 9d17ba9f24d5..000000000000 --- a/tests/unit/CheckBothCompilersTest.ts +++ /dev/null @@ -1,97 +0,0 @@ -import {spawnSync} from 'child_process'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -type CompilationResult = { - status: 'compiled' | 'failed' | 'no-components'; - memoized: boolean; - errors: unknown[]; -}; - -type DualResult = { - babel: CompilationResult; - oxc: CompilationResult; - isDivergent: boolean; -}; - -const runnerPath = path.resolve(__dirname, '../../scripts/check-both-compilers-runner.mjs'); - -function isCompilationResult(value: unknown): value is CompilationResult { - if (typeof value !== 'object' || value === null || !('status' in value) || !('memoized' in value) || !('errors' in value)) { - return false; - } - const {status, memoized, errors} = value; - return (status === 'compiled' || status === 'failed' || status === 'no-components') && typeof memoized === 'boolean' && Array.isArray(errors); -} - -function isDualResult(value: unknown): value is DualResult { - if (typeof value !== 'object' || value === null || !('babel' in value) || !('oxc' in value) || !('isDivergent' in value)) { - return false; - } - const {babel, oxc, isDivergent} = value; - return isCompilationResult(babel) && isCompilationResult(oxc) && typeof isDivergent === 'boolean'; -} - -function checkBothCompilers(source: string, filename: string): DualResult { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'check-both-compilers-')); - const sourcePath = path.join(tmpDir, path.basename(filename)); - - try { - fs.writeFileSync(sourcePath, source); - const result = spawnSync(process.execPath, ['--experimental-vm-modules', runnerPath, filename, sourcePath], { - encoding: 'utf8', - }); - - if (result.status !== 0) { - throw new Error(result.stderr || result.stdout || 'check-both-compilers-runner failed'); - } - - const parsed: unknown = JSON.parse(result.stdout); - if (!isDualResult(parsed)) { - throw new Error('check-both-compilers-runner returned invalid JSON'); - } - - return parsed; - } finally { - fs.rmSync(tmpDir, {recursive: true, force: true}); - } -} - -describe('checkBothCompilers', () => { - it('reports both compilers memoizing a component and no divergence', () => { - const source = ` - function MyComponent({items}: {items: number[]}) { - const doubled = items.map((x) => x * 2); - return
{doubled.join(',')}
; - } - `; - const result = checkBothCompilers(source, 'MyComponent.tsx'); - expect(result.babel.memoized).toBe(true); - expect(result.oxc.memoized).toBe(true); - expect(result.isDivergent).toBe(false); - }); - - it('reports neither compiler memoizing a plain utility file and no divergence', () => { - const source = ` - export function add(a: number, b: number): number { - return a + b; - } - `; - const result = checkBothCompilers(source, 'mathUtils.ts'); - expect(result.babel.memoized).toBe(false); - expect(result.oxc.memoized).toBe(false); - expect(result.isDivergent).toBe(false); - }); - - it('computes divergence as the XOR of the two compilers memoizing the file', () => { - const source = ` - function MyComponent({items}: {items: number[]}) { - const doubled = items.map((x) => x * 2); - return
{doubled.join(',')}
; - } - `; - const result = checkBothCompilers(source, 'MyComponent.tsx'); - expect(result.isDivergent).toBe(result.babel.memoized !== result.oxc.memoized); - }); -});