Mobile Development Bug Report

React Native New Architecture: legacy native module is null after upgrade

After upgrading to React Native 0.79 with the New Architecture on by default, our in-house printer module came back null on iOS and crashed on Android. Bridge-only registration and direct bridge calls were the cause; here is the migration path.

Daniel Tran Daniel Tran @fun_dev · June 24, 2025 · 8 min read · 11 Views
Banner: React Native New Architecture with a legacy native module returning null

Our point-of-sale app for small cafés talks to Bluetooth receipt printers through an in-house native module, ReceiptPrinter, written in Objective-C and Kotlin back in 2021. We upgraded from React Native 0.73 to 0.79 in one jump, partly to get off an unsupported version and partly because the New Architecture, enabled by default since 0.76, promised faster startup.

The app launched, navigation worked, and the first test print failed. On iOS, the JavaScript wrapper threw our own guard error:

Error: ReceiptPrinter native module is not available. Did you rebuild the app?
    at getPrinter (src/native/printer.ts:12:11)

NativeModules.ReceiptPrinter was null. On Android the module existed, but the first status callback from the printer crashed the app with an exception from inside our module, where it called reactContext.getCatalystInstance() to emit an event.

Reproducing it

  1. Build the app with React Native 0.79 and the default newArchEnabled=true (Android) and RCT_NEW_ARCH_ENABLED=1 (iOS, the default for pod install).
  2. iOS: open the printer screen. The module is null immediately.
  3. Android: connect to a printer, or call the module's simulateStatus() debug method. The app crashes when the module emits its first event.
  4. Rebuild with the New Architecture disabled and both problems disappear.

What we ruled out

  • A missing rebuild. The classic cause of a null module is a JS-only reload after adding native code. We did clean builds, pod install, and ./gradlew clean.
  • Module name mismatch. RCT_EXPORT_MODULE(ReceiptPrinter) and Kotlin's getName() both returned ReceiptPrinter, and the same names worked in the old architecture build.
  • The interop layer being off. Since 0.74 the interop layer for legacy modules is automatic, and our other legacy module, a simple haptics helper, worked fine under the New Architecture.

Root cause

Our two failures came from code that depended on the bridge itself, which does not exist in bridgeless mode:

  • iOS: the module needed a configuration object at init, so our AppDelegate created it manually and handed it to React Native through extraModulesForBridge:, an RCTBridgeDelegate method. In bridgeless mode there is no bridge and no bridge delegate, so that method is never called and the module never got registered.
  • Android: the module emitted events with reactContext.getCatalystInstance().getJSModule(...). The catalyst instance is a bridge concept; the React Native working group's bridgeless notes list direct getCatalystInstance() access as unsupported and recommend calling the equivalent methods on ReactContext.

What is happening under the hood

The New Architecture replaces the asynchronous JSON bridge with JSI: JavaScript holds direct references to C++ host objects. Native modules become TurboModules, which are loaded lazily on first access and typed by a codegen spec. "Bridgeless mode" is the final step, where the bridge object is gone entirely.

To make migration possible, React Native ships an interop layer that exposes legacy modules, the ones using RCT_EXPORT_MODULE or ReactContextBaseJavaModule, as if they were TurboModules. It covers the common cases well: exported methods, promises, callbacks and constants. It cannot cover code that reaches past the module API into the bridge:

Legacy pattern Under the New Architecture
RCT_EXPORT_MODULE() with default init Works through interop
Module instance created in extraModulesForBridge: Never registered, JS sees null
self.bridge on iOS A bridge proxy object that supports most, not all, legacy calls
reactContext.getCatalystInstance() on Android Not supported in bridgeless mode
reactContext.getJSModule(RCTDeviceEventEmitter::class.java) Works

Environment

Component Version / value
React Native 0.73.6 before, 0.79.3 after (New Architecture on)
React 19.0.0
JS engine Hermes (bundled with React Native)
iOS Xcode 16.3, iOS 18.5 devices, CocoaPods 1.16.2
Android AGP 8.8, Kotlin 2.0.21, targetSdk 35
Module ReceiptPrinter, Objective-C on iOS, Kotlin on Android, no codegen spec

Minimal reproduction

// AppDelegate.mm (excerpt, old architecture pattern)
- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge
{
  PrinterConfig *config = [PrinterConfig configWithVendorKey:@"pk_test_xxx"];
  return @[ [[ReceiptPrinter alloc] initWithConfig:config] ];
}
// ReceiptPrinterModule.kt (excerpt)
private fun emitStatus(status: WritableMap) {
    reactApplicationContext.catalystInstance
        .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
        .emit("printerStatus", status)
}

Who is affected

Apps upgrading to React Native 0.76 or newer with the New Architecture enabled that contain in-house or older third-party native modules with custom registration, direct bridge access, or bridge-specific APIs. Well-maintained libraries have mostly migrated; check the React Native Directory's New Architecture flag for each one before upgrading.

