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
93 changes: 93 additions & 0 deletions config/babel/oxcJestTransformer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const esbuild = require('esbuild');
const {transformSync} = require('oxc-transform-react');

const babelJest = require('babel-jest');
const OXC_TRANSFORM_REACT_VERSION = require('oxc-transform-react/package.json').version;
const BaseReactCompilerConfig = require('./reactCompilerConfig');

const babelTransformer = babelJest.createTransformer();

const NODE_MODULES_RE = /[/\\]node_modules[/\\]/;
const TESTS_RE = /[/\\]tests[/\\]/;
const JEST_SETUP_RE = /[/\\]jest[/\\]/;
const MOCKS_RE = /[/\\]__mocks__[/\\]/;

const TRANSFORMER_SOURCE = fs.readFileSync(__filename);
const REACT_COMPILER_CONFIG_KEY = JSON.stringify(BaseReactCompilerConfig);

const REACT_COMPILER_OPTIONS = {
...BaseReactCompilerConfig,
panicThreshold: 'none',
eslintSuppressionRules: [],
};

function getLang(filename) {
const ext = path.extname(filename).slice(1);
if (ext === 'tsx') {
return 'tsx';
}
if (ext === 'ts') {
return 'ts';
}
return 'jsx';
}

function shouldUseOxc(filename) {
return !NODE_MODULES_RE.test(filename) && !TESTS_RE.test(filename) && !JEST_SETUP_RE.test(filename) && !MOCKS_RE.test(filename);
}

function processWithOxc(sourceText, sourcePath) {
const oxcResult = transformSync(sourcePath, sourceText, {
lang: getLang(sourcePath),
sourcemap: true,
jsx: {runtime: 'automatic', development: true},
reactCompiler: REACT_COMPILER_OPTIONS,
Comment on lines +43 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve production transforms in render benchmarks

For every measureRenders suite, this sends application components through OXC while the React Native production/Jest path still uses Babel. These compilers are documented as behaviorally different, and this route also skips Babel-only transforms such as the FullStory JSX annotations in babel.config.js, so measured render counts and durations can diverge from the native code the benchmark is intended to protect; a regression caused by Babel's compiler output or annotation overhead can consequently pass this suite. Preserve the production transforms for measured React modules, or reproduce them before using OXC for the remaining lowering. CLAUDE.mdL57-L59

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

web uses oxc transformer and the Rust React Compiler. iOS/Android will soon: https://expensify.slack.com/archives/C05LX9D6E07/p1787741223437369

});

if (oxcResult.fatal || !oxcResult.code) {
return null;
}

const cjs = esbuild.transformSync(oxcResult.code, {
loader: 'js',
format: 'cjs',
supported: {'dynamic-import': false},
sourcefile: sourcePath,
sourcemap: true,
Comment on lines +58 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Chain the OXC source map into esbuild

When transformed application code throws during a performance test, this source map treats oxcResult.code as though it were the original sourcePath, while the actual OXC map produced above is discarded. React Compiler rewrites can substantially move generated statements, so Jest stack frames point to incorrect source lines; compose oxcResult.map with the esbuild map (as the existing Rsbuild OXC loader does) before returning it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate from a previous review, chose not to address

});

return {code: cjs.code, map: cjs.map};
}

