Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/reassurePerformanceTests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ jobs:
# Skips a cold Babel pass over the whole source tree; seedJestPerfCache.yml writes the entry
# on main and explains the key. Restore-only, so nothing an untrusted PR runs can end up in an
# entry a later run executes, and no restore-keys: a prefix fallback could serve output built
# by an older babel-plugin-react-compiler and move the render counts gated at
# by an older React Compiler and move the render counts gated at
# COUNT_DEVIATION: 0. Keep the key identical to the other three.
- name: Restore Jest transform cache
id: restoreJestCache
Expand Down Expand Up @@ -187,7 +187,7 @@ jobs:
# Skips a cold Babel pass over the whole source tree; seedJestPerfCache.yml writes the entry
# on main and explains the key. Restore-only, so nothing an untrusted PR runs can end up in an
# entry a later run executes, and no restore-keys: a prefix fallback could serve output built
# by an older babel-plugin-react-compiler and move the render counts gated at
# by an older React Compiler and move the render counts gated at
# COUNT_DEVIATION: 0. Keep the key identical to the other three.
- name: Restore Jest transform cache
id: restoreJestCache
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Use the `/react-native-best-practices` skill when working on performance-sensiti

### Memoization

React Compiler auto-memoizes code in components and hooks (excluding `tests/`). Two different compilers run it: `babel-plugin-react-compiler` on native (see `babel.config.js`) and `oxc-transform-react` on web (see `config/rsbuild/`) and under Jest (see `config/babel/oxcJestTransformer.js`). They do not behave identically. The compliance check and the ESLint processor run BOTH compilers via the shared helpers in `config/reactCompiler/` and only relax manual-memoization rules when both compilers memoize the file.
React Compiler auto-memoizes code in components and hooks (excluding `tests/`). `oxc-transform-react` runs it on web (see `config/rsbuild/`), on native (see `config/repack/`) and under Jest (see `config/babel/oxcJestTransformer.js`), all with the options in `config/babel/oxcReactCompilerConfig.js`. The compliance check and the ESLint processor use the same compiler via `config/reactCompiler/checkWithOxc.mjs`, and the ESLint processor only relaxes manual-memoization rules when it memoizes the file.

Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that compile with React Compiler.

Expand All @@ -71,7 +71,7 @@ Do not use `useMemo`, `useCallback`, or `React.memo` in components or hooks that

1. **ESLint**: Run `npm run lint-changed` to catch lint errors early.
2. **TypeScript**: Run `npm run typecheck` after changes that may affect typing (types, interfaces, or function signatures). It runs the TypeScript 7 native compiler and is the required merge gate in CI.
3. **React Compiler**: If you added new React components/hooks or modified existing ones, run `npm run react-compiler-compliance-check check-changed` to verify they compile with React Compiler. This applies the same rules as CI, evaluated against BOTH the Babel and OXC compilers: new components/hooks must compile, existing compiled files must not regress, and changes must not introduce new memoization divergence (one compiler memoizing a file while the other does not). See `contributingGuides/REACT_COMPILER.md` for details and common fixes.
3. **React Compiler**: If you added new React components/hooks or modified existing ones, run `npm run react-compiler-compliance-check check-changed` to verify they compile with React Compiler. This applies the same rules as CI: new components/hooks must compile, and existing compiled files must not regress. See `contributingGuides/REACT_COMPILER.md` for details and common fixes.
4. **Spelling**: Run `npm run spell-changed` to catch spelling errors (it discovers changed files itself; pass an explicit file list only if you want to check specific files instead). CI validates with cspell, which remains the required merge gate.

### Testing
Expand Down
15 changes: 6 additions & 9 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -189,5 +181,10 @@ module.exports = (api) => {
return repack;
}

return ['metro', 'babel-jest'].includes(runningIn) ? metro : {};
// React Compiler now runs only in OXC (Re.Pack and Jest), so a Metro bundle would ship without it.
if (runningIn === 'metro') {
throw new Error('Metro bundles no longer run React Compiler. Build with Re.Pack instead (unset BUNDLER=metro).');
}

return runningIn === 'babel-jest' ? metro : {};
};
71 changes: 57 additions & 14 deletions config/babel/oxcJestTransformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -91,19 +102,13 @@ function getLang(filename) {
return 'jsx';
}

/**
* Coverage runs stay on babel-jest: it adds coverage in the pass it already makes, while the OXC path
* would need a third pass (OXC, esbuild, then istanbul). On CI that was slower and used more memory.
*/
function shouldUseOxc(filename, transformOptions) {
if (transformOptions?.instrument) {
return false;
}
function shouldUseOxc(filename) {
return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename) && !TEST_FILE_RE.test(filename);
}

