Skip to content

fix(ios): register RoktEventManager as a TurboModule so events survive bridgeless - #376

Draft
jamesnrokt wants to merge 1 commit into
mainfrom
fix/rokt-event-manager-turbomodule
Draft

fix(ios): register RoktEventManager as a TurboModule so events survive bridgeless#376
jamesnrokt wants to merge 1 commit into
mainfrom
fix/rokt-event-manager-turbomodule

Conversation

@jamesnrokt

@jamesnrokt jamesnrokt commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Background

RNMPRokt is a codegen'd TurboModule, but the event channel next to it is not. RoktEventManager is a plain RCTEventEmitter, and RCTEventEmitter conforms only to RCTBridgeModule — not RCTTurboModule.

Under bridgeless, RCTTurboModuleManager decides whether to instantiate an ObjC module like this:

// RCTTurboModuleManager.mm
- (BOOL)_shouldCreateObjCModule:(Class)moduleClass {
  if (RCTTurboModuleInteropEnabled()) {
    return [moduleClass conformsToProtocol:@protocol(RCTBridgeModule)];
  }
  return [moduleClass conformsToProtocol:@protocol(RCTTurboModule)];
}

useTurboModuleInterop() defaults to false in React Native. It is flipped on by RCTRootViewFactory.initializeReactHostWithLaunchOptions, which is the standard RCTAppDelegate path — but a brownfield app that drives RCTHost itself never goes through it. In that configuration the module is never created, NativeModules.RoktEventManager is undefined, and:

  • js/rokt/rokt.ts exported undefined as RoktEventManager, so an integrator's new NativeEventEmitter(MParticle.RoktEventManager) throws the requires a non-null argument invariant on iOS;
  • rokt-layout-view.ios.tsx built that emitter at module scope, so the throw happened at import time and took the bundle down rather than degrading;
  • when interop is on, everything works — which is why this reproduces for some integrators and not others.

The consequence is severe and silent: selectPlacements still reaches native, because RNMPRokt is a real TurboModule. Placements are selected and served, Rokt's server-side telemetry counts them, but no RoktEvents ever reach JS — no InitComplete, no PlacementInteractive, no PlacementFailure. Any partner funnel built on those events goes dark while looking like a delivery problem.

Embedded placements fare worse than overlays: RoktLayoutView starts at height: 0 and only grows when LayoutHeightChanges arrives, so a dead channel means the placement is selected, served, and never visible.

Found while investigating a partner reporting missing placements with zero Rokt events of any type.

What Has Changed

RoktEventManager is now registered through codegen, so it is instantiated in every architecture regardless of the host app's interop setting.

  • New spec js/codegenSpecs/rokt/NativeRoktEventManager.ts declaring addListener / removeListeners. Uses TurboModuleRegistry.get (not getEnforcing) because the module is iOS-only.
  • ios/RNMParticle/RoktEventManager.h — conforms to the generated NativeRoktEventManagerSpec under RCT_NEW_ARCH_ENABLED. Both required selectors are already declared publicly by RCTEventEmitter, so no method implementations were needed.
  • RoktEventManager.m.mm — adds getTurboModule: returning NativeRoktEventManagerSpecJSI, matching the pattern RNMPRokt.mm already uses. Xcode project references updated; the podspec glob already covered .mm.
  • New js/rokt/rokt-event-manager.ts — single source of truth for resolution: TurboModule registry first, NativeModules fallback for the old architecture, null otherwise. Extracted rather than inlined so rokt-layout-view.ios.tsx does not have to pull in rokt.ts's whole module graph.
  • rokt-layout-view.ios.tsx — emitter is now built lazily instead of at module scope, so a missing module can never fail at import time.

Emission itself is unchanged. sendEventWithName: needs callableJSModules, and RCTTurboModuleManager._createAndSetUpObjCModule calls [_bridgeModuleDecorator attachInteropAPIsToModule:] for every ObjC module it creates — not just interop ones — and RCTEventEmitter synthesises that property. Verified in RN 0.81 source.

No public API change: MParticle.RoktEventManager is still exported and still an event-emitter-compatible module. Android is untouched — it delivers the same events over RCTDeviceEventEmitter and has no such native module, which is why resolution deliberately tolerates null.

