ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-04-08Advanced

Antigravity × Expo Router v3 — Migrating to React Native's New Architecture: TurboModules, Fabric, and JSI

A hands-on guide to migrating your Expo app to React Native's New Architecture—TurboModules, Fabric, and JSI—using Antigravity. Covers type-safe native modules, Expo Router v3, and full EAS Build CI/CD automation.

antigravity436exporeact-nativenew-architectureturbomodulesfabricmobile2EAS2

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 --fix

The --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 --clean

Error 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 30

Representative 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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-07-18
After Compose-First: Choosing Which View Screens to Migrate, Ranked by Churn Instead of Count
Google has declared Android development Compose-first. Here is how I rank View-based screens for migration using git history rather than screen counts, with the scoring script I actually ran and the three places partial migration quietly duplicates state.
App Dev2026-07-13
The Gate That Stops Visual Damage Before You Hand Bulk Image Optimization to an Agent
When an agent bulk re-encoded a few hundred wallpaper assets, a handful came back with dulled color. Size-reduction alone cannot catch that. Here is how to design a gate that stops bad conversions before merge using three axes — SSIM, ΔE, and file size — with a checker that runs on Pillow and scikit-image.
App Dev2026-07-09
Deciding When to Stop a Staged Rollout, Before You Have To — Agents Watch, I Halt
Field notes on building a Google Play staged-rollout watcher with Antigravity. Crash rate as a ratio to baseline, delayed ANR evaluation, and an explicit insufficient_data verdict — with the halt action kept in human hands.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →