From 867bd54147d6d80df627cb45b92a329c9472c426 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Wed, 30 Sep 2026 13:00:10 +0200 Subject: [PATCH 1/7] Consolidate on the OXC React Compiler and delete the Babel one Every build lane now compiles app source with OXC: web through Rsbuild, native through Re.Pack (HybridApp release builds too, since Mobile-Expensify #14144) and Jest through oxcJestTransformer. The Babel React Compiler only remained on the BUNDLER=metro debug fallback, plus a layer of tooling that ran both compilers and compared them. - Drop babel-plugin-react-compiler from babel.config.js, package.json and knip.json, and inline reactCompilerConfig.js into oxcReactCompilerConfig.js. - Delete checkBoth, checkWithBabel, check-both-compilers-runner, the divergence audit script and their tests. - Reduce the compliance check to OXC: new files must compile and modified files must not regress. The divergence rule and the inline Babel analysis are gone. - Point the lint worker at checkWithOxc and trim the fingerprint files. - Update CLAUDE.md, REACT_COMPILER.md and the compiler coding rule. BUNDLER=metro builds now run without React Compiler memoization. Metro is a debug escape hatch, not a shipped path. --- .../rules/clean-react-0-compiler.md | 6 +- .../workflows/reassurePerformanceTests.yml | 4 +- CLAUDE.md | 4 +- babel.config.js | 8 - config/babel/oxcReactCompilerConfig.js | 11 +- config/babel/reactCompilerConfig.js | 17 - config/reactCompiler/checkBoth.d.mts | 13 - config/reactCompiler/checkBoth.mjs | 47 --- config/reactCompiler/checkWithBabel.d.mts | 22 -- config/reactCompiler/checkWithBabel.mjs | 98 ------ config/reactCompiler/checkWithOxc.d.mts | 2 +- config/reactCompiler/checkWithOxc.mjs | 4 +- config/repack/rspack.config.mjs | 1 - .../loaders/oxc-react-compiler-loader.mjs | 1 - contributingGuides/REACT_COMPILER.md | 29 +- knip.json | 1 - package-lock.json | 7 +- package.json | 3 - scripts/check-both-compilers-runner.mjs | 14 - .../lint/processors/ReactCompilerFilter.ts | 40 ++- .../lint/processors/ReactCompilerWorker.ts | 8 +- scripts/react-compiler-compliance-check.ts | 292 ++++-------------- scripts/react-compiler-divergence-audit.ts | 138 --------- .../ReactCompilerComplianceCheck.test.ts | 12 +- tests/tooling/lintPipeline.test.ts | 8 +- tests/unit/CheckBothCompilersTest.ts | 97 ------ 26 files changed, 116 insertions(+), 771 deletions(-) delete mode 100644 config/babel/reactCompilerConfig.js delete mode 100644 config/reactCompiler/checkBoth.d.mts delete mode 100644 config/reactCompiler/checkBoth.mjs delete mode 100644 config/reactCompiler/checkWithBabel.d.mts delete mode 100644 config/reactCompiler/checkWithBabel.mjs delete mode 100644 scripts/check-both-compilers-runner.mjs delete mode 100644 scripts/react-compiler-divergence-audit.ts delete mode 100644 tests/unit/CheckBothCompilersTest.ts diff --git a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md index e43d5c7fb94a..cf5256085ff9 100644 --- a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md +++ b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md @@ -7,7 +7,7 @@ title: React Compiler compliance ### Reasoning -React Compiler is enabled in this codebase (`babel-plugin-react-compiler` runs first in both Rspack and metro configs). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. +React Compiler is enabled in this codebase (`oxc-transform-react` compiles app source in the web, native and Jest pipelines). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. Manual memoization is therefore: @@ -16,8 +16,8 @@ Manual memoization is therefore: 3. **Noisy** — it clutters the codebase with dependency arrays that must be maintained, reviewed, and debugged The codebase enforces this via: -- **Babel plugin**: `babel-plugin-react-compiler` in `babel.config.js` -- **Lint post-processor**: `scripts/lint/processors/ReactCompilerFilter.ts` suppresses redundant lint rules when both React Compilers memoize the file +- **Compiler**: `oxc-transform-react`, configured in `config/babel/oxcReactCompilerConfig.js` +- **Lint post-processor**: `scripts/lint/processors/ReactCompilerFilter.ts` suppresses redundant lint rules when the React Compiler memoizes the file - **CI compliance check**: `scripts/react-compiler-compliance-check.ts` enforces that new components/hooks compile and that existing compiled files don't regress Reference: [React Compiler documentation](https://react.dev/learn/react-compiler) diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index 3eeb13a31bd5..99441cb62cd7 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -82,7 +82,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 @@ -137,7 +137,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 b83dc7430e44..50b2596eb02b 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/`). One compiler, `oxc-transform-react`, runs it everywhere: 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. @@ -70,7 +70,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..dbdec32eca55 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', 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/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/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index cf721fb6804c..d39abad69dd4 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -4,47 +4,34 @@ [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`. ## 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/knip.json b/knip.json index 72c09c1b8500..feb8ebc5d1ba 100644 --- a/knip.json +++ b/knip.json @@ -79,7 +79,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 56c7ab2583f1..da3b5bb72ebd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -235,7 +235,6 @@ "@welldone-software/why-did-you-render": "7.0.1", "babel-jest": "29.7.0", "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.3.14", @@ -22492,9 +22491,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 abbd8791a2c6..1bde3663a700 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,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\"", @@ -311,7 +310,6 @@ "@welldone-software/why-did-you-render": "7.0.1", "babel-jest": "29.7.0", "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.3.14", @@ -401,7 +399,6 @@ "path-to-regexp": "0.1.10", "send": "0.19.0", "regexpu-core": "6.4.0", - "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/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 334a9ce92a4e..f2fee0088a14 100644 --- a/scripts/lint/processors/ReactCompilerFilter.ts +++ b/scripts/lint/processors/ReactCompilerFilter.ts @@ -10,11 +10,7 @@ const RULES_SUPPRESSED_BY_REACT_COMPILER = new Set(['react/jsx-no-constructed-co 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', @@ -74,8 +70,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 = { @@ -85,29 +81,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)); @@ -126,8 +122,8 @@ async function checkCandidatesWithPool(candidates: Candidate[], checkBoth: Compi } /** - * Drop suppressible React-Compiler-redundant messages, but only after both - * compilers memoize the file. Files with no suppressible message are skipped + * Drop suppressible React-Compiler-redundant messages, but only after the + * compiler memoizes the file. Files with no suppressible message are skipped * entirely (~60× fewer compiler invocations than the ESLint processor). * * Cache is one file per compiler fingerprint and content hash so concurrent @@ -137,21 +133,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(); @@ -177,12 +173,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 { @@ -203,16 +199,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 c8785733fc22..1c6c770fe530 100644 --- a/tests/tooling/lintPipeline.test.ts +++ b/tests/tooling/lintPipeline.test.ts @@ -131,7 +131,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'}), @@ -168,9 +168,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/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); - }); -}); From c2312c6bb7895f1d231bb438a6d8f5c720a58a43 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Wed, 30 Sep 2026 16:47:15 +0200 Subject: [PATCH 2/7] Keep the Babel React Compiler for Jest coverage runs CI runs Jest with --coverage, and oxcJestTransformer sends app source to babel-jest for those runs so istanbul can instrument in the same pass. That lane still compiled with babel-plugin-react-compiler through babel.config.js. Removing the plugin left CI tests unmemoized, and several UI suites looped until they timed out or ran out of memory. babel.config.js now applies the plugin only for babel-jest, with the options from oxcReactCompilerConfig so both compilers compile the same way. Metro and Re.Pack stay without it. The dependency and its override are restored. --- .../rules/clean-react-0-compiler.md | 2 +- CLAUDE.md | 2 +- babel.config.js | 17 ++++++++++++++++- config/babel/oxcReactCompilerConfig.js | 3 ++- contributingGuides/REACT_COMPILER.md | 2 +- knip.json | 1 + package-lock.json | 7 ++++--- package.json | 2 ++ 8 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md index cf5256085ff9..7225f510f695 100644 --- a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md +++ b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md @@ -7,7 +7,7 @@ title: React Compiler compliance ### Reasoning -React Compiler is enabled in this codebase (`oxc-transform-react` compiles app source in the web, native and Jest pipelines). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. +React Compiler is enabled in this codebase (`oxc-transform-react` compiles app source in the web, native and Jest pipelines; Jest coverage runs use `babel-plugin-react-compiler` with the same options). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. Manual memoization is therefore: diff --git a/CLAUDE.md b/CLAUDE.md index 50b2596eb02b..b46b101f28ce 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/`). One compiler, `oxc-transform-react`, runs it everywhere: 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. +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 one exception is Jest with `--coverage` (what CI runs): it goes through `babel-jest`, so `babel.config.js` still applies `babel-plugin-react-compiler` there, with the same options. 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. diff --git a/babel.config.js b/babel.config.js index dbdec32eca55..49863bab7381 100644 --- a/babel.config.js +++ b/babel.config.js @@ -6,6 +6,7 @@ require('dotenv').config(); process.env.EXPO_PUBLIC_USE_RN_FETCH = process.env.EXPO_PUBLIC_USE_RN_FETCH ?? '1'; const {expoInlineEnvVars} = require('babel-preset-expo/build/plugins/inline-env-vars'); +const oxcReactCompilerConfig = require('./config/babel/oxcReactCompilerConfig'); /** * Custom plugin that prints a file name when it's being processed by babel. @@ -152,6 +153,16 @@ if (process.env.CAPTURE_METRICS === 'true') { ]); } +// Jest coverage runs send app source through babel-jest instead of OXC (see oxcJestTransformer.js), so this +// lane still needs the Babel React Compiler. It shares OXC's options so both compile the same way. +const jest = { + ...metro, + plugins: [ + ['babel-plugin-react-compiler', oxcReactCompilerConfig({sources: (filename) => !filename.includes('tests/') && !filename.includes('node_modules/')})], // must run first! + ...metro.plugins, + ], +}; + const repack = { ...metro, plugins: metro.plugins @@ -181,5 +192,9 @@ module.exports = (api) => { return repack; } - return ['metro', 'babel-jest'].includes(runningIn) ? metro : {}; + if (runningIn === 'babel-jest') { + return jest; + } + + return runningIn === 'metro' ? metro : {}; }; diff --git a/config/babel/oxcReactCompilerConfig.js b/config/babel/oxcReactCompilerConfig.js index 1f1978309f1b..3cd96eddaf5e 100644 --- a/config/babel/oxcReactCompilerConfig.js +++ b/config/babel/oxcReactCompilerConfig.js @@ -1,6 +1,7 @@ /** * The React Compiler options OXC compiles with, in one place: Re.Pack native, Rsbuild web, the Jest - * transform and the react-compiler checker. Each used to spell out its own. The native build ended up + * transform and the react-compiler checker. babel.config.js passes the same object to the Babel compiler + * for Jest coverage runs. Each used to spell out its own. The native build ended up * 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. */ diff --git a/contributingGuides/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index d39abad69dd4..e01b9474255d 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -4,7 +4,7 @@ [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 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`. +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`. The one exception is Jest with `--coverage`, which CI uses: it compiles through `babel-jest`, so `babel.config.js` applies `babel-plugin-react-compiler` with the same options. ## React Compiler CI check diff --git a/knip.json b/knip.json index feb8ebc5d1ba..72c09c1b8500 100644 --- a/knip.json +++ b/knip.json @@ -79,6 +79,7 @@ "@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 da3b5bb72ebd..56c7ab2583f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -235,6 +235,7 @@ "@welldone-software/why-did-you-render": "7.0.1", "babel-jest": "29.7.0", "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.3.14", @@ -22491,9 +22492,9 @@ } }, "node_modules/babel-plugin-react-compiler": { - "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==", + "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==", "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" diff --git a/package.json b/package.json index 1bde3663a700..9ce3005a1256 100644 --- a/package.json +++ b/package.json @@ -310,6 +310,7 @@ "@welldone-software/why-did-you-render": "7.0.1", "babel-jest": "29.7.0", "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.3.14", @@ -399,6 +400,7 @@ "path-to-regexp": "0.1.10", "send": "0.19.0", "regexpu-core": "6.4.0", + "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "react": "19.2.3", "react-dom": "19.2.3", "eslint-config-expensify": { From 0670f0ea33ac1f3aff0f7443c4e8aba021d5c593 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Mon, 5 Oct 2026 14:44:48 +0200 Subject: [PATCH 3/7] Run Jest coverage through the OXC lane instead of babel-jest Coverage runs go OXC -> one light Babel pass (istanbul + CJS conversion) using OXC's source map, replacing the babel-jest coverage lane. Timed ~par in an A/B (Oct-5); the report shape shifts (type-only modules drop out, branch totals move), so the Codecov baseline needs team sign-off before merging. --- config/babel/oxcJestTransformer.js | 49 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/config/babel/oxcJestTransformer.js b/config/babel/oxcJestTransformer.js index b36f35e90b63..110a0cd20eab 100644 --- a/config/babel/oxcJestTransformer.js +++ b/config/babel/oxcJestTransformer.js @@ -91,15 +91,43 @@ function getLang(filename) { return 'jsx'; } +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); +} + /** - * 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. + * 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 shouldUseOxc(filename, transformOptions) { - if (transformOptions?.instrument) { - return false; +function processWithOxcForCoverage(sourceText, sourcePath, transformOptions) { + const {transformSync} = getOxcPipeline(); + const oxcResult = transformSync(sourcePath, sourceText, { + lang: getLang(sourcePath), + sourcemap: true, + jsx: {runtime: 'automatic', development: true}, + reactCompiler: REACT_COMPILER_OPTIONS, + }); + + if (oxcResult.fatal || !oxcResult.code) { + return null; } - return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename) && !TEST_FILE_RE.test(filename); + + 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) { @@ -129,10 +157,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 +175,12 @@ module.exports = { .update(REACT_COMPILER_CONFIG_KEY) .update(esbuild.version) .update(oxcVersion) + .update(transformOptions?.instrument ? 'instrument' : '') .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; } From 363289002c21babffd23d92042dc510ae0582989 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Tue, 13 Oct 2026 18:51:07 +0200 Subject: [PATCH 4/7] List the transformer's direct deps so knip sees them oxcJestTransformer imports babel-plugin-istanbul and the two Babel transform plugins directly now, but they were only transitive deps. CI's knip comparison flagged all three as unlisted. --- package-lock.json | 11 +++++++++-- package.json | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56c7ab2583f1..20879417c6aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -164,7 +164,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", "@babel/traverse": "^7.22.20", "@callstack/reassure-compare": "^1.0.0-rc.4", "@callstack/repack": "5.3.0", @@ -234,6 +236,7 @@ "@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", @@ -2738,11 +2741,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" @@ -22372,6 +22377,8 @@ }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index 9ce3005a1256..a3a7fed9699f 100644 --- a/package.json +++ b/package.json @@ -239,7 +239,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", "@babel/traverse": "^7.22.20", "@callstack/reassure-compare": "^1.0.0-rc.4", "@callstack/repack": "5.3.0", @@ -309,6 +311,7 @@ "@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", From 4651a67260bbb74a56d77f3dd454152042a8a2b8 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Wed, 21 Oct 2026 16:09:43 +0200 Subject: [PATCH 5/7] Restore compiled GH action builds to main --- .../isDeployChecklistLocked/index.js | 20 ++++++++++--------- .../javascript/proposalPoliceComment/index.js | 20 ++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index 3b74b05e821e..cfe1ac778241 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -21104,7 +21104,7 @@ var require_lib = __commonJS({ var require_awesome_phonenumber = __commonJS({ "node_modules/awesome-phonenumber/index.js"(exports, module) { var exportedName = "PhoneNumber$$module$src$index"; - module.exports = require_lib()[exportedName] || (typeof globalThis !== "undefined" && globalThis || typeof global !== "undefined" && global || typeof window !== "undefined" && window || typeof self !== "undefined" && self || exports)[exportedName]; + var lib = require_lib()[exportedName] || (typeof globalThis !== "undefined" && globalThis || typeof global !== "undefined" && global || typeof window !== "undefined" && window || typeof self !== "undefined" && self || exports)[exportedName]; Object.defineProperty( module.exports, "__esModule", @@ -21113,8 +21113,9 @@ var require_awesome_phonenumber = __commonJS({ } ); module.exports.parsePhoneNumber = (...args) => { + "worklet"; try { - const ret = module.exports(...args).toJSON(); + const ret = lib(...args).toJSON(); if (!ret.valid && !ret.possible) { ret.possible = false; if (!ret.possibility) @@ -21130,13 +21131,13 @@ var require_awesome_phonenumber = __commonJS({ }; } }; - module.exports.getCountryCodeForRegionCode = module.exports.getCountryCodeForRegionCode; - module.exports.getRegionCodeForCountryCode = module.exports.getRegionCodeForCountryCode; - module.exports.getSupportedCallingCodes = module.exports.getSupportedCallingCodes; - module.exports.getSupportedRegionCodes = module.exports.getSupportedRegionCodes; - module.exports.getExample = module.exports.getExample; - module.exports.getAsYouType = module.exports.getAsYouType; - module.exports.getNumberFrom = module.exports.getNumberFrom; + module.exports.getCountryCodeForRegionCode = lib.getCountryCodeForRegionCode; + module.exports.getRegionCodeForCountryCode = lib.getRegionCodeForCountryCode; + module.exports.getSupportedCallingCodes = lib.getSupportedCallingCodes; + module.exports.getSupportedRegionCodes = lib.getSupportedRegionCodes; + module.exports.getExample = lib.getExample; + module.exports.getAsYouType = lib.getAsYouType; + module.exports.getNumberFrom = lib.getNumberFrom; } }); @@ -21419,6 +21420,7 @@ var require_surrogate_pairs = __commonJS({ // node_modules/html-entities/lib/index.js var require_lib2 = __commonJS({ "node_modules/html-entities/lib/index.js"(exports) { + "worklet"; "use strict"; var __assign = exports && exports.__assign || function() { __assign = Object.assign || function(t) { diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 411ce2c19bbd..58a12c5fb27a 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -42363,7 +42363,7 @@ var require_lib2 = __commonJS({ var require_awesome_phonenumber = __commonJS({ "node_modules/awesome-phonenumber/index.js"(exports, module) { var exportedName = "PhoneNumber$$module$src$index"; - module.exports = require_lib2()[exportedName] || (typeof globalThis !== "undefined" && globalThis || typeof global !== "undefined" && global || typeof window !== "undefined" && window || typeof self !== "undefined" && self || exports)[exportedName]; + var lib = require_lib2()[exportedName] || (typeof globalThis !== "undefined" && globalThis || typeof global !== "undefined" && global || typeof window !== "undefined" && window || typeof self !== "undefined" && self || exports)[exportedName]; Object.defineProperty( module.exports, "__esModule", @@ -42372,8 +42372,9 @@ var require_awesome_phonenumber = __commonJS({ } ); module.exports.parsePhoneNumber = (...args) => { + "worklet"; try { - const ret = module.exports(...args).toJSON(); + const ret = lib(...args).toJSON(); if (!ret.valid && !ret.possible) { ret.possible = false; if (!ret.possibility) @@ -42389,13 +42390,13 @@ var require_awesome_phonenumber = __commonJS({ }; } }; - module.exports.getCountryCodeForRegionCode = module.exports.getCountryCodeForRegionCode; - module.exports.getRegionCodeForCountryCode = module.exports.getRegionCodeForCountryCode; - module.exports.getSupportedCallingCodes = module.exports.getSupportedCallingCodes; - module.exports.getSupportedRegionCodes = module.exports.getSupportedRegionCodes; - module.exports.getExample = module.exports.getExample; - module.exports.getAsYouType = module.exports.getAsYouType; - module.exports.getNumberFrom = module.exports.getNumberFrom; + module.exports.getCountryCodeForRegionCode = lib.getCountryCodeForRegionCode; + module.exports.getRegionCodeForCountryCode = lib.getRegionCodeForCountryCode; + module.exports.getSupportedCallingCodes = lib.getSupportedCallingCodes; + module.exports.getSupportedRegionCodes = lib.getSupportedRegionCodes; + module.exports.getExample = lib.getExample; + module.exports.getAsYouType = lib.getAsYouType; + module.exports.getNumberFrom = lib.getNumberFrom; } }); @@ -42678,6 +42679,7 @@ var require_surrogate_pairs = __commonJS({ // node_modules/html-entities/lib/index.js var require_lib3 = __commonJS({ "node_modules/html-entities/lib/index.js"(exports) { + "worklet"; "use strict"; var __assign = exports && exports.__assign || function() { __assign = Object.assign || function(t) { From c614719f70da05e865cf4acf5a3c3a57af40a7e9 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Thu, 22 Oct 2026 14:48:51 +0200 Subject: [PATCH 6/7] Drop the Babel React Compiler now that coverage runs use OXC Coverage runs compile app source with OXC and only use Babel for istanbul and the CommonJS conversion, with babel.config.js turned off. The jest block's babel-plugin-react-compiler only reached mocks, jest setup files and the OXC fallback, none of which need memoization. Remove it along with the dependency, its override and the knip ignore, and drop the coverage exception from the docs. babel-plugin-react-compiler stays in the lockfile as a dependency of babel-preset-expo, which we only use for inline-env-vars. --- .../rules/clean-react-0-compiler.md | 2 +- CLAUDE.md | 2 +- babel.config.js | 17 +---------------- config/babel/oxcReactCompilerConfig.js | 3 +-- contributingGuides/REACT_COMPILER.md | 2 +- knip.json | 1 - package-lock.json | 7 +++---- package.json | 2 -- 8 files changed, 8 insertions(+), 28 deletions(-) diff --git a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md index 7225f510f695..ffc779278dc8 100644 --- a/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md +++ b/.claude/skills/app-coding-standards/rules/clean-react-0-compiler.md @@ -7,7 +7,7 @@ title: React Compiler compliance ### Reasoning -React Compiler is enabled in this codebase (`oxc-transform-react` compiles app source in the web, native and Jest pipelines; Jest coverage runs use `babel-plugin-react-compiler` with the same options). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. +React Compiler is enabled in this codebase (`oxc-transform-react` compiles app source in the web, native and Jest pipelines, coverage runs included). It automatically memoizes components and hooks at the AST level — analyzing data flow, tracking dependencies, and inserting fine-grained caching that is more precise than any hand-written `useMemo`, `useCallback`, or `React.memo`. Manual memoization is therefore: diff --git a/CLAUDE.md b/CLAUDE.md index e24ccc00e379..d534253123c6 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/`). `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 one exception is Jest with `--coverage` (what CI runs): it goes through `babel-jest`, so `babel.config.js` still applies `babel-plugin-react-compiler` there, with the same options. 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. +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. diff --git a/babel.config.js b/babel.config.js index 49863bab7381..2b25e0028c1e 100644 --- a/babel.config.js +++ b/babel.config.js @@ -6,7 +6,6 @@ require('dotenv').config(); process.env.EXPO_PUBLIC_USE_RN_FETCH = process.env.EXPO_PUBLIC_USE_RN_FETCH ?? '1'; const {expoInlineEnvVars} = require('babel-preset-expo/build/plugins/inline-env-vars'); -const oxcReactCompilerConfig = require('./config/babel/oxcReactCompilerConfig'); /** * Custom plugin that prints a file name when it's being processed by babel. @@ -153,16 +152,6 @@ if (process.env.CAPTURE_METRICS === 'true') { ]); } -// Jest coverage runs send app source through babel-jest instead of OXC (see oxcJestTransformer.js), so this -// lane still needs the Babel React Compiler. It shares OXC's options so both compile the same way. -const jest = { - ...metro, - plugins: [ - ['babel-plugin-react-compiler', oxcReactCompilerConfig({sources: (filename) => !filename.includes('tests/') && !filename.includes('node_modules/')})], // must run first! - ...metro.plugins, - ], -}; - const repack = { ...metro, plugins: metro.plugins @@ -192,9 +181,5 @@ module.exports = (api) => { return repack; } - if (runningIn === 'babel-jest') { - return jest; - } - - return runningIn === 'metro' ? metro : {}; + return runningIn === 'babel-jest' || runningIn === 'metro' ? metro : {}; }; diff --git a/config/babel/oxcReactCompilerConfig.js b/config/babel/oxcReactCompilerConfig.js index 3cd96eddaf5e..1f1978309f1b 100644 --- a/config/babel/oxcReactCompilerConfig.js +++ b/config/babel/oxcReactCompilerConfig.js @@ -1,7 +1,6 @@ /** * The React Compiler options OXC compiles with, in one place: Re.Pack native, Rsbuild web, the Jest - * transform and the react-compiler checker. babel.config.js passes the same object to the Babel compiler - * for Jest coverage runs. Each used to spell out its own. The native build ended up + * transform and the react-compiler checker. Each used to spell out its own. The native build ended up * 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. */ diff --git a/contributingGuides/REACT_COMPILER.md b/contributingGuides/REACT_COMPILER.md index e01b9474255d..d39abad69dd4 100644 --- a/contributingGuides/REACT_COMPILER.md +++ b/contributingGuides/REACT_COMPILER.md @@ -4,7 +4,7 @@ [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 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`. The one exception is Jest with `--coverage`, which CI uses: it compiles through `babel-jest`, so `babel.config.js` applies `babel-plugin-react-compiler` with the same options. +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`. ## React Compiler CI check diff --git a/knip.json b/knip.json index 730d221fe72e..ea820ab8f87c 100644 --- a/knip.json +++ b/knip.json @@ -77,7 +77,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 1be45d45b1be..c23df12975de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -238,7 +238,6 @@ "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.3.14", @@ -22065,9 +22064,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 65f66ab714d9..eb7b2471077a 100644 --- a/package.json +++ b/package.json @@ -314,7 +314,6 @@ "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.3.14", @@ -408,7 +407,6 @@ "adm-zip": "0.6.1", "tinypool": "2.1.2", "@simple-git/argv-parser": "2.0.1", - "babel-plugin-react-compiler": "0.0.0-experimental-a1856f3-20260507", "react": "19.2.3", "react-dom": "19.2.3", "eslint-config-expensify": { From e0c996a15b23e939ed7e8e11dcbb528bd9c6a246 Mon Sep 17 00:00:00 2001 From: eliran goshen Date: Fri, 23 Oct 2026 11:36:43 +0200 Subject: [PATCH 7/7] Refuse Metro builds, hash coverage deps in cache key, share OXC compile step --- babel.config.js | 7 ++++- config/babel/oxcJestTransformer.js | 46 +++++++++++++++++++----------- rock.config.mjs | 5 ++-- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/babel.config.js b/babel.config.js index 2b25e0028c1e..0def4f929370 100644 --- a/babel.config.js +++ b/babel.config.js @@ -181,5 +181,10 @@ module.exports = (api) => { return repack; } - return runningIn === 'babel-jest' || runningIn === 'metro' ? 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 110a0cd20eab..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') { @@ -95,12 +106,8 @@ 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); } -/** - * 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) { +/** 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), @@ -112,6 +119,19 @@ function processWithOxcForCoverage(sourceText, sourcePath, transformOptions) { 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, @@ -131,18 +151,12 @@ function processWithOxcForCoverage(sourceText, sourcePath, transformOptions) { } function processWithOxc(sourceText, sourcePath) { - const {esbuild, transformSync} = getOxcPipeline(); - const oxcResult = transformSync(sourcePath, sourceText, { - lang: getLang(sourcePath), - sourcemap: true, - jsx: {runtime: 'automatic', development: true}, - reactCompiler: REACT_COMPILER_OPTIONS, - }); - - if (oxcResult.fatal || !oxcResult.code) { + 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}`, { @@ -175,7 +189,7 @@ module.exports = { .update(REACT_COMPILER_CONFIG_KEY) .update(esbuild.version) .update(oxcVersion) - .update(transformOptions?.instrument ? 'instrument' : '') + .update(transformOptions?.instrument ? `instrument:${getCoverageVersionsKey()}` : '') .digest('hex'); }, process(sourceText, sourcePath, transformOptions) { 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;