How Has This Been Tested

  • New js/__tests__/rokt-event-manager.test.ts — four cases pinning the resolution order: TurboModule registry alone, registry preferred over NativeModules, NativeModules fallback for the old architecture, and null when neither exists so NativeEventEmitter is never handed undefined.
  • Updated the react-native mocks in the two existing suites to expose TurboModuleRegistry.get.
  • yarn jest — 3 suites, 17 tests, all passing.
  • tsc --noEmit — clean.
  • eslint — clean.
  • Codegen verified end to end: ran combine-js-to-schema-cli + generate-all against js/codegenSpecs and confirmed the new module appears in the schema and that the generated RNMParticle.h emits exactly the symbols the native code references:
@protocol NativeRoktEventManagerSpec <RCTBridgeModule, RCTTurboModule>
- (void)addListener:(NSString *)eventName;
- (void)removeListeners:(double)count;
@end
// class JSI_EXPORT NativeRoktEventManagerSpecJSI : public ObjCTurboModule

Notes

Worth a reviewer's judgement: this makes RoktEventManager instantiate when js/rokt/rokt-event-manager.ts is first imported, which happens via index.tsx. Previously the NativeModules proxy also resolved at module scope, so the timing is equivalent — but integrators who deliberately keep native bridging off the bundle-eval path may care.

Verified on simulator

Reproduced the failure and confirmed the fix on an iPhone 16 Pro simulator (iOS 18.6), RN 0.84, bridgeless. Only variable changed between the two runs is the SDK version; app, probe and configuration are identical.

To reproduce the failing condition the sample app disables TurboModule interop from its own AppDelegate after [super application:...] returns, simulating a brownfield host that never goes through RCTRootViewFactory. RCTRootViewFactory enables interop unconditionally in bridgeless, so the stock sample app cannot exhibit this. The interop state is logged directly (LABFLAG) so the result is not inferred.

Before (this branch's parent):

LABFLAG interopEnabled=0 bridgeProxy=0

native:  [mParticle-Rokt] RNMPRokt module load
         (RoktEventManager module alloc)      <- absent
         (RoktEventManager startObserving)    <- absent

js:      Invariant Violation: `new NativeEventEmitter()` requires a non-null argument.
         Invariant Violation: "MParticleSample" has not been registered.

RoktEventManager is never instantiated, so JS receives undefined. Because the layout view built its emitter at module scope, the throw happened during import and prevented AppRegistry.registerComponent from running — the app failed to start entirely, rather than merely losing events.

After (this branch):

LABFLAG interopEnabled=0 bridgeProxy=0

native:  [mParticle-Rokt] RNMPRokt module load
         [mParticle-Rokt] RoktEventManager module alloc
         [mParticle-Rokt] RoktEventManager startObserving (JS listener added)

js:      NativeModules.RoktEventManager=DEFINED
         TurboModuleRegistry.get=DEFINED
         resolved=DEFINED
         NativeEventEmitter=CONSTRUCTED
         addListener=OK
         (no exceptions)

The module is created, resolves from JS, and a JS listener reaches native — confirmed on both sides of the bridge.

This matches the mechanism in RN source: RCTTurboModuleManager.mm installs legacyModuleProvider only when interop is enabled, and in bridgeless __turboModuleProxy is never installed, so a legacy RCTEventEmitter is unreachable from JS without it. Registering the emitter via codegen removes that dependency.

RoktEventManager is an RCTEventEmitter, which conforms only to
RCTBridgeModule. Under bridgeless, RCTTurboModuleManager only
instantiates such modules when the host app has enabled TurboModule
interop - off by default in React Native, and turned on by
RCTRootViewFactory, which a brownfield app driving RCTHost itself never
goes through. In that configuration NativeModules.RoktEventManager was
undefined, so every Rokt event was dropped before reaching JS while
selectPlacements kept working through the RNMPRokt TurboModule:
placements served and billed, no InitComplete, no PlacementInteractive,
no PlacementFailure. Embedded layouts stayed at height 0 and never
became visible.

Registers the emitter through codegen so it exists in every
architecture, and resolves it via the TurboModule registry with a
NativeModules fallback. The layout view now builds its emitter lazily so
a missing module cannot throw at import time. Android is unchanged - it
delivers the same events over RCTDeviceEventEmitter.

RoktEventManager.m becomes .mm for the getTurboModule: hook, so it
compiles as Objective-C++; RoktContracts is imported via its headers
rather than `@import`, which fails without -fcxx-modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant