Skip to content

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

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

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>
*/
export const RoktEventManager =
TurboModuleRegistry.get(ROKT_EVENT_MANAGER_MODULE_NAME) ??
NativeModules[ROKT_EVENT_MANAGER_MODULE_NAME] ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to test this won Android and see it need any other changes.

@jamesnrokt
jamesnrokt marked this pull request as ready for review August 14, 2026 14:52
@jamesnrokt
jamesnrokt requested a review from a team as a code owner August 14, 2026 14:52
Copilot AI lite review requested due to automatic review settings August 14, 2026 14:52
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the critical native→JS event path for Rokt on iOS New Architecture; behavior is well-tested but wrong registration could still drop or mis-deliver placement events.

Overview
Registers RoktEventManager as a codegen TurboModule so iOS bridgeless hosts can instantiate the event emitter without relying on TurboModule interop. Previously a plain RCTEventEmitter often never loaded, so RoktEvents and layout height updates never reached JS while placements still ran natively.

Adds NativeRoktEventManager spec and wires iOS RoktEventManager (.m.mm) to NativeRoktEventManagerSpec with getTurboModule:, matching RNMPRokt.mm. JS resolves the module via new rokt-event-manager.ts: TurboModule registry first, NativeModules on old arch, null on Android. rokt.ts re-exports from there; rokt-layout-view.ios.tsx builds NativeEventEmitter lazily so a missing module cannot crash at import time.

Tests cover resolution order and mock TurboModuleRegistry.get in existing suites.

Reviewed by Cursor Bugbot for commit 7c0d383. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Registers the iOS RoktEventManager (RCTEventEmitter) via codegen as a TurboModule so Rokt events (and embedded placement height updates) continue to reach JS in bridgeless configurations where TurboModule interop is disabled.

Changes:

  • Adds a codegen TurboModule spec for RoktEventManager and updates the iOS native emitter to conform / provide getTurboModule.
  • Introduces a JS resolver (rokt-event-manager.ts) that prefers TurboModuleRegistry with NativeModules fallback.
  • Avoids constructing NativeEventEmitter at module scope in rokt-layout-view.ios.tsx and adds targeted Jest coverage for resolution order.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
js/rokt/rokt.ts Switches RoktEventManager export to the new centralized resolver.
js/rokt/rokt-layout-view.ios.tsx Defers emitter construction to runtime (no longer at import time).
js/rokt/rokt-event-manager.ts New single-source resolver for the native event module across architectures.
js/codegenSpecs/rokt/NativeRoktEventManager.ts New TurboModule spec to register the event emitter in codegen (iOS-only behavior tolerated).
js/tests/rokt-layout-view-style.test.tsx Updates RN mocks to include TurboModuleRegistry.get.
js/tests/rokt-event-manager.test.ts New tests pinning resolver precedence and null behavior.
js/tests/attribute-normalization.test.ts Updates RN mocks to include TurboModuleRegistry.get.
ios/RNMParticle/RoktEventManager.mm Converts to ObjC++ and implements getTurboModule for the new spec.
ios/RNMParticle/RoktEventManager.h Conditionally conforms to NativeRoktEventManagerSpec under the New Architecture.
ios/RNMParticle.xcodeproj/project.pbxproj Updates Xcode project references from .m to .mm.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +44 to +56
// Built on first use rather than at module scope: constructing a NativeEventEmitter with a
// missing native module throws on iOS, which would take down the bundle at import time
// instead of degrading to a placement that never resizes.
let eventManagerEmitter: NativeEventEmitter | undefined;

function getEventManagerEmitter(): NativeEventEmitter {
if (!eventManagerEmitter) {
eventManagerEmitter = new NativeEventEmitter(
RoktEventManager as NativeModule
);
}
return eventManagerEmitter;
}
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.

3 participants