// rnx vite plugin — use in any vite project to get react-native resolution, // native dep stubbing, and external app support. // // usage: // import { sootsim } from 'sootsim/vite' // export default defineConfig({ plugins: [sootsim()] }) // // // with external app: // export default defineConfig({ plugins: [sootsim({ app: '~/my-rn-app' })] }) import fs from 'fs' import { createRequire } from 'module' import path from 'path' import { fileURLToPath } from 'url' import { type Plugin, transformWithOxc } from 'vite' import { resolveExportsHiddenSubpath } from '../../compat/src/resolve-hidden-subpath.ts' import { SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES, SOOTSIM_COMPAT_WRAPPER_REAL_PACKAGES, SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SOURCE, SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SPECIFIER, compatStubsForBuildResolver, reactNativeDeepStubsForBuildResolver, } from '../../compat/src/stub-manifest.ts' import { resolveSootsimBridgePort } from './bridge-constants.ts' import { SHELL_BRIDGE_IDENTITY_PATH, type ShellBridgeIdentity, } from './shell-bridge-identity.ts' import { REANIMATED_WORKLETIZATION_REGEX, shouldApplyWorkletsPlugin, transformWorkletsCode, } from './worklets-babel.ts' const sootsimPluginRequire = createRequire(import.meta.url) const virtualIdPrefix = String.fromCharCode(0) export interface SootSimOptions { // directory of an external RN app to load app?: string // additional source directories to treat as app sources (metro transforms apply) sources?: string[] // additional packages that must resolve from the host project, not the external app ownedPackages?: string[] } // derive sootsim's root from this file's location (src/vite-plugin.ts → ..) const sootsimRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const workspaceRoot = path.resolve(sootsimRoot, '..', '..') const workspaceNodeModules = path.resolve(workspaceRoot, 'node_modules') const workspaceTamaguiDir = path.resolve(workspaceNodeModules, '@tamagui') // stubs and the mutable React bridge live in @sootsim/compat. const compatRoot = path.resolve(sootsimRoot, '..', 'compat') // the rendering engine and its shims live in the private sootsim-engine // workspace package. resolve via workspace root rather than relative hops so // renames of either package don't break things. const engineRoot = path.resolve(workspaceRoot, 'packages/sootsim-engine') const rnShimPath = path.resolve(engineRoot, 'src/react-native/index.ts') const reactBridgePath = path.resolve( workspaceRoot, 'packages/contrast-kit/src/react-bridge.ts', ) const sootsimBrowserSourceAliases = [ { find: 'rnxsim/backend-origin', replacement: path.resolve(sootsimRoot, 'src/backend-origin.ts'), }, { find: 'rnxsim/bridge-constants', replacement: path.resolve(sootsimRoot, 'src/bridge-constants.ts'), }, { find: 'rnxsim/dev-bundle-resolution', replacement: path.resolve(sootsimRoot, 'src/dev-bundle-resolution.ts'), }, ] const compatStubsDir = path.resolve(compatRoot, 'src/stubs') const nativeAutoStubPath = path.resolve(compatStubsDir, 'native-auto-stub.ts') const rnDeepStubDefault = path.resolve(compatStubsDir, 'react-native-internals.ts') function getInternalTamaguiPackages(): string[] { try { const scoped = fs .readdirSync(workspaceTamaguiDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => `@tamagui/${entry.name}`) return ['tamagui', ...scoped].sort() } catch { return ['tamagui'] } } const internalTamaguiPackages = getInternalTamaguiPackages() const vitePackageStubs = compatStubsForBuildResolver('vite') const viteReactNativeDeepStubs = reactNativeDeepStubsForBuildResolver('vite') const builtinStubs = Object.fromEntries( vitePackageStubs.map((entry) => [entry.specifier, entry.stubFile]), ) const rnLibraryStubs = Object.fromEntries( viteReactNativeDeepStubs.map((entry) => [ entry.specifier, path.resolve(compatStubsDir, entry.stubFile), ]), ) type PublishedBrowserPackage = { specifier: string packageDir: string browserEntry: string } function resolvePublishedBrowserPackages(appDir: string): PublishedBrowserPackage[] { const packageRequire = appDir ? createRequire(path.join(appDir, 'package.json')) : sootsimPluginRequire return Object.values(SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES).flatMap( ({ specifier }) => { try { const packageJson = packageRequire.resolve(`${specifier}/package.json`) const packageManifest = JSON.parse(fs.readFileSync(packageJson, 'utf8')) const rootExport = Reflect.get(packageManifest.exports, '.') const browserFile = typeof rootExport === 'object' && rootExport !== null ? Reflect.get(rootExport, 'browser') : undefined if (typeof browserFile !== 'string') return [] const packageDir = path.dirname(packageJson) const browserEntry = path.resolve(packageDir, browserFile) if (!fs.existsSync(browserEntry)) return [] return [{ specifier, packageDir, browserEntry }] } catch { return [] } }, ) } function resolvePublishedBrowserInternal( source: string, importer: string | undefined, packages: PublishedBrowserPackage[], ): string | null { if (!importer || !source.startsWith('.')) return null const cleanImporter = importer.split('?')[0] const packageEntry = packages.find(({ packageDir }) => { const relative = path.relative(packageDir, cleanImporter) return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative) }) if (!packageEntry) return null const cleanSource = source.split('?')[0] const resolved = path.resolve(path.dirname(cleanImporter), cleanSource) const sourceExtension = path.extname(cleanSource) if (sourceExtension) { const candidate = `${resolved.slice(0, -sourceExtension.length)}.web${sourceExtension}` return fs.existsSync(candidate) ? candidate : null } for (const extension of ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs']) { const fileCandidate = `${resolved}.web${extension}` if (fs.existsSync(fileCandidate)) return fileCandidate const indexCandidate = path.join(resolved, `index.web${extension}`) if (fs.existsSync(indexCandidate)) return indexCandidate } return null } // resolve the real @react-navigation/native location at config time so the // wrapper at compat/src/stubs/react-navigation-native.tsx can import it // without bouncing back through the alias above. the wrapper imports from // the synthetic specifier `@sootsim-internal/react-navigation-native-real` // which is aliased to this absolute path; rolldown follows the path // terminally so its `export *` static analysis sees the real export // surface. const reactNavigationNativeRealPath = (() => { try { return sootsimPluginRequire.resolve('@react-navigation/native') } catch { return null } })() const reactNativeHapticFeedbackTouchablePath = (() => { try { return path.join( path.dirname( sootsimPluginRequire.resolve('react-native-haptic-feedback/package.json'), ), SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SOURCE, ) } catch { return null } })() // `@expo/ui/swift-ui/modifiers` is pure JS (every modifier just returns a // ModifierConfig via createModifier) and runs from the real bundle unchanged — // the runtime stub-registry intentionally does NOT intercept it. but the // `@expo/ui/swift-ui` stub alias is a vite PREFIX match, so without an explicit // longer alias it captures `.../modifiers` and rewrites it to // `/expo-ui.ts/modifiers` ("Not a directory"). resolve the real subpath // here and alias it first (stubAliases are sorted longest-first, and this is // longer than `@expo/ui/swift-ui`, so it wins). const expoUiSwiftUiModifiersRealPath = (() => { try { return sootsimPluginRequire.resolve('@expo/ui/swift-ui/modifiers') } catch { return null } })() // the same prefix-alias trap, for the whole Android half of the package. // `@expo/ui/jetpack-compose` and its `/modifiers` subpath are deliberately NOT // intercepted: every component is a thin `requireNativeView('ExpoUI', …)` // wrapper and the Android views come from the compat native seam, so the real // upstream JS has to load. Without these two longer aliases the `@expo/ui` stub // prefix rewrites them to `/expo-ui.ts/jetpack-compose` ("Not a // directory") and every Android Expo UI screen fails to build. const expoUiJetpackComposeRealAliases = [ '@expo/ui/jetpack-compose/modifiers', '@expo/ui/jetpack-compose', ].flatMap((specifier) => { try { return [{ find: specifier, replacement: sootsimPluginRequire.resolve(specifier) }] } catch { return [] } }) // these components are upstream pure JS and should load unchanged over the // native seam. gesture handler 3 still supplies ReanimatedSwipeable but removed // DrawerLayout, so the latter resolves from the explicit v2 compatibility // alias. exact subpath aliases must precede the root facade alias. function rnghSubpathRealPath(packageName: string, subpath: string): string | null { try { const packageJsonPath = sootsimPluginRequire.resolve( `${packageName}/${subpath}/package.json`, ) const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) const moduleEntry = packageJson?.module if (typeof moduleEntry !== 'string' || moduleEntry.length === 0) return null const modulePath = path.resolve(path.dirname(packageJsonPath), moduleEntry) if (fs.existsSync(modulePath) && fs.statSync(modulePath).isFile()) return modulePath const jsPath = `${modulePath}.js` if (fs.existsSync(jsPath)) return jsPath const indexPath = path.join(modulePath, 'index.js') return fs.existsSync(indexPath) ? indexPath : null } catch { return null } } const rnghReanimatedSwipeableRealPath = rnghSubpathRealPath( 'react-native-gesture-handler', 'ReanimatedSwipeable', ) const rnghDrawerLayoutRealPath = rnghSubpathRealPath( 'react-native-gesture-handler-v2', 'DrawerLayout', ) const rnghGestureHandlerRootViewContextRealPath = (() => { try { return path.join( path.dirname( sootsimPluginRequire.resolve('react-native-gesture-handler/package.json'), ), 'lib/module/GestureHandlerRootViewContext.js', ) } catch { return null } })() const rnghGestureHandlerRootViewContextShimPath = path.resolve( compatStubsDir, 'rngh-gesture-handler-root-view-context.ts', ) const rnghRootStubPath = path.resolve(compatStubsDir, 'react-native-gesture-handler.ts') const rnghLibModuleRoots = [ rnghGestureHandlerRootViewContextRealPath ? path.dirname(rnghGestureHandlerRootViewContextRealPath) : null, rnghDrawerLayoutRealPath ? path.dirname(path.dirname(rnghDrawerLayoutRealPath)) : null, ].filter((root): root is string => root !== null) const compatWrapperRealPackageAliases: Array<{ find: string replacement: string }> = [] for (const { specifier, packageName } of Object.values( SOOTSIM_COMPAT_WRAPPER_REAL_PACKAGES, )) { let replacement = resolveExportsHiddenSubpath(packageName, (specifier) => sootsimPluginRequire.resolve(specifier), ) if (!replacement) { try { replacement = sootsimPluginRequire.resolve(packageName) } catch {} } if (!replacement) { // a dropped alias only surfaces much later as an opaque // "failed to resolve @sootsim-internal/…", so say it here instead. console.warn( `[sootsim] compat wrapper alias ${specifier} -> ${packageName} did not resolve`, ) continue } compatWrapperRealPackageAliases.push({ find: specifier, replacement }) } // packages that must always resolve from sootsim to ensure single instances // across the renderer/runtime itself. keep this list minimal for external apps. const coreOwnedPackages = [ 'react', 'react-dom', 'react-reconciler', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'canvaskit-wasm', 'canvaskit-wasm/full', 'yoga-layout', ] const nativePackagePatterns = [ /^expo-/, /^react-native-/, /^@react-native\//, /^@react-native-community\//, /^@expo\//, /^expo$/, ] function getPackageName(source: string): string { return source.startsWith('@') ? source.split('/').slice(0, 2).join('/') : source.split('/')[0] } function isNativePackage(source: string): boolean { return nativePackagePatterns.some((p) => p.test(source)) } function moduleExistsIn(pkgName: string, dir: string): boolean { try { return fs.existsSync(path.join(dir, 'node_modules', pkgName)) } catch { return false } } export function sootsim(options: SootSimOptions = {}): Plugin[] { const appDir = options.app ? path.resolve(options.app) : '' const extraSources = (options.sources || []).map((s) => path.resolve(s)) const ownedPackages = new Set([...coreOwnedPackages, ...(options.ownedPackages || [])]) const publishedBrowserPackages = resolvePublishedBrowserPackages(appDir) function isAppSource(filePath: string): boolean { // exclude node_modules — only actual app source files if (filePath.includes('/node_modules/')) return false if (appDir && filePath.startsWith(appDir)) return true return extraSources.some((s) => filePath.startsWith(s)) } return [ wsBridgePlugin(), reactBridgePlugin(), { name: 'sootsim-published-browser-internals', enforce: 'pre', resolveId: { filter: { id: /^\./ }, handler(source, importer) { return resolvePublishedBrowserInternal( source, importer, publishedBrowserPackages, ) }, }, }, sootsimConfigPlugin(appDir, extraSources, publishedBrowserPackages), rnghUpstreamInternalResolvePlugin(), fixJsxRuntimeExports(), flowStripTransform(), nodeModulesJsxTransform(), // must run before metroNativeResolve — that plugin resolves relative // imports like `./bindings` to absolute file paths via the platform- // extension lookup, and once it returns a resolved id, downstream // resolveId hooks don't see the source string anymore. keyboardControllerBindingsRedirect(), reanimatedNativeSeamRedirect(), manifestNativeSeamRedirect(), workletsBabelTransform(isAppSource), metroNativeResolve(isAppSource), reactNativeRequirePlugin(isAppSource), ...(appDir ? [externalAppResolvePlugin(appDir, isAppSource, ownedPackages)] : []), ...(appDir || extraSources.length > 0 ? [externalAppTransformPlugin(isAppSource)] : []), stubMissingNativeDeps(appDir), stubMissingImages(isAppSource), ] } function stripViteQuery(id: string): string { return id.split('?')[0] } function isPathInside(child: string, parent: string): boolean { const rel = path.relative(parent, child) return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel)) } function rnghUpstreamInternalResolvePlugin(): Plugin { return { name: 'sootsim-rngh-upstream-internal-resolve', enforce: 'pre', resolveId: { filter: { id: /(?:^\.\.\/\.\.(?:\.(?:js|jsx|mjs|ts|tsx))?$|GestureDetector(?:\.(?:js|jsx|mjs|ts|tsx))?$|GestureHandlerRootViewContext(?:\.(?:js|jsx|mjs|ts|tsx))?$|(?:PanGestureHandler|TapGestureHandler|State)(?:\.(?:js|jsx|mjs|ts|tsx))?$)/, }, handler(source, importer) { if (!importer || rnghLibModuleRoots.length === 0) return null const importerPath = stripViteQuery(importer) if (!rnghLibModuleRoots.some((root) => isPathInside(importerPath, root))) { return null } const sourcePath = source.replace(/\.(js|jsx|mjs|ts|tsx)$/, '') if ( sourcePath === '../..' || sourcePath.endsWith('/handlers/gestures/GestureDetector') || // upstream's DrawerLayout builds on the old-API handlers and the // State enum; both come from our seam, not upstream's native modules. sourcePath.endsWith('/handlers/PanGestureHandler') || sourcePath.endsWith('/handlers/TapGestureHandler') || sourcePath === '../State' ) { return rnghRootStubPath } if (sourcePath.endsWith('GestureHandlerRootViewContext')) { return rnghGestureHandlerRootViewContextShimPath } return null }, }, } } // shared worker plugin chain for the engine's shell-host build (used by // both `vite.config.ts` for dev/watch and `vite.build.config.ts` for prod). // keep these here — duplicating the list inline drifts (which is exactly how // the prod shell worker shipped without `reanimatedNativeSeamRedirect` and // threw "Native part of Reanimated doesn't seem to be initialized" until // 2e6a8450). // // shell-host.ts spawns shell-worker.ts via Vite's declarative // `new Worker(new URL(...))` pattern, so shell-worker is a worker chunk of // the engine build. apply react-bridge / jsx-runtime fixes plus the // reanimated/keyboard-controller native-seam redirects so upstream's // reanimated wires up our turbomodule + globals inside the worker too. // without these, worker bundles see upstream's original // `NativeReanimatedModule.ts`, `TurboModuleRegistry.get('ReanimatedModule')` // returns null on web/sootsim, `__reanimatedModuleProxy` never gets set, // and the NativeReanimated constructor throws on shell-worker mount. // // workletsBabelTransform populates `__closure` on user-code worklets so // useAnimatedStyle/useDerivedValue/etc. subscribe correctly inside the // worker. matching is content-driven today, so the predicate is `() => true`. export function recordingPluginWorkerRedirect(): Plugin { const workerTarget = path.resolve( workspaceRoot, 'packages/rnx-plugin-recording/src/worker.ts', ) return { name: 'sootsim-recording-plugin-worker-redirect', enforce: 'pre', resolveId(source) { if (source === '@rnx/plugin-recording') { return workerTarget } return null }, } } export function engineShellWorkerPlugins(): Plugin[] { return [ workerReactBridgePlugin(), fixJsxRuntimeExports(), flowStripTransform(), nodeModulesJsxTransform(), recordingPluginWorkerRedirect(), reanimatedNativeSeamRedirect(), manifestNativeSeamRedirect(), keyboardControllerBindingsRedirect(), rnghUpstreamInternalResolvePlugin(), workletsBabelTransform(() => true), workerBootChunkPlugin(ENGINE_WORKER_BOOT_CLOSURES), ] } // a worker cannot modulepreload, so every static level under its // implementation root and every dynamic import on its boot path is a serial // round trip before the worker is ready. this plugin bundles each declared // worker into one file and fails the build when that file grows past its byte // cap, so a worker feature cannot silently add startup bytes. // // the entries stay export-free and import-free so webkit keeps the one-way // dynamic boundary (see shell-worker-entry.ts); with every chunk inlined no // other file can import the worker script, so nothing can evaluate it twice. // inlined dynamic roots still evaluate at their `import()` (the reanimated // passthrough must install before upstream reanimated, the worklet runtime // must exist before shell-in-worker): with code splitting off rolldown wraps // every dynamic-import target in an init function that runs on first import. // shell-worker.ts throws at init if a bundle ever hoists one of them. export type WorkerBootClosure = { // substring of the worker entry module id this applies to entry: string // byte cap on the rendered worker file; fix excess boot dependencies and // unneeded inclusions rather than raising the cap maxBytes: number } const ENGINE_WORKER_BOOT_CLOSURES: WorkerBootClosure[] = [ // these workers inline their dynamic imports, so anything reachable from the // entry lands in the boot file whether or not it runs. that is why the font // parser is not reachable from either one: only the tenant reads a face's // name table, and it sends the aliases and metrics it read on the wire // (engine/app-font-registry.ts registerForwardedAppFonts). keep it that way // when adding font work here, or both caps take the parser again. // measured 3,406,869 bytes after the shell engine config split: the shell // worker installs a slim config without the list, modal, slider, swipeable, // and tab components nothing shell-side reads (compat-bridge composes the // full config for main, tenant, headless, and external realms on top of // it), and the gesture-handler stub imports TouchableNativeFeedback // statically instead of require()ing the whole react-native namespace into // the boot file. { entry: 'src/render-worker/shell-worker-entry.ts', maxBytes: 3_451_000 }, { entry: 'src/render-worker/compositor-worker-entry.ts', maxBytes: 1_080_000 }, { entry: 'src/render-worker/compositor-worker-ganesh-entry.ts', maxBytes: 1_820_000 }, ] export function workerBootChunkPlugin(closures: WorkerBootClosure[]): Plugin { let closure: WorkerBootClosure | null = null return { name: 'sootsim-worker-boot-chunk', apply: 'build', options(inputOptions) { const input = inputOptions.input closure = typeof input === 'string' ? (closures.find((candidate) => input.includes(candidate.entry)) ?? null) : null }, outputOptions(options) { if (!closure) return null return { ...options, codeSplitting: false } }, generateBundle(_options, bundle) { if (!closure) return const chunks = Object.values(bundle).filter((output) => output.type === 'chunk') if (chunks.length !== 1 || !chunks[0].isEntry) { this.error( `worker ${closure.entry}: expected one inlined chunk, got ${chunks.map((chunk) => chunk.fileName).join(', ')}`, ) } const bytes = Buffer.byteLength(chunks[0].code) if (bytes > closure.maxBytes) { this.error( `worker ${closure.entry}: ${chunks[0].fileName} is ${bytes} bytes, over the ${closure.maxBytes} byte cap; eliminate excess boot dependencies rather than raising the cap`, ) } }, } } // react-native-keyboard-controller is pure-JS for everything except its // native-event seam (`bindings.ts` / `bindings.native.ts`) and RN platform // `findNodeHandle` resolution. let upstream's components, hooks, animated // module, and KeyboardAvoidingView resolve from node_modules unchanged, and // redirect only those native/platform seams. // // the redirect runs `enforce: 'pre'` so it sees `./bindings` / `../bindings` // / `../../bindings` strings before vite's native resolver collapses them to // absolute paths. matches by importer path (must be inside upstream's // node_modules root) and source basename. export function keyboardControllerBindingsRedirect(): Plugin { const target = path.resolve(compatStubsDir, 'react-native-keyboard-controller.ts') const findNodeHandleTarget = '\0sootsim:rnkc-find-node-handle-native' const nativePlatformBasenames = new Set(['findNodeHandle', 'reanimated']) const nativeExts = [ '.ios.tsx', '.ios.ts', '.ios.jsx', '.ios.js', '.native.tsx', '.native.ts', '.native.jsx', '.native.js', '.native.mjs', ] const resolveNativePlatformFile = (source: string, importer: string): string | null => { const base = path.resolve(path.dirname(importer), source) for (const ext of nativeExts) { const candidate = base + ext try { if (fs.existsSync(candidate)) return candidate } catch {} } for (const ext of nativeExts) { const candidate = path.join(base, 'index' + ext) try { if (fs.existsSync(candidate)) return candidate } catch {} } return null } return { name: 'sootsim-keyboard-controller-bindings-redirect', enforce: 'pre', resolveId: { filter: { id: /(?:^|\/)(?:bindings(?:\.native)?|findNodeHandle|reanimated)$/, }, handler(source, importer) { if (!importer) return null if (!importer.includes('/react-native-keyboard-controller/')) return null // keep bindings imports — strip optional .native and any leading // `./`, `../`, `../../` so the depth doesn't matter. const basename = source.split('/').pop() ?? '' if (nativePlatformBasenames.has(basename)) { return ( resolveNativePlatformFile(source, importer) ?? (basename === 'findNodeHandle' ? findNodeHandleTarget : null) ) } if (basename !== 'bindings' && basename !== 'bindings.native') return null // don't redirect bindings imports that already resolved to our file if (importer === target) return null return target }, }, load: { filter: { id: new RegExp(`^${virtualIdPrefix}sootsim:rnkc-find-node-handle-native$`), }, handler() { return `export { findNodeHandle } from ${JSON.stringify(rnShimPath)};` }, }, } } // react-native-reanimated is mostly pure-JS — useSharedValue, useAnimatedStyle, // useDerivedValue, withTiming/withSpring/etc., createAnimatedComponent, hooks, // layout animations, easing all sit on top of a thin native seam: // // - src/specs/NativeReanimatedModule (turbomodule that installs __reanimatedModuleProxy) // - src/platformFunctions/scrollTo // - src/platformFunctions/measure // - src/platformFunctions/setNativeProps // - src/platformFunctions/dispatchCommand // - src/platformFunctions/setGestureState // // per the project policy of not shimming pure-JS libs, we let upstream resolve // from node_modules and redirect ONLY the native seam to our compat stub // (one file at packages/compat/src/stubs/react-native-reanimated.ts that // exports the right shape — default = turbomodule, named = platform fns). // // upstream's `assertWorkletsVersion` (DEV-only) imports a build-time script // that does `require('react-native-worklets/package.json')`. our flat-file // stub for react-native-worklets has no such subpath, so this throws under // vite's worker build. redirect the validate script to a no-op stub. const reanimatedNativeSeamFiles = new Set([ 'NativeReanimatedModule', 'scrollTo', 'measure', 'setNativeProps', 'dispatchCommand', 'setGestureState', ]) const reanimatedFabricUtilsRedirectPath = path.resolve( compatStubsDir, 'react-native-reanimated-fabric-utils.ts', ) const reanimatedValidateWorkletsVersionPath = path.resolve( compatStubsDir, 'react-native-reanimated-validate-worklets-version.ts', ) export function reanimatedNativeSeamRedirect(): Plugin { const target = path.resolve(compatStubsDir, 'react-native-reanimated.ts') return { name: 'sootsim-reanimated-native-seam-redirect', enforce: 'pre', resolveId: { filter: { id: /(?:^react-native-worklets\/package\.json$|^react-native-reanimated\/scripts\/validate-worklets-version(?:\.js)?$|(?:^|\/)(?:fabricUtils|NativeReanimatedModule|scrollTo|measure|setNativeProps|dispatchCommand|setGestureState)(?:\.(?:native|web|ios|android))?$)/, }, handler(source, importer) { // upstream's `assertWorkletsVersion` (DEV-only) imports a build-time // script that does `require('react-native-worklets/package.json')`. // our flat-file stub for react-native-worklets has no such subpath, // so this throws under vite's worker build. resolve the package.json // import to a synthetic virtual id and load synthetic JSON below. if (source === 'react-native-worklets/package.json') { return '\0sootsim:react-native-worklets-package-json' } if (!importer) return null // no-op the assertWorkletsVersion validator — same reason. matches both // the bare import and the .js-suffixed resolved form some bundlers see. if ( source === 'react-native-reanimated/scripts/validate-worklets-version' || source === 'react-native-reanimated/scripts/validate-worklets-version.js' ) { return reanimatedValidateWorkletsVersionPath } if (!importer.includes('/react-native-reanimated/')) return null if (importer === target) return null // strip optional `.web` / `.native` extension and leading `./`s — the // redirect should match regardless of relative depth or platform suffix. const basename = (source.split('/').pop() ?? '').replace( /\.(native|web|ios|android)$/, '', ) // keep fabricUtils on the SootSim Fabric public-instance contract. the // redirected module reads the constructor-attached handle for native // refs and uses ReactFabric's renderer lookup for composite refs. if ( basename === 'fabricUtils' && importer !== reanimatedFabricUtilsRedirectPath ) { return reanimatedFabricUtilsRedirectPath } if (!reanimatedNativeSeamFiles.has(basename)) return null if (process.env.SOOTSIM_REANIMATED_REDIRECT_DEBUG) { console.log( '[reanimatedRedirect]', basename, '<-', source, 'from', importer.slice(-80), ) } return target }, }, load: { filter: { id: new RegExp(`^${virtualIdPrefix}sootsim:react-native-worklets-package-json$`), }, handler() { // synthetic package.json for react-native-worklets — see resolveId // above. just enough for upstream's version-validator to read `version`. return `export default ${JSON.stringify({ version: '0.0.0-sootsim', name: 'react-native-worklets' })}` }, }, } } // generalised native-seam redirect driven by NATIVE_SEAM_MANIFEST. each entry // names an upstream package + a list of basenames to redirect to a single // stub. agents migrating from wholesale-rewrite stubs to the native-seam // pattern append entries here without touching the plugin code. // // the reanimated plugin above stays separate because it has package-specific // edge cases (fabricUtils, validate-worklets-version). new migrations should // use the manifest path unless they need similar special handling. import { NATIVE_SEAM_MANIFEST } from './native-seam-manifest.ts' export function manifestNativeSeamRedirect(): Plugin { // build a fast lookup: pkg → { basenames, target } const byPkg = new Map< string, { basenames: Set; target: string; notes?: string } >() for (const entry of NATIVE_SEAM_MANIFEST) { const existing = byPkg.get(entry.pkg) if (existing) { for (const b of entry.seamBasenames) existing.basenames.add(b) } else { byPkg.set(entry.pkg, { basenames: new Set(entry.seamBasenames), target: entry.target, notes: entry.notes, }) } } const sourceFilter = new RegExp( `(?:^|/)(?:${[...byPkg.values()] .flatMap(({ basenames }) => [...basenames]) .map((basename) => basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('|')})(?:\\.(?:native|web|ios|android))?$`, ) return { name: 'sootsim-manifest-native-seam-redirect', enforce: 'pre', resolveId: { filter: { id: sourceFilter }, handler(source, importer) { if (!importer || byPkg.size === 0) return null // find the package whose path appears in the importer. matches both // `//` (unscoped) and `/@scope//` (scoped — the leading `@` // is part of the pkg name in the manifest, e.g. `@notifee/react-native`). let match: { basenames: Set; target: string; notes?: string } | undefined let matchedPkg: string | undefined for (const [pkg, entry] of byPkg) { if (importer.includes(`/${pkg}/`)) { match = entry matchedPkg = pkg break } } if (!match) return null if (importer === match.target) return null const basename = (source.split('/').pop() ?? '').replace( /\.(native|web|ios|android)$/, '', ) if (!match.basenames.has(basename)) return null if (process.env.SOOTSIM_NATIVE_SEAM_DEBUG) { console.log( '[nativeSeamRedirect]', `${matchedPkg}:${basename}`, '<-', source, 'from', importer.slice(-80), match.notes ? `(${match.notes})` : '', ) } return match.target }, }, } } interface ViteBridgeProcessEntry { boundPort: Promise close: () => Promise owners: Set shellPort: { read: () => number | null } } declare global { var __sootsimViteBridgeProcesses: Map | undefined } // vite creates the replacement server before closing the prior server during a // config restart. keep the independently listening bridge owned by that process // across the overlap so every connected sim stays on the same websocket. const viteBridgeProcesses = (globalThis.__sootsimViteBridgeProcesses ??= new Map()) // start WS bridge server for debug CLI connectivity. exported so shell // vite can install it standalone (engine builds via `vite build --watch`, // which doesn't fire configureServer — the bridge has to run on shell). export function wsBridgePlugin(options: { ownedBridgePort?: number } = {}): Plugin { if ( options.ownedBridgePort !== undefined && (!Number.isInteger(options.ownedBridgePort) || options.ownedBridgePort <= 0 || options.ownedBridgePort > 65_535) ) { throw new Error(`invalid externally owned bridge port: ${options.ownedBridgePort}`) } const owner = {} // the port the bridge ACTUALLY bound, which is not necessarily the one it // asked for: the host scans forward from its preferred port on EADDRINUSE. // the page has to be told this value. when it derived its own guess from // the shell's http port instead, any box where 7668 was already taken sent // the page dialing a port nobody listens on, and the only symptom was a // websocket console error during boot. // a parent that already owns a bridge supplies its receipt here. the shell // publishes that exact port and never starts a competing development bridge. let boundPort: Promise | null = options.ownedBridgePort === undefined ? null : Promise.resolve(options.ownedBridgePort) // vite runs closeBundle inside server.close(), so awaiting the bridge here // makes the port released by the time server.close() resolves. the http // 'close' event is not a substitute: after a failed restart vite swaps in a // replacement server that never listened, and closing it emits no 'close', // which leaked the bridge for the life of the process. let releaseBridge: (() => Promise) | null = null return { name: 'sootsim-ws-bridge', apply: 'serve', async closeBundle() { await releaseBridge?.() }, configureServer(server) { if (!boundPort) { const preferredPort = resolveSootsimBridgePort({ explicitPort: process.env.SOOTSIM_BRIDGE_PORT, portOffset: process.env.PORT_OFFSET, }) const readShellPort = () => { const addr = server.httpServer?.address() if (addr && typeof addr === 'object') return addr.port return server.config.server.port || null } const existing = viteBridgeProcesses.get(preferredPort) if (existing) { existing.owners.add(owner) existing.shellPort.read = readShellPort boundPort = existing.boundPort } else { // lazy import to avoid pulling ws into the main bundle. // note: the .ts extension is required on vite 8 / node ESM — without // it, import() throws "Cannot find module" and the bridge silently // never starts. const shellPort = { read: readShellPort } const host = import('./host/bridge-host.ts').then(({ SootSimBridgeHost }) => { return new SootSimBridgeHost({ port: preferredPort, writeDevLockfile: true, getShellPort: () => shellPort.read(), }) }) let entry: ViteBridgeProcessEntry boundPort = host .then((bridgeHost) => bridgeHost.startAsync({ silent: true })) .catch((err: unknown) => { if (viteBridgeProcesses.get(preferredPort) === entry) { viteBridgeProcesses.delete(preferredPort) } const message = err instanceof Error ? err.message : String(err) console.warn('[rnx] ws bridge failed to start:', message) return null }) entry = { boundPort, close: async () => { try { await (await host).close() } catch {} }, owners: new Set([owner]), shellPort, } viteBridgeProcesses.set(preferredPort, entry) } releaseBridge = async () => { releaseBridge = null const entry = viteBridgeProcesses.get(preferredPort) if (!entry || !entry.owners.delete(owner) || entry.owners.size > 0) return viteBridgeProcesses.delete(preferredPort) await entry.close() } } if (!boundPort) throw new Error('rnx bridge identity was not initialized') const identityPort = boundPort server.middlewares.use((req, res, next) => { if (req.url?.split('?')[0] !== SHELL_BRIDGE_IDENTITY_PATH) { next() return } void identityPort .then((bridgePort) => { if (bridgePort === null) { res.statusCode = 503 res.end('rnx bridge unavailable') return } const identity = { schema: 1, bridgePort, } satisfies ShellBridgeIdentity res.setHeader('Content-Type', 'application/json') res.setHeader('Cache-Control', 'no-store') res.end(JSON.stringify(identity)) }) .catch(() => { res.statusCode = 503 res.end('rnx bridge unavailable') }) }) }, // publish the bound port to the page. head-prepend so it lands before the // module script that reads it. `vite build` never runs configureServer, so // boundPort stays null there and nothing is injected. // // `||` rather than a plain assignment, and do not "simplify" it away: the // playwright driver and the electron preload inject the port of the daemon // they actually own via addInitScript, which runs before any page script. // that value is authoritative and must win, so this dev server only ever // fills in a blank — it never overwrites a port someone else published. async transformIndexHtml() { const port = boundPort ? await boundPort : null if (port == null) return return [ { tag: 'script', injectTo: 'head-prepend' as const, children: `window.__sootsimBridgePort=window.__sootsimBridgePort||${port};`, }, ] }, } } // applies resolve, define, optimizeDeps config function sootsimConfigPlugin( appDir: string, extraSources: string[], publishedBrowserPackages: PublishedBrowserPackage[], ): Plugin { return { name: 'sootsim-config', config(_, { mode }) { const fsAllow = ['.'] for (const src of extraSources) fsAllow.push(src) if (appDir) { fsAllow.push(appDir) const appNodeModules = path.join(appDir, 'node_modules') if (fs.existsSync(appNodeModules)) fsAllow.push(appNodeModules) } // build alias list from builtin stubs const stubAliases = Object.entries(builtinStubs) // sort longest keys first so subpath aliases (e.g. foo/Bar) match before // their parent package (e.g. foo) — vite processes aliases in order .sort((a, b) => b[0].length - a[0].length) .map(([find, file]) => ({ // the bare expo entry is a facade while its package subpaths are // independent upstream modules. a string alias is prefix-matched by // vite, so make this one exact or expo/devtools and future subpaths // become impossible paths such as expo.ts/devtools. find: find === 'expo' ? /^expo$/ : find, replacement: path.resolve(compatStubsDir, file), })) const publishedBrowserEntryAliases = publishedBrowserPackages.map( ({ specifier, browserEntry }) => ({ find: new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), replacement: browserEntry, }), ) const dedupe = ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'] // internal sootsim builds must keep the entire Tamagui package family on a // single module graph. mixing raw @fs modules with prebundled .vite/deps // versions breaks context identity (Adapt/Portal/ZIndex/etc). if (!appDir) { dedupe.push(...internalTamaguiPackages) } const optimizeDepsExclude = [ '@tamagui/config', '@tamagui/demos', 'react-native', // tamagui native variants reference RN internals that the metro transform // handles — must go through sootsim plugin, not rolldown pre-bundling ...(!appDir ? internalTamaguiPackages : []), // ships .js with JSX that rolldown can't parse 'react-native-actions-sheet', // these packages import `react-native-reanimated` and `react-native-gesture-handler`, // both of which we alias to local stubs. pre-bundling would resolve those imports // against the REAL packages (the alias only applies to main-plugin resolveId), so // the resulting dep has a different reanimated instance than the app graph. keep // them served live so our plugin aliases kick in. '@gorhom/bottom-sheet', '@gorhom/portal', // these reviewed packages publish a supported browser artifact. keep // them on the live graph so the exact alias below wins over their // `react-native` export during dependency optimization too. ...Object.values(SOOTSIM_COMPAT_PUBLISHED_BROWSER_ENTRIES).map( ({ specifier }) => specifier, ), ] return { define: { 'process.env.NODE_ENV': JSON.stringify(mode), 'process.env.TEST_NATIVE_PLATFORM': JSON.stringify(''), 'process.env.TAMAGUI_TARGET': JSON.stringify('native'), // expo sdk 57's winter runtime (expo/src/winter/runtime.native.ts) // replaces globalThis.fetch with a native implementation whose // Response class extends ExpoFetchModule.NativeResponse. that native // module does not exist in a browser, so constructing any response // throws `this.addListener is not a function` and every request the // app makes fails. upstream's own web build keeps the platform fetch // (src/winter/fetch/fetch.web.ts is `globalThis.fetch`), and this is // upstream's supported switch for that — it removes the native fetch // install, leaving the browser's streaming fetch in place. 'process.env.EXPO_PUBLIC_USE_RN_FETCH': JSON.stringify('1'), global: 'globalThis', }, optimizeDeps: { include: [ 'react', 'react-dom', 'react-dom/client', 'react-reconciler', 'react-reconciler/constants', 'react/jsx-runtime', 'react/jsx-dev-runtime', '@react-native/normalize-color', // CJS deps transitively imported by @gorhom/bottom-sheet and // @gorhom/portal (both excluded above). vite's CJS→ESM interop // only kicks in via pre-bundle, so these must be pre-bundled even // though their importers aren't. 'invariant', 'nanoid/non-secure', ], exclude: optimizeDepsExclude, // vite 8: rolldown replaces esbuild for dep optimization rolldownOptions: { resolve: { conditionNames: ['react-native', 'import'], mainFields: ['react-native', 'module', 'jsnext:main', 'jsnext'], extensions: [ '.ios.tsx', '.ios.ts', '.ios.jsx', '.ios.js', '.native.tsx', '.native.ts', '.native.jsx', '.native.js', '.native.mjs', '.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', ], }, plugins: [ { name: 'sootsim-published-browser-internals', resolveId(source: string, importer: string | undefined) { const replacement = resolvePublishedBrowserInternal( source, importer, publishedBrowserPackages, ) return replacement ? { id: replacement } : null }, }, { name: 'sootsim-stub-images', resolveId(source: string, importer: string | undefined) { if ( /\.(jpg|png|gif)$/.test(source) && importer?.includes('node_modules') ) { return { id: '\0stub-image' } } }, load(id: string) { if (id === '\0stub-image') return 'export default ""' }, }, ], }, }, build: { target: 'esnext' }, oxc: { target: 'esnext' }, server: { fs: { allow: fsAllow }, }, resolve: { dedupe, conditions: ['react-native'], mainFields: ['react-native', 'module', 'jsnext:main', 'jsnext'], extensions: [ '.ios.tsx', '.ios.ts', '.ios.jsx', '.ios.js', '.native.tsx', '.native.ts', '.native.jsx', '.native.js', '.native.mjs', '.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json', ], alias: [ ...sootsimBrowserSourceAliases, ...publishedBrowserEntryAliases, ...Object.entries(rnLibraryStubs).map(([find, replacement]) => ({ find, replacement, })), { find: /^react-native\/Libraries\/.*/, replacement: rnDeepStubDefault }, { find: 'react-native', replacement: rnShimPath }, { find: 'react-native-web', replacement: rnShimPath }, // resolved-path alias for the synthetic specifier the // @react-navigation/native compat wrapper imports from. terminates // the wrapper's own real-package import without re-entering the // alias above. omit the entry if the package isn't installed — // the wrapper file simply won't be loaded in that case. ...(reactNavigationNativeRealPath ? [ { find: '@sootsim-internal/react-navigation-native-real', replacement: reactNavigationNativeRealPath, }, ] : []), ...(reactNativeHapticFeedbackTouchablePath ? [ { find: SOOTSIM_HAPTIC_FEEDBACK_TOUCHABLE_SPECIFIER, replacement: reactNativeHapticFeedbackTouchablePath, }, ] : []), ...compatWrapperRealPackageAliases, ...(expoUiSwiftUiModifiersRealPath ? [ { find: '@expo/ui/swift-ui/modifiers', replacement: expoUiSwiftUiModifiersRealPath, }, ] : []), ...expoUiJetpackComposeRealAliases, ...(rnghReanimatedSwipeableRealPath ? [ { find: /^react-native-gesture-handler\/ReanimatedSwipeable$/, replacement: rnghReanimatedSwipeableRealPath, }, ] : []), ...(rnghDrawerLayoutRealPath ? [ { find: /^(?:react-native-gesture-handler|react-native-gesture-handler-v2)\/DrawerLayout$/, replacement: rnghDrawerLayoutRealPath, }, ] : []), ...stubAliases, ], }, } }, } } // redirect `import ... from 'react'` to react-bridge for sootsim, compat, and // react-reconciler code. this enables runtime React version switching for // external app bundles — bindReact(appReact) swaps which React all code uses. function reactBridgePlugin(): Plugin { let redirectCount = 0 return { name: 'sootsim-react-bridge', apply: 'serve', enforce: 'pre', resolveId: { filter: { id: /^react$/ }, async handler(_source, importer, options) { if (!importer) return null // skip during dep scanning / SSR — only active in browser serve mode if ((options as { scan?: boolean })?.scan || options?.ssr) return null // only redirect imports from sootsim, sootsim-engine, compat, and react-reconciler const shouldRedirect = importer.includes('/sootsim/src/') || importer.includes('/sootsim-engine/src/') || importer.includes('/compat/src/') || importer.includes('/contrast-kit/src/') || importer.includes('react-reconciler') if (!shouldRedirect) return null // don't redirect react-bridge's own import of react (avoid infinite loop) if (importer.includes('react-bridge')) return null // stub-registry needs the real React (with __CLIENT_INTERNALS) for bundle stubs if (importer.includes('stub-registry')) return null redirectCount++ if (redirectCount <= 10) { console.log( '[react-bridge-plugin] REDIRECTING:', importer.split('/').slice(-3).join('/'), ) } else if (redirectCount === 11) { console.log('[react-bridge-plugin] ... more redirects') } return { id: reactBridgePath, external: false } }, }, } } // worker-compatible react-bridge redirect. unlike the main plugin, this one // also applies during builds since workers are built, not served. exported for // use in worker.plugins configuration. export function workerReactBridgePlugin(): Plugin { return { name: 'sootsim-worker-react-bridge', enforce: 'pre', resolveId: { filter: { id: /^react$/ }, async handler(_source, importer, options) { if (!importer) return null // skip during dep scanning / SSR if ((options as { scan?: boolean })?.scan || options?.ssr) return null // don't redirect react-bridge's own import of react (avoid infinite loop) if (importer.includes('react-bridge')) return null // stub-registry needs the real React (with __CLIENT_INTERNALS) for bundle stubs if (importer.includes('stub-registry')) return null // redirect react imports from sootsim, sootsim-engine, compat, react-reconciler, // and any pure-JS RN-ecosystem package the bundle pulls in. without this // redirect, packages like react-native-drawer-layout / use-latest-callback // call into a separate React instance than the renderer/bridge does, so // their useRef/useEffect refs collide with the engine's hooks state and // we hit "Maximum update depth exceeded" infinite-loop renders. const shouldRedirect = importer.includes('/sootsim/src/') || importer.includes('/sootsim-engine/src/') || importer.includes('/compat/src/') || importer.includes('/contrast-kit/src/') || importer.includes('react-reconciler') || importer.includes('/node_modules/') if (!shouldRedirect) return null return { id: reactBridgePath, external: false } }, }, } } // react 19's jsx-runtime.development.js wraps exports.jsx inside a conditional // IIFE, so esbuild's static CJS analysis can't detect the named exports during // pre-bundling. this plugin wraps the real pre-bundled module with explicit exports. // exported for use in worker.plugins configuration. export function fixJsxRuntimeExports(): Plugin { return { name: 'sootsim-fix-jsx-runtime', enforce: 'pre', resolveId: { filter: { id: /^react\/jsx-(?:dev-)?runtime$/ }, async handler(source, importer, options) { const resolved = await this.resolve(source, importer, { ...options, skipSelf: true, }) if (!resolved) return null const prefix = source === 'react/jsx-runtime' ? '\0sootsim:jsx-runtime:' : '\0sootsim:jsx-dev-runtime:' return prefix + resolved.id }, }, load: { filter: { id: new RegExp(`^${virtualIdPrefix}sootsim:jsx-(?:dev-)?runtime:`), }, handler(id) { if (id.startsWith('\0sootsim:jsx-runtime:')) { const realId = id.slice('\0sootsim:jsx-runtime:'.length) return [ `import * as _mod from ${JSON.stringify(realId)};`, `const _m = _mod.default || _mod;`, `export const jsx = _m.jsx || _mod.jsx;`, `export const jsxs = _m.jsxs || _mod.jsxs;`, `export const Fragment = _m.Fragment || _mod.Fragment;`, ].join('\n') } if (id.startsWith('\0sootsim:jsx-dev-runtime:')) { const realId = id.slice('\0sootsim:jsx-dev-runtime:'.length) // react/jsx-runtime is already wrapped by this plugin (above) to expose // { jsx, jsxs, Fragment } as named exports, so read them directly from // the namespace — no `_runtime.default || _runtime` fallback needed. // rolldown statically knows the wrapped module has no default export // and would emit IMPORT_IS_UNDEFINED warnings on every build. return [ `import * as _mod from ${JSON.stringify(realId)};`, `import * as _runtime from "react/jsx-runtime";`, `const _m = _mod.default || _mod;`, // production React builds may not expose jsxDEV from react/jsx-dev-runtime. // fall back to jsx so transformed modules still execute. `export const jsxDEV = _m.jsxDEV || _mod.jsxDEV || _m.jsx || _mod.jsx || _runtime.jsx;`, `export const Fragment = _m.Fragment || _mod.Fragment || _runtime.Fragment;`, ].join('\n') } return null }, }, } } // esbuild plugin for optimizeDeps pre-bundling function sootsimEsbuildPlugin(appDir: string) { const platformExts = [ '.ios.tsx', '.ios.ts', '.ios.jsx', '.ios.js', '.native.tsx', '.native.ts', '.native.jsx', '.native.js', '.native.mjs', ] function isAppSourceEsbuild(filePath: string): boolean { return !!(appDir && filePath.startsWith(appDir)) } return { name: 'sootsim-esbuild', setup(build: any) { // metro-style .ios > .native resolution build.onResolve({ filter: /^\./ }, (args: any) => { if (!args.importer.includes('node_modules') && !isAppSourceEsbuild(args.importer)) return null const dir = path.dirname(args.importer) const resolved = path.resolve(dir, args.path) for (const ext of platformExts) { const candidate = resolved + ext try { if (fs.existsSync(candidate)) return { path: candidate } } catch {} } for (const ext of platformExts) { const candidate = path.join(resolved, 'index' + ext) try { if (fs.existsSync(candidate)) return { path: candidate } } catch {} } const allExts = [...platformExts, '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'] for (const ext of allExts) { if (args.path.endsWith(ext)) { const base = resolved.slice(0, -ext.length) for (const pext of platformExts) { const candidate = base + pext try { if (fs.existsSync(candidate)) return { path: candidate } } catch {} } break } } return null }) // react-native deep path stubs for (const [importPath, stubPath] of Object.entries(rnLibraryStubs)) { build.onResolve( { filter: new RegExp(`^${importPath.replace(/\//g, '\\/')}$`) }, () => ({ path: stubPath, }), ) } build.onResolve({ filter: /^react-native\/Libraries\// }, (args: any) => { return { path: rnLibraryStubs[args.path] || rnDeepStubDefault } }) // resolve builtin stubs (react-native-safe-area-context, expo-*, etc.) for (const [pkgName, stubFile] of Object.entries(builtinStubs)) { const stubPath = path.resolve(compatStubsDir, stubFile) const escaped = pkgName.replace(/[.*+?^${}()|[\]\\/@-]/g, '\\$&') build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({ path: stubPath, })) } // external app node_modules resolution — use esbuild's own resolve // so exports conditions are respected (not CJS require.resolve which ignores them) if (appDir) { const owned = new Set(coreOwnedPackages) build.onResolve({ filter: /^[^.]/ }, async (args: any) => { if (args.pluginData?.fromAppResolve) return null if ( !isAppSourceEsbuild(args.importer) && !args.importer.includes(path.join(appDir, 'node_modules')) ) return null const src = args.path if (src.startsWith('\0')) return null if (owned.has(src) || owned.has(getPackageName(src))) return null if (src === 'react-native' || src.startsWith('react-native/')) return null try { const result = await build.resolve(src, { resolveDir: appDir, pluginData: { fromAppResolve: true }, }) if (result.errors.length) return null return { path: result.path } } catch { return null } }) } // auto-stub missing native packages build.onResolve( { filter: /^(expo-|react-native-|@react-native\/|@react-native-community\/|@expo\/|expo$)/, }, (args: any) => { const pkgName = getPackageName(args.path) if (moduleExistsIn(pkgName, sootsimRoot)) return null if (appDir && moduleExistsIn(pkgName, appDir)) return null return { path: nativeAutoStubPath } }, ) // stub missing image imports build.onResolve({ filter: /\.(jpg|png|gif)$/ }, (args: any) => { if (args.importer.includes('node_modules')) { return { path: args.path, namespace: 'stub-image' } } return null }) build.onLoad({ filter: /.*/, namespace: 'stub-image' }, () => ({ contents: 'export default ""', loader: 'js', })) }, } } // handle .js files containing JSX from node_modules during production builds. // upstream `react-native-reanimated` and downstream consumers ( // `react-native-keyboard-controller`, `react-native-gesture-handler`, // `react-native-screens`, sootsim's own fixtures + RN compat source) ship // raw source containing `'worklet'` directives and inline updaters to // auto-workletizable hooks. the `react-native-worklets/plugin` babel // transform lifts those into wrapper functions carrying `__closure`, // `__workletHash`, and `__initData`. without that transform, // `useAnimatedStyle`'s `let inputs = Object.values(updater.__closure ?? {})` // returns `[]` and the mapper never subscribes — animations don't run. // // metro builds (apps loaded inside sootsim — 3pc, bluesky, expensify, // eigen, …) run the plugin in their own pipeline, so the bundle that // reaches us already has `__closure` baked in. our transform only needs // to act on: // // - sootsim's own src + test fixtures + external-app source, and // - upstream library source loaded from node_modules where the redirect // plugin let pure-JS resolve naturally. // // the keyword list + ignored-path list is ported from // `~/one/packages/compiler/src/transformBabel.ts` (which is itself ported // from reanimated 3.15.1's autoworkletization keywords). keep the two in // sync if upstream adds new auto-workletized hooks. detection is // content-driven, not path-driven, so we don't need a per-package // allow-list — anything that mentions a workletizable identifier and // isn't on the ignored-path list goes through. export function workletsBabelTransform( // accepted for future per-target overrides; matching is content-driven // today so we don't actually consult it. kept for plugin-list parity. _isAppSource: (p: string) => boolean = () => false, ): Plugin { return { name: 'sootsim-worklets-babel-transform', enforce: 'pre', transform: { filter: { id: { include: /\.(?:tsx?|jsx?|mjs|cjs|mts|cts)$/, exclude: /node_modules\/(?:react|react-dom|react-native|react-native-web)\//, }, code: REANIMATED_WORKLETIZATION_REGEX, }, async handler(code, id) { if (!shouldApplyWorkletsPlugin(id, code)) return null try { if (process.env.SOOTSIM_WORKLETS_BABEL_DEBUG) { console.log('[worklets-babel] transforming', id.slice(-80)) } return await transformWorkletsCode(code, id, true) } catch (err) { // surface but don't kill the build — fall back to raw code so the // rest of the pipeline can run. these failures are loud in the // console and easy to chase. const message = err instanceof Error ? err.message : String(err) console.warn(`[sootsim-worklets-babel] transform failed for ${id}: ${message}`) return null } }, }, } } // rolldown can't parse JSX in .js files — transform them first. // needed because some RN packages (e.g. react-native-actions-sheet) ship JSX in .js. // some upstream RN libraries (e.g. @react-native-segmented-control/segmented-control) // publish raw Flow source as their runtime entry. Metro strips Flow in its babel // pipeline; vite/rolldown/oxc do not. detect the `@flow` pragma in node_modules and // run @babel/plugin-transform-flow-strip-types so the wrapper-real seam can pull // in the real upstream JS without re-implementing it locally. mirrored in // scripts/build-bundler-deps.ts so esbuild-based dep prebundles stay in sync. let flowBabel: typeof import('@babel/core') | null = null let flowStripPlugin: import('@babel/core').PluginItem | null = null let jsxSyntaxPlugin: import('@babel/core').PluginItem | null = null function loadFlowBabel(): void { if (flowBabel) return flowBabel = sootsimPluginRequire('@babel/core') flowStripPlugin = sootsimPluginRequire( '@babel/plugin-transform-flow-strip-types', ) as import('@babel/core').PluginItem jsxSyntaxPlugin = sootsimPluginRequire( '@babel/plugin-syntax-jsx', ) as import('@babel/core').PluginItem } function flowStripTransform(): Plugin { return { name: 'sootsim-flow-strip', enforce: 'pre', transform: { filter: { id: /\/node_modules\/.*\.[cm]?jsx?$/, code: /@flow/, }, handler(code, id) { loadFlowBabel() const result = flowBabel!.transformSync(code, { filename: id, babelrc: false, configFile: false, plugins: [flowStripPlugin!, jsxSyntaxPlugin!], sourceMaps: true, compact: false, }) if (!result?.code) return null return { code: result.code, map: result.map ?? null } }, }, } } function nodeModulesJsxTransform(): Plugin { return { name: 'sootsim-node-modules-jsx', enforce: 'pre', transform: { filter: { id: /\/node_modules\/.*\.js$/, code: / .native > base function metroNativeResolve(isAppSource: (p: string) => boolean): Plugin { const platformExts = [ '.ios.tsx', '.ios.ts', '.ios.jsx', '.ios.js', '.native.tsx', '.native.ts', '.native.jsx', '.native.js', '.native.mjs', ] const allExts = [...platformExts, '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'] function tryResolve(base: string): string | null { for (const ext of platformExts) { const candidate = base + ext try { if (fs.existsSync(candidate)) return candidate } catch {} } for (const ext of platformExts) { const candidate = path.join(base, 'index' + ext) try { if (fs.existsSync(candidate)) return candidate } catch {} } return null } return { name: 'sootsim-metro-native-resolve', enforce: 'pre', resolveId: { filter: { id: /^\./ }, handler(source, importer) { if (!importer) return null if (!importer.includes('node_modules') && !isAppSource(importer)) return null if (platformExts.some((ext) => source.endsWith(ext))) return null const dir = path.dirname(importer) const hasBaseExt = allExts.some((ext) => source.endsWith(ext)) if (hasBaseExt) { const resolved = path.resolve(dir, source) for (const ext of allExts) { if (source.endsWith(ext)) { const found = tryResolve(resolved.slice(0, -ext.length)) if (found) return found break } } } else { const found = tryResolve(path.resolve(dir, source)) if (found) return found } return null }, }, } } // convert require("react-native") and deep library requires to ESM imports function reactNativeRequirePlugin(isAppSource: (p: string) => boolean): Plugin { return { name: 'sootsim-react-native-require', enforce: 'pre', transform: { filter: { code: /react-native/ }, handler(code, id) { if (!id.includes('node_modules') && !isAppSource(id)) return null let hasChanges = false let imports = '' let result = code let importCounter = 0 if ( result.includes('require("react-native")') || result.includes("require('react-native')") ) { imports += `import * as __soot_rn_shim__ from ${JSON.stringify(rnShimPath)};\n` result = result.replace(/require\(["']react-native["']\)/g, '__soot_rn_shim__') hasChanges = true } const deepRequireRegex = /require\(["'](react-native\/Libraries\/[^"']+)["']\)/g const replacements: Array<{ full: string; path: string; varName: string }> = [] const seenPaths = new Map() let match deepRequireRegex.lastIndex = 0 while ((match = deepRequireRegex.exec(result)) !== null) { const importPath = match[1] if (!seenPaths.has(importPath)) { const varName = `__soot_rn_lib_${importCounter++}__` const stubPath = rnLibraryStubs[importPath] || rnDeepStubDefault imports += `import * as ${varName} from ${JSON.stringify(stubPath)};\n` seenPaths.set(importPath, varName) } replacements.push({ full: match[0], path: importPath, varName: seenPaths.get(importPath)!, }) } if (replacements.length > 0) { for (const rep of replacements) { result = result.replace(rep.full, rep.varName) } hasChanges = true } if (!hasChanges) return null return { code: imports + result, map: null } }, }, } } // resolve bare imports from external app's node_modules function externalAppResolvePlugin( appDir: string, isAppSource: (p: string) => boolean, ownedPackages: Set, ): Plugin { // synthetic importer inside the app dir so vite searches app's node_modules // using its conditions-aware exports resolution (not CJS require.resolve) const appVirtualImporter = path.join(appDir, '_virtual_.js') return { name: 'sootsim-external-app-resolve', enforce: 'pre', async resolveId(source, importer, options) { if (!importer) return null if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null if (ownedPackages.has(source) || ownedPackages.has(getPackageName(source))) return null if (!isAppSource(importer) && !importer.includes(path.join(appDir, 'node_modules'))) return null if (source === 'react-native' || source.startsWith('react-native/')) return null // re-resolve through vite's pipeline so exports conditions (react-native, import) are respected const resolved = await this.resolve(source, appVirtualImporter, { ...options, skipSelf: true, }) return resolved || null }, } } // convert require() calls and handle JSX in .js files from external app sources function externalAppTransformPlugin(isAppSource: (p: string) => boolean): Plugin { return { name: 'sootsim-external-app-transform', enforce: 'pre', transform: { filter: { code: /require\(| = [] const seenPaths = new Map() let match requireRegex.lastIndex = 0 while ((match = requireRegex.exec(result)) !== null) { const reqPath = match[1] if (reqPath === 'react-native' || reqPath.startsWith('react-native/')) continue if (!seenPaths.has(reqPath)) { const varName = `__soot_cjs_${importCounter++}__` imports += `import * as ${varName} from ${JSON.stringify(reqPath)};\n` seenPaths.set(reqPath, varName) } replacements.push({ full: match[0], varName: seenPaths.get(reqPath)! }) } if (replacements.length > 0) { for (const rep of replacements) { result = result.replace(rep.full, rep.varName) } hasChanges = true } } if (imports) result = imports + result if (id.endsWith('.js') && result.includes('<')) { const needsReact = !result.includes('import React') && !result.includes('import * as React') const withReact = needsReact ? `import React from 'react';\n${result}` : result const transformed = await transformWithOxc(withReact, id, { lang: 'jsx', jsx: { runtime: 'classic' }, }) return { code: transformed.code, map: transformed.map } } if (!hasChanges) return null return { code: result, map: null } }, }, } } // auto-stub native packages that don't exist in any node_modules function stubMissingNativeDeps(appDir: string): Plugin { const checked = new Map() function packageExists(pkgName: string): boolean { const cached = checked.get(pkgName) if (cached !== undefined) return cached let exists = moduleExistsIn(pkgName, sootsimRoot) if (!exists && appDir) exists = moduleExistsIn(pkgName, appDir) checked.set(pkgName, exists) return exists } return { name: 'sootsim-stub-missing-native-deps', resolveId(source) { if (!isNativePackage(source)) return null const pkgName = getPackageName(source) if (packageExists(pkgName)) return null console.log(`[rnx] auto-stubbing missing native dep: ${source}`) return nativeAutoStubPath }, } } // stub missing image imports from node_modules function stubMissingImages(isAppSource: (p: string) => boolean): Plugin { return { name: 'sootsim-stub-images', enforce: 'pre', resolveId: { filter: { id: /\.(?:jpg|png|gif)$/ }, handler(id, importer) { if (importer?.includes('node_modules') || (importer && isAppSource(importer))) { return '\0stub-image' } return null }, }, load: { filter: { id: new RegExp(`^${virtualIdPrefix}stub-image$`) }, handler() { return 'export default ""' }, }, } }