function processWithOxc(sourceText, sourcePath) {
const {esbuild, transformSync} = getOxcPipeline();
/** Compile with OXC (TS, JSX and React Compiler). Returns null on a fatal error so the caller falls back to babel-jest. */
function compileWithOxc(sourceText, sourcePath) {
const {transformSync} = getOxcPipeline();
const oxcResult = transformSync(sourcePath, sourceText, {
lang: getLang(sourcePath),
sourcemap: true,
Expand All @@ -114,7 +119,44 @@ function processWithOxc(sourceText, sourcePath) {
if (oxcResult.fatal || !oxcResult.code) {
return null;
}
return oxcResult;
}

/**
* Coverage runs: OXC compiles as usual, then one light Babel pass converts to CommonJS and adds the
* istanbul counters, reading OXC's source map so coverage points at the original lines. That replaces
* esbuild rather than adding a third pass after it.
*/
function processWithOxcForCoverage(sourceText, sourcePath, transformOptions) {
const oxcResult = compileWithOxc(sourceText, sourcePath);
if (!oxcResult) {
return null;
}

const result = require('@babel/core').transformSync(oxcResult.code, {
filename: sourcePath,
configFile: false,
babelrc: false,
inputSourceMap: oxcResult.map,
sourceMaps: true,
auxiliaryCommentBefore: ' istanbul ignore next ',
plugins: [
[require.resolve('babel-plugin-istanbul'), {cwd: transformOptions.config.cwd, exclude: []}],
[require.resolve('@babel/plugin-transform-modules-commonjs'), {strictMode: false}],
require.resolve('@babel/plugin-transform-dynamic-import'),
],
});

return result?.code ? {code: result.code, map: result.map} : null;
}

function processWithOxc(sourceText, sourcePath) {
const oxcResult = compileWithOxc(sourceText, sourcePath);
if (!oxcResult) {
return null;
}

const {esbuild} = getOxcPipeline();
// esbuild chains an inline input map, so its output maps straight back to the original file.
const inputMap = Buffer.from(JSON.stringify(oxcResult.map)).toString('base64');
const cjs = esbuild.transformSync(`${oxcResult.code}\n//# sourceMappingURL=data:application/json;base64,${inputMap}`, {
Expand All @@ -129,10 +171,10 @@ function processWithOxc(sourceText, sourcePath) {
}

module.exports = {
// Coverage runs go to babel-jest, which instruments them itself, so Jest must not instrument again.
// Coverage is instrumented here (istanbul plugin in the OXC coverage pass), so Jest must not instrument again.
canInstrument: true,
getCacheKey(sourceText, sourcePath, transformOptions) {
if (!shouldUseOxc(sourcePath, transformOptions)) {
if (!shouldUseOxc(sourcePath)) {
return babelTransformer.getCacheKey(sourceText, sourcePath, transformOptions);
}

Expand All @@ -147,11 +189,12 @@ module.exports = {
.update(REACT_COMPILER_CONFIG_KEY)
.update(esbuild.version)
.update(oxcVersion)
.update(transformOptions?.instrument ? `instrument:${getCoverageVersionsKey()}` : '')
.digest('hex');
},
process(sourceText, sourcePath, transformOptions) {
if (shouldUseOxc(sourcePath, transformOptions)) {
const result = processWithOxc(sourceText, sourcePath);
if (shouldUseOxc(sourcePath)) {
const result = transformOptions?.instrument ? processWithOxcForCoverage(sourceText, sourcePath, transformOptions) : processWithOxc(sourceText, sourcePath);
if (result) {
return result;
}
Expand Down
11 changes: 6 additions & 5 deletions config/babel/oxcReactCompilerConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +17,10 @@ const BaseReactCompilerConfig = require('./reactCompilerConfig');
*/
function oxcReactCompilerConfig(overrides = {}) {
return {
...BaseReactCompilerConfig,
target: '19',
environment: {
enableTreatRefLikeIdentifiersAsRefs: true,
},
eslintSuppressionRules: [],
panicThreshold: 'none',
...overrides,
Expand Down
17 changes: 0 additions & 17 deletions config/babel/reactCompilerConfig.js

This file was deleted.

2 changes: 1 addition & 1 deletion config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ const config = defineConfig([
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
rules: {
// The lint pipeline keeps this diagnostic only when both compilers memoize the file.
// The lint pipeline keeps this diagnostic only when React Compiler memoizes the file.
'rulesdir/no-manual-memoization': 'error',
'rulesdir/prefer-locale-compare-from-context': 'error',
'rulesdir/no-object-keys-includes': 'error',
Expand Down
13 changes: 0 additions & 13 deletions config/reactCompiler/checkBoth.d.mts

This file was deleted.

47 changes: 0 additions & 47 deletions config/reactCompiler/checkBoth.mjs

This file was deleted.

22 changes: 0 additions & 22 deletions config/reactCompiler/checkWithBabel.d.mts

This file was deleted.

Loading
Loading