We took a two-step path: first make the legacy module work through the interop layer by removing bridge dependencies, then convert it to a typed TurboModule in a later release.

The fix

iOS: let React Native create the module, and inject configuration differently. We removed the extraModulesForBridge: override and moved the configuration to a small shared store that the app fills before React Native starts:

// PrinterConfigStore.h
@interface PrinterConfigStore : NSObject
+ (void)setSharedConfig:(PrinterConfig *)config;
+ (PrinterConfig *)sharedConfig;
@end
// ReceiptPrinter.mm
@implementation ReceiptPrinter

RCT_EXPORT_MODULE(ReceiptPrinter)

- (instancetype)init
{
  if (self = [super init]) {
    _config = [PrinterConfigStore sharedConfig];
    NSAssert(_config != nil, @"PrinterConfigStore must be set before React Native starts");
  }
  return self;
}

+ (BOOL)requiresMainQueueSetup { return NO; }

@end
// AppDelegate.mm, before the React Native factory starts
[PrinterConfigStore setSharedConfig:[PrinterConfig configWithVendorKey:vendorKeyFromKeychain()]];

React Native now instantiates the module itself through the interop layer, on first access, and the module finds its configuration ready.

Android: emit events through ReactContext, not the catalyst instance.

  private fun emitStatus(status: WritableMap) {
-     reactApplicationContext.catalystInstance
-         .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
-         .emit("printerStatus", status)
+     reactApplicationContext
+         .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+         ?.emit("printerStatus", status)
  }

We also added addListener and removeListeners methods to the Android module, which NativeEventEmitter expects, to silence its warnings.

Next release: a real TurboModule. We added a codegen spec so the module is typed and loaded directly, without the interop layer:

// src/native/NativeReceiptPrinter.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  connect(address: string): Promise<boolean>;
  print(jobJson: string): Promise<string>;
  addListener(eventName: string): void;
  removeListeners(count: number): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('ReceiptPrinter');

getEnforcing throws a clear error at import time if the module is missing, instead of returning null and failing later at the call site.

Alternatives and trade-offs

  • Disable the New Architecture temporarily. Setting newArchEnabled=false in gradle.properties and installing pods with RCT_NEW_ARCH_ENABLED=0 restored the old behaviour in 0.79. We used it for one internal build while fixing, but the legacy architecture is frozen, and new library versions increasingly require the new one.
  • Keep the module legacy indefinitely. The interop layer is a migration tool, not a permanent home. Converting a small module with a spec is a day of work.
  • Rewrite the module as an Expo Module. A good option for teams already on Expo's tooling; we are not, so we stayed with codegen.

How to test the fix

We added a native module smoke test to our Detox suite that runs on both platforms, with the New Architecture on, against a mocked printer transport:

// e2e/printer.e2e.ts
it('connects and reports status events', async () => {
  await element(by.id('printer-settings')).tap();
  await element(by.id('connect-mock-printer')).tap();
  await expect(element(by.id('printer-status'))).toHaveText('ready');
});
npx detox build -c ios.sim.release && npx detox test -c ios.sim.release e2e/printer.e2e.ts
npx detox build -c android.emu.release && npx detox test -c android.emu.release e2e/printer.e2e.ts

We also checked at runtime that the app was really bridgeless, by logging global.RN$Bridgeless === true at startup in debug builds.

Confirming in production

  • We rolled out to 20 cafés first. Print success rate, which we log per job, stayed at 99.4 percent, identical to the previous version.
  • No crashes from ReceiptPrinterModule on Android in Crashlytics during the staged rollout or after 100 percent.
  • Cold start time on a mid-range Android tablet fell from 2.9 s to 2.4 s at the median, part of the reason for the upgrade.

Regression guard

  • The Detox printer test runs on every pull request for both platforms with the New Architecture on.
  • A lint rule bans catalystInstance and getCatalystInstance in Kotlin sources, and a grep in CI fails if extraModulesForBridge reappears in iOS code.
  • New native modules must start with a codegen spec; legacy modules need an architecture review.

Security implications

The old code passed a vendor key into the module from AppDelegate, which tempted earlier developers to hard-code it. As part of the fix the key now comes from the Keychain at startup and only lives in the config store in memory. Interop and TurboModules do not change the permission model: any module reachable from JavaScript is callable by any JavaScript in the bundle, so keep native methods narrow and validate their inputs, especially for modules that talk to hardware.

Prevention checklist

  • Before upgrading React Native, audit native modules for bridge access, custom registration and bridge delegate methods.
  • Use TurboModuleRegistry.getEnforcing so missing modules fail loudly at startup.
  • Write codegen specs for in-house modules instead of relying on the interop layer.
  • Test native modules end to end with the New Architecture on, on both platforms.
  • Check each third-party library's New Architecture support before the upgrade.

References

Add a label

#ios #android #react-native #new-architecture #turbomodules
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.