import { existsSync, readFileSync } from 'node:fs' import { extname, join } from 'node:path' // type-only, so that importing this module does not drag babel in. every metro // worker loads it through the package index, and on the native transform path // babel is never called at all: loading it there is pure startup cost. import type * as babel from '@babel/core' import { resolvePath } from '@vxrn/utils' import { normalizePath } from 'vite' import { configuration } from './configure' import { asyncGeneratorRegex, debug } from './constants' import type { GetTransformProps, GetTransformResponse } from './types' type Props = GetTransformProps & { userSetting?: GetTransformResponse } const USER_BABEL_CONFIG_FILES = [ 'babel.config.js', 'babel.config.cjs', 'babel.config.mjs', 'babel.config.json', '.babelrc', '.babelrc.js', '.babelrc.json', ] as const // matches ONE_GENERATED_MARKER in one/src/cli/generateBundlerConfig.ts const ONE_GENERATED_MARKER = '@one/generated bundler-config' export function findUserBabelConfig(projectRoot?: string): string | null { if (!projectRoot) return null for (const name of USER_BABEL_CONFIG_FILES) { const fullPath = join(projectRoot, name) if (existsSync(fullPath)) { try { const content = readFileSync(fullPath, 'utf8') if (content.includes(ONE_GENERATED_MARKER)) { continue } } catch { continue } return fullPath } } return null } export function getBabelOptions(props: Props): babel.TransformOptions | null { // unify caller contracts (the Vite plugin hands POSIX ids; the native/patches // path hands OS-native ids) so every path matcher below can assume forward // slashes. Mirrors transformSWC.ts, which normalizes at its entry for the same // reason. without it, RN's own files aren't matched on Windows. props = { ...props, id: normalizePath(props.id.split('?')[0]) } const isProjectFile = !props.id.includes('node_modules') const userBabelConfig = isProjectFile && props.projectRoot ? findUserBabelConfig(props.projectRoot) : null if (props.userSetting === 'babel') { return getOptions(props, true, userBabelConfig) } if ( typeof props.userSetting === 'undefined' || (typeof props.userSetting === 'object' && props.userSetting.transform === 'babel') ) { if (props.userSetting?.excludeDefaultPlugins) { return { caller: getBabelCaller(props), ...props.userSetting, ...(userBabelConfig ? { configFile: userBabelConfig, babelrc: true } : {}), } } return getOptions(props, false, userBabelConfig) } // an explicit per-file opt-out of babel survives a user config. without // this, merely adding babel.config.js flips swc/oxc files to babel. const userSetting = props.userSetting if ( userSetting === 'swc' || userSetting === 'oxc' || userSetting === false || (typeof userSetting === 'object' && userSetting !== null && (userSetting.transform === 'swc' || userSetting.transform === 'oxc')) ) { return null } if (userBabelConfig) { return getOptions(props, false, userBabelConfig) } return null } const getOptions = ( props: Props, force = false, userBabelConfig: string | null = null ): babel.TransformOptions | null => { let plugins: babel.PluginItem[] = [] if (force || shouldBabelGenerators(props)) { plugins = getBasePlugins(props) } const enableNativewind = configuration.enableNativewind && (props.environment === 'ios' || props.environment === 'android') && // if reanimated gets wrapped in transform it causes circular dep issues !/node_modules/.test(props.id) && // only needed for createElement calls, so be a bit conservative props.code.includes('createElement') if (enableNativewind) { if (!props.id.includes('node_modules')) { plugins.push(resolvePath('react-native-css-interop/dist/babel-plugin.js')) } } if (shouldBabelReactCompiler(props)) { debug?.(`Using babel react compiler on file`) plugins.push(getBabelReactCompilerPlugin(props)) } if (plugins.length || userBabelConfig) { return { plugins, caller: getBabelCaller(props), ...(userBabelConfig ? { configFile: userBabelConfig, babelrc: true } : {}), } } return null } function getBabelCaller(props: Props): babel.TransformOptions['caller'] { // babel hands every caller field to presets (babel-preset-expo reads platform // and isDev), but its types list only the esm support flags, so the extra // fields go through a named value rather than a checked literal. const caller = { name: 'vxrn', platform: props.environment === 'ios' || props.environment === 'android' ? props.environment : 'web', isDev: props.development, } return caller } /** * Run the react compiler through oxc's rust port instead of babel. * * measured over 156 real .tsx files in this repo: 1.74ms/file vs babel's * 47.57ms/file, with identical memoization decisions (116 memoized by both, * zero divergence). `jsx: 'preserve'` leaves JSX alone so vite's own oxc pass * still applies the project's jsxImportSource and dev-mode settings, exactly * as it did when babel only stripped types here. */ export async function transformOxcReactCompiler( id: string, code: string, optionsOrTarget: '18' | '19' | (Record & { target?: '18' | '19' }) = '19', sourceMap = false ) { const { transform } = await import('oxc-transform-react') const compilerOptions = typeof optionsOrTarget === 'string' ? { target: optionsOrTarget } : optionsOrTarget || {} const target = compilerOptions.target ?? '19' const result = await transform(id, code, { jsx: 'preserve', sourcemap: sourceMap, reactCompiler: { target, ...compilerOptions, environment: { enableCustomTypeDefinitionForReanimated: true, ...compilerOptions.environment, }, }, }) // `errors` with fatal:false are react compiler BAILOUTS ("Cannot access refs // during render", "This value cannot be modified"). the compiler skips // memoizing that component and still returns valid code, which is what babel // does silently. only a fatal result means we couldn't transform the file. if (result.fatal) { throw new Error( `[vxrn:compiler] oxc react compiler failed on ${id}: ${(result.errors ?? []) .map((e: any) => e.message ?? String(e)) .join(', ')}` ) } if (result.errors?.length) { debug?.( `[compiler/bailout] ${id}: ${result.errors.map((e: any) => e.message ?? String(e)).join(', ')}` ) } return { code: result.code, map: sourceMap ? (result.map as any) : undefined, } } /** * Transform input to mostly ES5 compatible code, keep ESM syntax, and transform generators. */ export async function transformBabel( id: string, code: string, options: babel.TransformOptions ) { const [{ default: babelCore }, { default: hermesParserPlugin }] = await Promise.all([ import('@babel/core'), import('babel-plugin-syntax-hermes-parser'), ]) const extension = extname(id) const isTSX = extension === '.tsx' const isTS = isTSX || extension === '.ts' const babelOptions: babel.TransformOptions = { filename: id, compact: false, babelrc: options.babelrc ?? false, configFile: options.configFile ?? false, sourceMaps: false, minified: false, ...options, ...(!isTS ? { parserOpts: { ...options.parserOpts, plugins: [...(options.parserOpts?.plugins || []), 'jsx'], }, } : {}), // vite and rolldown own module syntax and import.meta, so presets written for // metro (babel-preset-expo) must keep esm instead of rewriting it for metro's runtime caller: { name: 'vxrn', ...options.caller, supportsStaticESM: true, supportsDynamicImport: true, }, presets: [ isTS ? [ '@babel/preset-typescript', { isTSX, allExtensions: isTSX, }, ] : '', ...(options.presets || []), ].filter(Boolean), // non-TS files use React Native's Flow dialect. RN's own component specs are // Flow `.js` (`import type`, `codegenNativeComponent(…)`, `(expr: Type)` casts). // enable Hermes Flow parsing + stripping, mirroring the TypeScript preset above // (`@react-native/babel-preset` uses the same parser for JS). without it babel // can't parse current RN Flow syntax such as `call(...) as HostComponent`, so // plugins like // @react-native/babel-plugin-codegen silently fail on RN specs and the runtime // "Codegen didn't run" warning fires. TS path is byte-identical (empty slice). plugins: [ ...(isTS ? [] : [ [hermesParserPlugin, { parseLangTypes: 'flow', reactRuntimeTarget: '19' }], 'babel-plugin-transform-flow-enums', ]), ...(options.plugins || []), ...(isTS ? [] : ['@babel/plugin-transform-flow-strip-types']), ], } return await new Promise((res, rej) => { babelCore.transform(code, babelOptions, (err: unknown, result) => { if (!result || err) { return rej(err || new Error(`[vxrn:compiler] babel returned no result for ${id}`)) } res(result) }) }) } const getBasePlugins = ({ development }: Props) => [ ['@babel/plugin-transform-destructuring'], ['@babel/plugin-transform-react-jsx', { development }], ['@babel/plugin-transform-async-generator-functions'], ['@babel/plugin-transform-async-to-generator'], [ '@babel/plugin-transform-runtime', { helpers: true, // NOTE THIS WAS SPELLED WRONG BEFOER THIS COMMIT MAYBE IT WAS UNINTENTIONALLY WORKING regenerator: false, }, ], ] satisfies babel.PluginItem[] /** * ----- react compiler ----- */ const shouldBabelReactCompiler = (props: Props) => { if (props.environment === 'ssr') { // disable on server for now - we aren't really re-rendering anything anyway return false } if (!configuration.enableCompiler) { return false } const { enableCompiler } = configuration // per-platform config object if ( typeof enableCompiler === 'object' && !Array.isArray(enableCompiler) && !(enableCompiler instanceof RegExp) ) { const isNative = props.environment === 'ios' || props.environment === 'android' const filter = isNative ? enableCompiler.native : enableCompiler.web if (!filter) return false return applyCompilerFilter(filter, props) } // function filter - let user control entirely if (typeof enableCompiler === 'function') { return enableCompiler(props.id, props.environment) } // regex filter if (enableCompiler instanceof RegExp) { return enableCompiler.test(props.id) } // environment array filter if (Array.isArray(enableCompiler)) { if (!enableCompiler.includes(props.environment)) { return false } } // default filters for boolean/array modes return applyDefaultFilters(props) } function applyCompilerFilter( filter: boolean | RegExp | ((id: string, environment: Props['environment']) => boolean), props: Props ): boolean { if (typeof filter === 'function') { return filter(props.id, props.environment) } if (filter instanceof RegExp) { return filter.test(props.id) } // boolean true - use default filters if (filter === true) { return applyDefaultFilters(props) } return false } function applyDefaultFilters(props: Props): boolean { if (!/(\.tsx?)$/.test(props.id)) return false // disable node modules for now... if (props.id.includes('node_modules')) return false // we had OG image generation on the server using react, this fixes a bug because compiler breaks @vercel/og if (props.id.includes('+api.')) return false if (props.code.startsWith('// disable-compiler')) return false return true } const getBabelReactCompilerPlugin = (props: Props) => { const target = props.reactForRNVersion === '18' && (props.environment === 'ios' || props.environment === 'android') ? '18' : '19' return ['babel-plugin-react-compiler', { target }] } /** * ----- generators ------ */ function shouldBabelGenerators({ code }: Props) { if (process.env.VXRN_USE_BABEL_FOR_GENERATORS) { return asyncGeneratorRegex.test(code) } }