module.exports = {
canInstrument: false,
getCacheKey(sourceText, sourcePath, transformOptions) {
if (!shouldUseOxc(sourcePath)) {
return babelTransformer.getCacheKey(sourceText, sourcePath, transformOptions);
}

return crypto
.createHash('sha1')
.update(sourceText)
.update('\0', 'utf8')
.update(sourcePath)
.update(TRANSFORMER_SOURCE)
.update(REACT_COMPILER_CONFIG_KEY)
.update(esbuild.version)
.update(OXC_TRANSFORM_REACT_VERSION)
.digest('hex');
},
process(sourceText, sourcePath, transformOptions) {
if (shouldUseOxc(sourcePath)) {
const result = processWithOxc(sourceText, sourcePath);
if (result) {
return result;
}
}

return babelTransformer.process(sourceText, sourcePath, transformOptions);
},
};
5 changes: 4 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ module.exports = {
`<rootDir>/?(*.)+(spec|test).${testFileExtension}`,
],
transform: {
'^.+\\.[jt]sx?$': 'babel-jest',
// Reassure re-transforms ~7k files under `--max-opt=1` (V8 sparkplug only), which
// makes Babel ~half of each measure job. OXC + esbuild is native and stays fast
// without TurboFan. Test files stay on babel-jest so `jest.mock` is still hoisted.
'^.+\\.[jt]sx?$': isPerfTestRun ? '<rootDir>/config/babel/oxcJestTransformer.js' : 'babel-jest',
Comment thread
roryabraham marked this conversation as resolved.
Comment on lines +21 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm new to this area of our codebase so just about anything you write here will be a bit hard for me to understand, but there's a lot in here which is specifically hard to understand and goes beyond the scope of what this line does. Maybe this version would explain the why behind this line without adding more questions?

// For performance test runs use oxc to transform the source files using native code, instead of Babel running JS. Performance tests use a really slow but stable compiler for stable results, which makes the transformation slow. Oxc avoids that slow down, saving developer time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

going to merge and then follow-up with a better comment

'^.+\\.svg?$': 'jest-transformer-svg',
},
transformIgnorePatterns: [
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"tests/globals.d.ts",
".storybook/**/*.{js,ts,tsx}",
"metro.config.js",
"config/babel/oxcJestTransformer.js",
"eslint.changed.config.mjs",
"react-native.config.js",
"rock.config.mjs",
Expand Down
84 changes: 84 additions & 0 deletions tests/tooling/oxcTransformer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {describe, expect, it} from 'bun:test';

import {createRequire} from 'node:module';
import path from 'node:path';

type TransformResult = {code: string};

type OxcTransformer = {
process: (sourceText: string, sourcePath: string, transformOptions: unknown) => TransformResult;
};

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Jest transformers are CJS and tsconfig.bun.json does not type-check config/babel/
const oxcTransformer = createRequire(import.meta.url)('../../config/babel/oxcJestTransformer') as OxcTransformer;

const transformOptions = {
config: {cwd: process.cwd(), rootDir: process.cwd(), cache: false},
cacheFS: new Map(),
configString: '',
instrument: false,
supportsDynamicImport: false,
supportsExportNamespaceFrom: false,
supportsStaticESM: false,
supportsTopLevelAwait: false,
};

describe('oxcTransformer', () => {
it('emits CommonJS for app TypeScript', () => {
const source = `
export function add(a: number, b: number): number {
return a + b;
}
`;
const result = oxcTransformer.process(source, path.resolve('src/libs/math.ts'), transformOptions);
expect(result.code).toContain('module.exports');
expect(result.code).toContain('add: () => add');
expect(result.code).not.toMatch(/^export /m);
expect(result.code).not.toContain(': number');
});

it('runs React Compiler on app components', () => {
const source = `
export function Hello({name}: {name: string}) {
return <div>{name.toUpperCase()}</div>;
}
`;
const result = oxcTransformer.process(source, path.resolve('src/components/Hello.tsx'), transformOptions);
expect(result.code).toMatch(/compiler-runtime|_c\(/);
expect(result.code).toContain('jsxDEV');
});

it('leaves test files on babel-jest so jest.mock is hoisted', () => {
const source = `
import foo from './foo';
jest.mock('./foo');
export const x = 1;
`;
const result = oxcTransformer.process(source, path.resolve('tests/perf-test/Hello.perf-test.tsx'), transformOptions);
expect(result.code).toContain('_getJestObj().mock("./foo")');
expect(result.code.indexOf('_getJestObj().mock')).toBeLessThan(result.code.indexOf('exports.x'));
});

it('lowers dynamic import() so Jest still owns the module graph', () => {
const source = `
export function loadLazy() {
return import('./LazyScreen');
}
`;
const result = oxcTransformer.process(source, path.resolve('src/libs/loadLazy.ts'), transformOptions);
expect(result.code).not.toMatch(/\bimport\s*\(/);
expect(result.code).toMatch(/require\(['"]\.\/LazyScreen['"]\)/);
});

it('falls back to babel-jest for Flow in node_modules', () => {
const source = `
// @flow
export function add(a: number, b: number): number {
return a + b;
}
`;
const result = oxcTransformer.process(source, path.resolve('node_modules/react-native/Libraries/foo.js'), transformOptions);
expect(result.code).toBeTruthy();
expect(result.code).not.toContain(': number');
});
});
Loading