What Happens to Apps Left on the Old Bridge
Since late 2024, React Native has shipped with the New Architecture enabled by default. Expo SDK 52 and later follow the same pattern, making the new architecture standard. Apps still relying on the classic asynchronous Bridge are leaving serious performance gains on the table—and facing a future support cliff.
The three pillars of the New Architecture:
- JSI (JavaScript Interface): JS and Native share memory directly, eliminating the async bridge overhead
- TurboModules: Native modules load lazily, only when needed, improving startup time
- Fabric: A reimagined UI rendering engine that supports synchronous, thread-safe rendering
Migration, however, is non-trivial—especially for apps that carry custom native modules. This guide walks you through a systematic migration using Antigravity, with real code at every step.
Who this guide is for:
- Teams running Expo SDK 50/51 who are ready to upgrade to SDK 52+
- Developers struggling to convert custom native modules to TurboModules
- Lead engineers who want to squeeze every benefit out of Expo Router v3's file-based routing
Before You Migrate: Let Antigravity Map the Terrain
The most common migration mistake is jumping straight into code changes without understanding the blast radius. Antigravity's workspace analysis eliminates that risk.
Analyzing the Whole Project
Prompt Antigravity like this:
@Workspace Analyze this React Native project for New Architecture readiness:
1. List every file that uses NativeModules or NativeEventEmitter directly
2. Identify npm packages that may not support the New Architecture
3. Create a phased migration roadmap ranked by difficulty (low / medium / high)
Antigravity will scan your package.json and source files, returning a structured report:
[Migration Readiness Report]
High complexity (manual effort required):
- src/modules/BleManager.ts → Direct NativeModules.BleManager references
- src/modules/CameraModule.ts → Uses NativeEventEmitter
- android/app/.../CameraModule.java → Legacy Bridge implementation
Medium complexity (library upgrades sufficient):
- react-native-camera: v4.2.0 → bump to v5.x.x for New Arch support
- react-native-bluetooth-classic: No New Arch support; consider alternative
Low complexity (config changes only):
- react-native-async-storage: 1.21+ already supports New Arch
- react-native-vector-icons: 10.x already supports New Arch
Use this report to structure your migration into three distinct phases.
Phase 1: Upgrading to Expo SDK 52 and Enabling the New Architecture
1-1. Updating package.json
Ask Antigravity to generate the exact commands:
# Tell Antigravity: "Generate migration commands for Expo SDK 52"
npx expo install expo@^52.0.0 --fix
npx expo install react-native@0.76.0 --fixThe --fix flag automatically resolves peer dependency conflicts, saving you from hours of manual version juggling.
1-2. Enabling New Architecture in app.json / app.config.ts
Explicitly opt into the New Architecture:
{
"expo": {
"name": "MyApp",
"newArchEnabled": true,
"android": {
"newArchEnabled": true
},
"ios": {
"newArchEnabled": true
}
}
}For app.config.ts users, you can make this a feature flag:
// app.config.ts
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'MyApp',
slug: 'my-app',
newArchEnabled: true,
extra: {
// Toggle via environment variable for staged rollout
newArchEnabled: process.env.NEW_ARCH_ENABLED !== 'false',
},
});Ask Antigravity to "feature-flag this configuration with environment variables" and it will generate the above pattern instantly.
Phase 2: Migrating to TurboModules
TurboModules are the cornerstone of the New Architecture. Here's how to migrate your existing NativeModules-based code into type-safe TurboModules with Antigravity's help.
2-1. Writing the Codegen Schema
TurboModules work by writing TypeScript type definitions that Codegen uses to auto-generate Swift/Kotlin boilerplate. Start with a spec file:
// src/specs/NativeCameraModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
// Request camera permission
requestCameraPermission(): Promise<'granted' | 'denied' | 'blocked'>;
// Take a photo
takePicture(options: {
quality: number; // 0.0 – 1.0
base64: boolean;
exif: boolean;
}): Promise<{
uri: string;
width: number;
height: number;
base64?: string;
exif?: Record<string, unknown>;
}>;
// Set flash mode
setFlashMode(mode: 'on' | 'off' | 'auto' | 'torch'): void;
// Required for event emitter pattern
addListener(eventName: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('CameraModule');Prompt Antigravity: "Generate a TurboModule spec file from my existing CameraModule.ts." It will read your current file and produce the typed spec above—accounting for all existing method signatures.
2-2. Configuring Codegen in package.json
{
"name": "my-camera-module",
"codegenConfig": {
"name": "CameraModuleSpecs",
"type": "modules",
"jsSrcsDir": "./src/specs",
"android": {
"javaPackageName": "com.myapp.camera"
}
}
}2-3. iOS Implementation (Swift)
Conform to the Codegen-generated protocol:
// ios/CameraModule.swift
import Foundation
import AVFoundation
@objc(CameraModule)
class CameraModule: NSObject, NativeCameraModuleSpec {
private var captureSession: AVCaptureSession?
private var photoOutput: AVCapturePhotoOutput?
@objc func requestCameraPermission(
_ resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
resolve("granted")
case .denied, .restricted:
resolve("denied")
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
resolve(granted ? "granted" : "denied")
}
@unknown default:
resolve("denied")
}
}
@objc func takePicture(
_ options: NSDictionary,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
guard let output = photoOutput else {
reject("NO_SESSION", "Camera session not initialized", nil)
return
}
let quality = options["quality"] as? Double ?? 0.8
// Ask Antigravity: "Implement HEIF/JPEG capture with AVFoundation"
// It will generate the full AVCapturePhotoCaptureDelegate implementation
_ = output
_ = quality
resolve(["uri": "file:///tmp/photo.jpg", "width": 1920, "height": 1080])
}
@objc func setFlashMode(_ mode: String) { /* implementation */ }
// Required TurboModule methods
@objc func addListener(_ eventName: String) {}
@objc func removeListeners(_ count: Double) {}
}2-4. Android Implementation (Kotlin)
// android/app/src/main/java/com/myapp/CameraModule.kt
package com.myapp
import com.facebook.react.bridge.*
import com.facebook.react.module.annotations.ReactModule
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
@ReactModule(name = CameraModule.NAME)
class CameraModule(reactContext: ReactApplicationContext) :
NativeCameraModuleSpec(reactContext) {
companion object {
const val NAME = "CameraModule"
}
override fun getName(): String = NAME
override fun requestCameraPermission(promise: Promise) {
val hasPermission = ActivityCompat.checkSelfPermission(
reactApplicationContext,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
promise.resolve("granted")
} else {
// Ask Antigravity: "Integrate react-native-permissions for runtime permission flow"
promise.resolve("denied")
}
}
override fun takePicture(options: ReadableMap, promise: Promise) {
try {
val quality = options.getDouble("quality")
// Ask Antigravity: "Implement high-quality JPEG capture using CameraX API"
val result = WritableNativeMap().apply {
putString("uri", "file:///sdcard/photo.jpg")
putInt("width", 1920)
putInt("height", 1080)
}
promise.resolve(result)
} catch (e: Exception) {
promise.reject("CAPTURE_ERROR", e.message, e)
}
}
override fun setFlashMode(mode: String) { /* implementation */ }
override fun addListener(eventName: String) {}
override fun removeListeners(count: Double) {}
}Phase 3: Unlocking Expo Router v3's Full Potential
Expo Router v3 has evolved well beyond a navigation library—it's now a full-stack framework for universal React apps.
3-1. Server Components and API Routes
app/
├── _layout.tsx ← Root layout
├── (tabs)/
│ ├── _layout.tsx ← Tab layout
│ ├── index.tsx ← Home tab
│ └── profile.tsx ← Profile tab
├── api/
│ └── users+api.ts ← API Route (server-side)
├── modal.tsx ← Modal screen
└── [id].tsx ← Dynamic route
A complete API Route implementation:
// app/api/users+api.ts
import { ExpoRequest, ExpoResponse } from 'expo-router/server';
export async function GET(request: ExpoRequest): Promise<ExpoResponse> {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') ?? '1');
// Fetch from your DB (Supabase, Firebase, Prisma, etc.)
const users = await fetchUsers({ page, limit: 20 });
return ExpoResponse.json({
users,
pagination: { page, total: users.length, hasMore: users.length === 20 },
});
}
export async function POST(request: ExpoRequest): Promise<ExpoResponse> {
const body = await request.json();
if (!body.email || !body.name) {
return ExpoResponse.json(
{ error: 'email and name are required' },
{ status: 400 }
);
}
const user = await createUser(body);
return ExpoResponse.json(user, { status: 201 });
}Ask Antigravity: "Implement a JWT-based auth API using Expo Router API Routes and Supabase Auth." It will wire up the full login/refresh/logout flow in one shot.
3-2. Type-Safe Navigation with Typed Routes
Enable compile-time route validation:
// app.json
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}// tsconfig.json
{
"compilerOptions": {
"types": ["expo-router/types"]
}
}// Now navigation is fully type-checked
import { router, Link } from 'expo-router';
// ✅ Checked at compile time
router.push('/profile');
router.push({ pathname: '/[id]', params: { id: '123' } });
// ❌ Non-existent routes trigger a compile error
// router.push('/nonexistent'); // Type Error!
<Link href="/profile">Profile</Link>
<Link href={{ pathname: '/[id]', params: { id: userId } }}>
User Detail
</Link>3-3. Advanced Tab Layout Configuration
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { useColorScheme, Platform } from 'react-native';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colorScheme === 'dark' ? '#fff' : '#000',
tabBarStyle: Platform.select({
ios: { position: 'absolute' }, // Floating tab bar on iOS
default: {},
}),
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <HomeIcon color={color} />,
}}
/>
<Tabs.Screen name="camera" options={{ title: 'Camera' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
</Tabs>
);
}Antigravity × EAS Build: Automating Your Entire Release Pipeline
4-1. Optimizing eas.json
{
"cli": {
"version": ">= 10.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": { "simulator": true },
"env": { "APP_ENV": "development" }
},
"preview": {
"distribution": "internal",
"channel": "preview",
"env": { "APP_ENV": "staging" }
},
"production": {
"autoIncrement": true,
"channel": "production",
"env": { "APP_ENV": "production" },
"ios": { "resourceClass": "m-medium" },
"android": {
"buildType": "apk",
"resourceClass": "medium"
}
}
},
"submit": {
"production": {
"ios": {
"appleId": "your@apple.com",
"ascAppId": "1234567890",
"appleTeamId": "ABCDEFGHIJ"
},
"android": {
"serviceAccountKeyPath": "./google-services-key.json",
"track": "production"
}
}
}
}4-2. GitHub Actions Integration
Ask Antigravity: "Create a GitHub Actions workflow that integrates EAS Build." Here's what it generates:
# .github/workflows/eas-build.yml
name: EAS Build & Submit
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
name: EAS Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
# Preview build for pull requests
- name: Build (Preview)
if: github.event_name == 'pull_request'
run: eas build --profile preview --platform all --non-interactive
# Production build on merge to main
- name: Build (Production)
if: github.ref == 'refs/heads/main'
run: eas build --profile production --platform all --non-interactive
# Auto-submit to App Store + Google Play
- name: Submit to Stores
if: github.ref == 'refs/heads/main'
run: eas submit --platform all --non-interactive
env:
EXPO_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}4-3. Strategic OTA Updates
// src/hooks/useOtaUpdate.ts
import { useEffect, useState } from 'react';
import * as Updates from 'expo-updates';
import { Alert } from 'react-native';
export function useOtaUpdate() {
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (__DEV__) return;
async function checkForUpdates() {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
setIsUpdateAvailable(true);
await Updates.fetchUpdateAsync(); // Download in background
Alert.alert(
'Update Available',
'A new version is ready. Restart now?',
[
{ text: 'Later', style: 'cancel' },
{
text: 'Restart',
onPress: async () => {
setIsUpdating(true);
await Updates.reloadAsync();
},
},
]
);
}
} catch (error) {
console.warn('OTA update check failed:', error);
}
}
checkForUpdates();
}, []);
return { isUpdateAvailable, isUpdating };
}Deep Dive: Bridging Native Event Emitters with the New Architecture
One of the trickier parts of the migration involves event emitters—modules that push data from native code back to JavaScript asynchronously. In the old architecture, NativeEventEmitter handled this. In the New Architecture, the pattern shifts.
Using EventEmitter in TurboModules
Here's how to correctly implement a native event emitter pattern:
// src/specs/NativeLocationModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
startLocationUpdates(options: {
accuracy: 'low' | 'balanced' | 'high';
distanceFilter: number; // meters
}): void;
stopLocationUpdates(): void;
// Required by EventEmitter pattern in New Architecture
addListener(eventName: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('LocationModule');On the JavaScript side, wrap the module in a class that extends EventEmitter:
// src/modules/LocationModule.ts
import { NativeEventEmitter } from 'react-native';
import NativeLocationModule from '../specs/NativeLocationModule';
const locationEmitter = new NativeEventEmitter(NativeLocationModule);
export const LocationModule = {
startUpdates(options: { accuracy: 'low' | 'balanced' | 'high'; distanceFilter: number }) {
NativeLocationModule.startLocationUpdates(options);
},
stopUpdates() {
NativeLocationModule.stopLocationUpdates();
},
onLocationUpdate(callback: (location: { lat: number; lng: number; accuracy: number }) => void) {
const subscription = locationEmitter.addListener('onLocationUpdate', callback);
return () => subscription.remove(); // cleanup function
},
onError(callback: (error: { code: string; message: string }) => void) {
const subscription = locationEmitter.addListener('onLocationError', callback);
return () => subscription.remove();
},
};Using it in a React component with proper cleanup:
// src/screens/MapScreen.tsx
import { useEffect, useState } from 'react';
import { LocationModule } from '../modules/LocationModule';
interface Location {
lat: number;
lng: number;
accuracy: number;
}
export default function MapScreen() {
const [location, setLocation] = useState<Location | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
LocationModule.startUpdates({
accuracy: 'high',
distanceFilter: 10,
});
// Subscribe to location updates
const unsubscribeLocation = LocationModule.onLocationUpdate((loc) => {
setLocation(loc);
});
// Subscribe to errors
const unsubscribeError = LocationModule.onError((err) => {
setError(err.message);
LocationModule.stopUpdates();
});
// Cleanup on unmount — critical for preventing memory leaks in Fabric
return () => {
LocationModule.stopUpdates();
unsubscribeLocation();
unsubscribeError();
};
}, []);
return (
// ... render map with location
null
);
}Ask Antigravity: "Convert my NativeEventEmitter-based module to the TurboModule EventEmitter pattern, including React hook integration." It will migrate both the spec file and the consuming component in one pass.
Advanced Pattern: Synchronous Native Calls with JSI
One of JSI's most powerful—and underutilized—capabilities is synchronous communication between JavaScript and native. This eliminates the round-trip latency that even async TurboModules still carry.
When to use synchronous JSI calls:
- Reading device sensors where even 1ms async delay causes jitter
- Accessing a local SQLite database for high-frequency reads
- Implementing worklet-like computations (similar to Reanimated's approach)
// src/specs/NativeSensorModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
// Synchronous read — returns immediately from native
getAccelerometerDataSync(): {
x: number;
y: number;
z: number;
timestamp: number;
};
// Configure sampling rate
setSamplingRate(hz: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('SensorModule');On iOS, synchronous methods require the @objc decorator with no callback parameters:
// ios/SensorModule.swift
@objc(SensorModule)
class SensorModule: NSObject, NativeSensorModuleSpec {
private var motionManager = CMMotionManager()
@objc func getAccelerometerDataSync() -> NSDictionary {
// This runs synchronously on the JS thread via JSI
guard let data = motionManager.accelerometerData else {
return ["x": 0, "y": 0, "z": 0, "timestamp": Date().timeIntervalSince1970]
}
return [
"x": data.acceleration.x,
"y": data.acceleration.y,
"z": data.acceleration.z,
"timestamp": Date().timeIntervalSince1970 * 1000,
]
}
@objc func setSamplingRate(_ hz: Double) {
motionManager.accelerometerUpdateInterval = 1.0 / hz
if !motionManager.isAccelerometerActive {
motionManager.startAccelerometerUpdates()
}
}
}Prompt Antigravity: "Implement a synchronous JSI module for high-frequency sensor reading in both Swift and Kotlin." It will produce both native implementations and the TypeScript spec simultaneously.
Common Errors and How Antigravity Helps You Fix Them
Error 1: "TurboModule not found"
Error: Cannot find native module 'CameraModule'
Cause: Codegen didn't run, or the native build is stale.
Prompt Antigravity:
Analyze the "TurboModule not found" error. Check Podfile, build.gradle,
and codegenConfig in package.json for root causes and suggest fixes.
Typical fix:
cd ios && pod install --repo-update && cd ..
cd android && ./gradlew clean && cd ..
npx expo prebuild --cleanError 2: Fabric Rendering Differences
After enabling the New Architecture, FlatList and ScrollView may behave differently.
// Old approach — can be problematic with Fabric
<FlatList
data={items}
renderItem={({ item }) => <ItemComponent item={item} />}
onEndReachedThreshold={0.5}
onEndReached={loadMore}
/>
// New Architecture — optimized approach
// @shopify/flash-list is natively optimized for Fabric
<FlashList
data={items}
renderItem={({ item }) => <ItemComponent item={item} />}
estimatedItemSize={80} // Required: helps the layout engine optimize
onEndReachedThreshold={0.5}
onEndReached={loadMore}
/>Ask Antigravity: "Migrate all FlatList usages to FlashList including type definitions." It will scan your entire codebase and perform the refactor.
Error 3: Metro Bundler Configuration
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
const config = getDefaultConfig(__dirname);
// Enable package exports for New Architecture packages
config.resolver.unstable_enablePackageExports = true;
// Monorepo symlink support
config.resolver.nodeModulesPaths = [
path.resolve(__dirname, 'node_modules'),
];
module.exports = config;Measuring the Impact: Before vs. After
Pair Antigravity with Flashlight for concrete performance measurements:
npm install -g @perf-tools/flashlight
flashlight measure --app com.myapp --duration 30Representative results from production migrations:
- App startup time: 2.8s → 1.9s (~32% faster, thanks to TurboModules lazy loading)
- Scroll FPS: avg 52fps → 58fps (Fabric's synchronous rendering)
- Time to first render: 340ms → 220ms
- Memory usage: 245MB → 198MB (~19% reduction)
Prompt Antigravity: "Analyze this Flashlight JSON output and list bottlenecks by priority." It will parse the profiling data and return a ranked optimization plan you can act on immediately.
Testing Strategy: Keeping Quality High Through Migration
New Architecture migration without a solid test strategy is how bugs slip into production. Here's the testing approach Antigravity recommends:
Unit Testing TurboModules with Jest
// __tests__/CameraModule.test.ts
import NativeCameraModule from '../src/specs/NativeCameraModule';
// Mock the TurboModule for unit tests
jest.mock('../src/specs/NativeCameraModule', () => ({
requestCameraPermission: jest.fn(),
takePicture: jest.fn(),
setFlashMode: jest.fn(),
addListener: jest.fn(),
removeListeners: jest.fn(),
}));
describe('CameraModule', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('requests camera permission and resolves with status', async () => {
(NativeCameraModule.requestCameraPermission as jest.Mock).mockResolvedValue('granted');
const result = await NativeCameraModule.requestCameraPermission();
expect(result).toBe('granted');
expect(NativeCameraModule.requestCameraPermission).toHaveBeenCalledTimes(1);
});
it('takes a picture with correct options', async () => {
const mockResult = {
uri: 'file:///tmp/photo.jpg',
width: 1920,
height: 1080,
};
(NativeCameraModule.takePicture as jest.Mock).mockResolvedValue(mockResult);
const options = { quality: 0.8, base64: false, exif: false };
const result = await NativeCameraModule.takePicture(options);
expect(result.uri).toBe('file:///tmp/photo.jpg');
expect(result.width).toBe(1920);
expect(NativeCameraModule.takePicture).toHaveBeenCalledWith(options);
});
});Ask Antigravity: "Generate comprehensive Jest tests for all my TurboModule specs, including error cases and edge conditions." It will read your spec files and generate full test suites.
Integration Testing with Detox
For end-to-end tests that run on real devices (or simulators), Detox pairs well with the New Architecture:
// e2e/camera.test.ts
import { by, device, element, expect as detoxExpect } from 'detox';
describe('Camera Feature', () => {
beforeAll(async () => {
await device.launchApp({ permissions: { camera: 'YES' } });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it('should open camera and take a photo', async () => {
// Navigate to camera screen
await element(by.id('camera-tab')).tap();
await detoxExpect(element(by.id('camera-preview'))).toBeVisible();
// Tap shutter button
await element(by.id('shutter-button')).tap();
// Verify photo was captured
await detoxExpect(element(by.id('photo-preview'))).toBeVisible();
await detoxExpect(element(by.id('save-button'))).toBeVisible();
});
it('should handle camera permission denial gracefully', async () => {
await device.launchApp({ permissions: { camera: 'NO' } });
await element(by.id('camera-tab')).tap();
// Should show permission request UI
await detoxExpect(element(by.id('permission-request-screen'))).toBeVisible();
await detoxExpect(element(by.id('open-settings-button'))).toBeVisible();
});
});Configure Detox in .detoxrc.js:
// .detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: { '$0': 'jest', config: 'e2e/jest.config.js' },
jest: { setupTimeout: 120000 },
},
apps: {
'ios.sim.debug': {
type: 'ios.simulator',
binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.emu.debug': {
type: 'android.emulator',
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
},
},
devices: {
simulator: {
type: 'ios.simulator',
device: { type: 'iPhone 16 Pro' },
},
emulator: {
type: 'android.emulator',
device: { avdName: 'Pixel_7_API_34' },
},
},
configurations: {
'ios.sim.debug': { device: 'simulator', app: 'ios.sim.debug' },
'android.emu.debug': { device: 'emulator', app: 'android.emu.debug' },
},
};Running the Full Test Suite in CI
Add test steps to your GitHub Actions workflow:
# Add to .github/workflows/eas-build.yml
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage --ci
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}Prompt Antigravity: "Set up a full test pipeline integrating Jest unit tests, Detox e2e tests, and EAS Build. Include coverage thresholds and failure conditions." It will generate the complete multi-stage workflow.
Summary
Migrating to React Native's New Architecture is one of the highest-leverage investments you can make in your mobile app's long-term health. The path is clearer than it might seem when you break it into structured phases:
Phase 1 — Assess and upgrade: Let Antigravity map your entire codebase for New Architecture compatibility. This one-prompt analysis saves days of manual audit work and gives you a realistic migration roadmap before you change a single line of code.
Phase 2 — TurboModule migration: Codegen eliminates the tedium of writing native boilerplate. With Antigravity writing the Swift and Kotlin implementations from your TypeScript spec, you can migrate a non-trivial native module in hours rather than days. The JSI synchronous call pattern opens up performance possibilities that were simply not available in the old Bridge architecture.
Phase 3 — Expo Router v3 features: Server Components, API Routes, and Typed Routes transform Expo from a mobile framework into a universal, full-stack platform. Teams that adopt these features early gain significant advantages in code sharing, type safety, and developer experience.
Phase 4 — CI/CD automation: EAS Build integrated with GitHub Actions creates a release pipeline where a merged PR can result in a production build submitted to both stores—without manual intervention. OTA updates handle hotfixes without requiring full store review cycles.
Phase 5 — Testing confidence: A robust test suite covering TurboModules with Jest and full user flows with Detox ensures the migration doesn't introduce regressions. Antigravity can generate the scaffolding for your entire test layer from your module specs.
The common thread across all phases is that Antigravity handles the repetitive, error-prone work—native boilerplate, cross-platform consistency, workflow configuration—so you can stay focused on the parts of your app that genuinely require your expertise.
A practical recommendation: don't attempt a "big bang" migration. Instead, pick the simplest self-contained native module in your app, walk through the full TurboModules workflow with Antigravity, and ship it. That first successful migration builds team confidence and validates your toolchain. From there, each subsequent module goes faster.
For more on building with Expo and Antigravity, see our guide on Expo × Antigravity Cross-Platform Development. For automating your App Store and Google Play releases, Antigravity × Fastlane Deployment Automation is essential reading.