Yesterday I spent almost the whole day, and part of the night into the early morning, working on the Aether SDK plus some fixes across the backend, the CLI, the landing page and the Dashboard. Somewhere in the middle of all that, I ran into this.
Aether is my OTA update tool for React Native, and the client SDK is a fork of CodePush. It ships JavaScript and asset changes to apps that are already installed, without going through the app store. The app asks the server if there is a newer bundle for its deployment key and binary version, downloads it, installs it, and restarts onto the new bundle. If that bundle fails to start, the next launch rolls back to the previous one.

The SDK worked. What it did badly was reach the New Architecture. It got there by reflecting on React Native’s private internals instead of calling supported APIs, and that turned every React Native release into a chance for a silent break. So yesterday I finally sat down to remove that, which in practice meant turning the native module into a TurboModule on both platforms. Android and iOS each had one wall that ate most of the time. Neither wall was where I expected it.
The reflection tax
Before this work, the module reached New Architecture behavior by reading React Native’s private fields. The clearest example was the restart path on Android. To point a running app at a new bundle, the old code grabbed the host’s delegate by field name, overwrote its bundle loader, and asked the host to reload.
// Roughly what the old restart path did on Android.Field delegateField = reactHost.getClass() .getDeclaredField("reactHostDelegate");delegateField.setAccessible(true);Object delegate = delegateField.get(reactHost);// ...overwrite the delegate's jsBundleLoader, then reactHost.reload()
React Native 0.76 renamed that field from reactHostDelegate to mReactHostDelegate. The lookup threw NoSuchFieldException, the code swallowed it, and reload() ran with the old loader. So the update downloaded, installed, reported success, and then kept running the old bundle anyway. A field rename in a minor React Native release broke a core feature, and nothing failed loudly. That is the worst kind of bug. The one that looks green.
That is the pattern I wanted gone. If the SDK depends on the exact shape of React Native’s internals, every release is a coin flip.
Commit: f20e2e5 (#4). Probe both mReactHostDelegate and reactHostDelegate so restartApp survives the rename across React Native versions.
Freezing a typed contract
The first piece is a Codegen spec. It is a TypeScript interface that describes the native surface, and it becomes the contract both sides agree on.
// src/specs/NativeCodePush.tsexport interface Spec extends TurboModule { getConstants(): { codePushInstallModeImmediate: number; // ...the install-mode and update-state enums }; restartApp(onlyIfUpdateIsPending: boolean): Promise<void>; downloadUpdate(updatePackage: Object, notifyProgress: boolean): Promise<Object>; installUpdate(updatePackage: Object, installMode: number, minimumBackgroundDuration: number): Promise<void>; getUpdateMetadata(updateState: number): Promise<Object | null>; notifyApplicationReady(): Promise<Object>; // ...the rest of the surface}export default TurboModuleRegistry.getEnforcing<Spec>('CodePush');
Codegen reads this and generates the native interfaces on each platform. From here on, a field rename inside React Native can still break the small shim that reaches for it, but it can no longer change the shape of the public API. The compiler checks the method names and types now, instead of the bridge matching them by name at runtime.
One detail shapes the native code more than you would think. Codegen maps a TypeScript number to double. So the native methods take double where the old bridge module took int or an enum, and they cast back inside the method body. This comes back to bite on both platforms.
On the JavaScript side, the module resolves through the TurboModule registry and falls back to the bridge, so the same code runs whether the app is on the New Architecture or not.
const NativeCodePush = TurboModuleRegistry.get("CodePush") || NativeModules.CodePush;
That fallback is the whole compatibility story from JavaScript. The hard part was making both native sides real.
Commit: 0132e4c (#9). The Codegen spec: 19 methods and 7 constants mirrored from the bridge module, dormant until the native side implements it.
Android, wall one: the autolinking that would not link
The Java side converted cleanly. The module became a TurboModule, the package advertised it, everything compiled. Then at runtime TurboModuleRegistry.get("CodePush") returned null and the app red-screened.

Logging showed the Java side working fine. The package’s module provider fired, getModule("CodePush") fired, so the Java TurboModule was being created. What was missing was the C++ JSI provider that exposes it to JavaScript. The generated C++ provider returned nullptr for every name.
The cause was one field. In the app’s generated autolinking.json, the SDK’s Android entry had libraryName: null. React Native’s CLI derives libraryName from a library’s codegenConfig, but it skips that when the library also supplies a custom packageInstance. And the SDK needed a custom packageInstance, because CodePush could not be built with a no-arg constructor. It took the deployment key at construction time.
So a custom packageInstance and codegen autolinking were mutually exclusive in the CLI. Setting libraryName by hand did not help either, because the CLI drops it.
There was a second, quieter trap in the same area. React Native’s CLI finds the package class with a regular expression that only matches implements ReactPackage or extends TurboReactPackage. My package extended BaseReactPackage, which matches neither, so the CLI could not find it at all. The fix was a redundant but load-bearing line.
// `implements ReactPackage` is redundant here (BaseReactPackage already// implements it), but the CLI's class detection regex only matches this// or `extends TurboReactPackage`.public class CodePush extends BaseReactPackage implements ReactPackage {
The real fix for the libraryName problem was to make CodePush auto-instantiable, so the custom packageInstance could go away and libraryName would derive normally. That meant giving the package a no-arg constructor and moving the context-dependent setup, including reading the deployment key, into a lazy step that runs the first time the module is requested.
private synchronized void ensureInitialized(Context context) { if (mUpdateManager != null) return; mContext = context.getApplicationContext(); String deploymentKey = getCustomPropertyFromStringsIfExist("DeploymentKey"); boolean isDebugMode = (mContext.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; initialize(deploymentKey, mContext, isDebugMode);}@Overridepublic NativeModule getModule(String name, ReactApplicationContext reactContext) { if ("CodePush".equals(name)) { ensureInitialized(reactContext); return new CodePushNativeModule(reactContext, this, mUpdateManager, ...); } // ...}
Reading the deployment key from strings.xml inside ensureInitialized is the move that unlocks everything. The module no longer needs the key handed to its constructor, so the config can point autolinking at a plain new CodePush(getApplicationContext()), libraryName derives from codegenConfig, and the C++ provider finally gets emitted.
// react-native.config.jspackageImportPath: 'import com.microsoft.codepush.react.CodePush;',packageInstance: 'new CodePush(getApplicationContext())',
The FLAG_DEBUGGABLE check above is there for a reason too. The package cannot see the host app’s generated BuildConfig, so it cannot read BuildConfig.DEBUG. It reads the debuggable flag off the application info instead.
One last thing cost an hour by itself. Gradle caches the CLI’s autolinking config. I edited react-native.config.js, rebuilt, and nothing changed, because the old config was still cached. It only picked up the change after I cleared the caches and forced a rerun.
rm -rf node_modules/.cache android/build/generated/autolinking./gradlew --stop./gradlew ... --rerun-tasks
Commits: c68e651 (#10), the autolinking root cause written down; 0be825a (#11), CodePush as a working TurboModule with the BaseReactPackage and packageInstance fixes.
Android, wall two: one package, two architectures
The New Architecture module extends the generated spec. That class only exists when Codegen runs, which only happens under the New Architecture. So a consumer who opted out would not have the generated class at all, and a single module class could not serve both worlds.
The answer was to split by source set. The shared logic lives in one plain class. Two thin subclasses adapt it: one extends the generated spec, the other extends the classic bridge base. Gradle picks the directory that matches the build.
sourceSets { main { if (IS_NEW_ARCHITECTURE_ENABLED) { java.srcDirs += ['src/newarch/java'] } else { java.srcDirs += ['src/oldarch/java'] } }}
Each build compiles exactly one of them. The old architecture keeps its @ReactMethod class, the new one gets the typed spec, and neither knows the other exists.
Commit: 60a0d85 (#14). Shared CodePushNativeModuleImpl plus the two newarch/oldarch subclasses selected by sourceSets.
iOS, wall one: getting it to compile
iOS started easier and then bit harder. The reload path was already on a supported API, so restart worked untouched. The first job was just to expose the module through JSI by implementing getTurboModule.
#ifdef RCT_NEW_ARCH_ENABLED- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params{ return std::make_shared<facebook::react::NativeCodePushSpecJSI>(params);}#endif
NativeCodePushSpecJSI is generated, and its header is Objective-C++. It can only be imported from a .mm file. So CodePush.m became CodePush.mm, and the protocol conformance moved into a class extension inside the .mm.
That rename produced a linker error. CPLog is defined in a plain C file, CodePushUtils.m. Once CodePush.mm compiled as Objective-C++, the C++ compiler mangled the symbol name and the linker could not find the plain C symbol. The fix is to declare it with C linkage in the header.
#ifdef __cplusplusextern "C" {#endifvoid CPLog(NSString *formatString, ...);#ifdef __cplusplus}#endif
It built. It ran. It crashed on launch.
iOS, wall two: two crashes, read from the device
The app aborted at startup with a SIGABRT inside ObjCTurboModule::performVoidMethodInvocation. The crash report’s exception fields came back empty, so the .ips file was useless. The real message was in the unified log. Streaming the process log gave it in plain text.
Error: CodePush.getConstants raised an exception:-[CodePush getConstants]: unrecognized selector sent to instance
The spec declares getConstants(). The generated JSI binding turns that into a direct call to an Objective-C selector.
// generatedreturn static_cast<ObjCTurboModule&>(turboModule) .invokeObjCMethod(rt, ObjectKind, "getConstants", @selector(getConstants), args, count);
The bridge module never had a getConstants. It exported constants through constantsToExport, which the bridge calls for you. The TurboModule invokes getConstants on the instance directly, the class did not respond to that selector, and because this happened while JavaScript was first requiring the module, the whole bundle evaluation aborted. The fix is tiny once you see it.
- (NSDictionary *)getConstants { return [self constantsToExport];}
Then a second crash of the same shape.
'NSInvalidArgumentException', reason:'-[CodePush notifyApplicationReady:reject:]: unrecognized selector sent to instance'
This one is a label mismatch. The classic bridge lets you name your promise blocks whatever you want, and the module used the CodePush convention of resolver: and rejecter:. The generated JSI binding expects resolve: and reject:. The bridge never cared about the labels, because it matches on the JavaScript method name and finds the promise blocks by type. The TurboModule invokes a specific selector, so the labels have to be exact.
The fix was to rename every promise method’s labels to match the generated selectors, and to widen the enum arguments to double with a cast back, the same number to double mapping from earlier.
// beforeRCT_EXPORT_METHOD(getUpdateMetadata:(CodePushUpdateState)updateState resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)// afterRCT_EXPORT_METHOD(getUpdateMetadata:(double)updateState resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
Renaming the labels does not break the bridge build, because bridge dispatch keys off the JavaScript method name, not the selector labels. And every TurboModule addition sits behind RCT_NEW_ARCH_ENABLED, so the old build never compiles the JSI code at all.
Commit: 4999bf5 (#15). The whole iOS TurboModule: getTurboModule, the .mm rename, CPLog C linkage, getConstants, and the selector labels.
Proving it end to end
A build that renders is not proof that OTA updates work. The real check is the full cycle against a live server: check, download, install, restart, and confirm the app is running the downloaded bundle instead of the one compiled into the binary.
On Android I drove it by hand. On iOS the simulator has no tap automation, so I set the app to sync on launch with an immediate install, which exercises the same native methods, including restartApp, the one that broke in the old architecture. After the sync, the SDK’s own storage told the story. Its state file pointed at the downloaded package.
{"currentPackage":"c3da7853b89b797d486984c6b33af9c6b174be5007489b63667e02eb6b901d9f"}
And a cold relaunch loaded the bundle from that package, not from the app binary.
[CodePush] Loading JS bundle from .../Library/Application Support/CodePush/c3da7853.../Aether/main.jsbundle

Before the update, that same line pointed at AetherSmoke.app/main.jsbundle. The path change is the proof. The app is running downloaded JavaScript, and the TurboModule ran the whole cycle without crashing.
What I would tell myself before starting
A few things would have saved me hours.
Most of these problems came down to a single field or a single label. Every time it looked like a design flaw, and it never was. The whole Android autolinking wall traced back to libraryName: null, which traced back to passing the deployment key through the constructor. Chasing it through C++ providers and JSI felt deep and architectural, and the fix was to stop doing one small thing.
Read the device, not the crash report. The iOS .ips file had empty exception fields both times. The unified log had the exact selector name in plain text. The answer was sitting there the whole time, in the wrong file.
Selector labels are load-bearing under TurboModules and cosmetic under the bridge. That single asymmetry caused the second iOS crash, and it is easy to miss, because the same code that runs fine on the old architecture aborts on the new one.
Keep the old path alive behind a compile flag. Guarding every New Architecture addition behind IS_NEW_ARCHITECTURE_ENABLED and RCT_NEW_ARCH_ENABLED, plus the Android source set split, means one package serves both worlds. Someone who has not migrated yet still builds and runs. For a library that wants adoption across a wide range of React Native versions, that matters more than a clean single code path.
The migration is done on both platforms now. The boundary is typed, the reflection is boxed in and guarded, and the next React Native field rename will break a shim with a test around it, instead of a shipped feature in silence.
Leave a comment