diff --git a/config/babel/oxcJestTransformer.js b/config/babel/oxcJestTransformer.js new file mode 100644 index 000000000000..33a1f167c35f --- /dev/null +++ b/config/babel/oxcJestTransformer.js @@ -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, + }); + + 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, + }); + + 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); + }, +}; diff --git a/jest.config.js b/jest.config.js index 7b9a523b566f..14b6e257de98 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,10 @@ module.exports = { `/?(*.)+(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 ? '/config/babel/oxcJestTransformer.js' : 'babel-jest', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: [ diff --git a/knip.json b/knip.json index 5ba328041087..0096a0064917 100644 --- a/knip.json +++ b/knip.json @@ -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", diff --git a/tests/tooling/oxcTransformer.test.ts b/tests/tooling/oxcTransformer.test.ts new file mode 100644 index 000000000000..85d92cc27dbb --- /dev/null +++ b/tests/tooling/oxcTransformer.test.ts @@ -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
{name.toUpperCase()}
; + } + `; + 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'); + }); +});