fix(ios): register RoktEventManager as a TurboModule so events survive bridgeless - #376
fix(ios): register RoktEventManager as a TurboModule so events survive bridgeless#376jamesnrokt wants to merge 1 commit into
Conversation
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] ?? |
There was a problem hiding this comment.
We need to test this won Android and see it need any other changes.
PR SummaryMedium Risk Overview Adds Tests cover resolution order and mock Reviewed by Cursor Bugbot for commit 7c0d383. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
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
RoktEventManagerand updates the iOS native emitter to conform / providegetTurboModule. - Introduces a JS resolver (
rokt-event-manager.ts) that prefersTurboModuleRegistrywithNativeModulesfallback. - Avoids constructing
NativeEventEmitterat module scope inrokt-layout-view.ios.tsxand 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.
| // 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; | ||
| } |
Background
RNMPRoktis a codegen'd TurboModule, but the event channel next to it is not.RoktEventManageris a plainRCTEventEmitter, andRCTEventEmitterconforms only toRCTBridgeModule— notRCTTurboModule.Under bridgeless,
RCTTurboModuleManagerdecides whether to instantiate an ObjC module like this:useTurboModuleInterop()defaults to false in React Native. It is flipped on byRCTRootViewFactory.initializeReactHostWithLaunchOptions, which is the standardRCTAppDelegatepath — but a brownfield app that drivesRCTHostitself never goes through it. In that configuration the module is never created,NativeModules.RoktEventManagerisundefined, and:js/rokt/rokt.tsexportedundefinedasRoktEventManager, so an integrator'snew NativeEventEmitter(MParticle.RoktEventManager)throws therequires a non-null argumentinvariant on iOS;rokt-layout-view.ios.tsxbuilt that emitter at module scope, so the throw happened at import time and took the bundle down rather than degrading;The consequence is severe and silent:
selectPlacementsstill reaches native, becauseRNMPRoktis a real TurboModule. Placements are selected and served, Rokt's server-side telemetry counts them, but noRoktEventsever reach JS — noInitComplete, noPlacementInteractive, noPlacementFailure. Any partner funnel built on those events goes dark while looking like a delivery problem.Embedded placements fare worse than overlays:
RoktLayoutViewstarts atheight: 0and only grows whenLayoutHeightChangesarrives, 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
RoktEventManageris now registered through codegen, so it is instantiated in every architecture regardless of the host app's interop setting.js/codegenSpecs/rokt/NativeRoktEventManager.tsdeclaringaddListener/removeListeners. UsesTurboModuleRegistry.get(notgetEnforcing) because the module is iOS-only.ios/RNMParticle/RoktEventManager.h— conforms to the generatedNativeRoktEventManagerSpecunderRCT_NEW_ARCH_ENABLED. Both required selectors are already declared publicly byRCTEventEmitter, so no method implementations were needed.RoktEventManager.m→.mm— addsgetTurboModule:returningNativeRoktEventManagerSpecJSI, matching the patternRNMPRokt.mmalready uses. Xcode project references updated; the podspec glob already covered.mm.js/rokt/rokt-event-manager.ts— single source of truth for resolution: TurboModule registry first,NativeModulesfallback for the old architecture,nullotherwise. Extracted rather than inlined sorokt-layout-view.ios.tsxdoes not have to pull inrokt.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:needscallableJSModules, andRCTTurboModuleManager._createAndSetUpObjCModulecalls[_bridgeModuleDecorator attachInteropAPIsToModule:]for every ObjC module it creates — not just interop ones — andRCTEventEmittersynthesises that property. Verified in RN 0.81 source.No public API change:
MParticle.RoktEventManageris still exported and still an event-emitter-compatible module. Android is untouched — it delivers the same events overRCTDeviceEventEmitterand has no such native module, which is why resolution deliberately toleratesnull.How Has This Been Tested
js/__tests__/rokt-event-manager.test.ts— four cases pinning the resolution order: TurboModule registry alone, registry preferred overNativeModules,NativeModulesfallback for the old architecture, andnullwhen neither exists soNativeEventEmitteris never handedundefined.TurboModuleRegistry.get.yarn jest— 3 suites, 17 tests, all passing.tsc --noEmit— clean.eslint— clean.combine-js-to-schema-cli+generate-allagainstjs/codegenSpecsand confirmed the new module appears in the schema and that the generatedRNMParticle.hemits exactly the symbols the native code references:Notes
Worth a reviewer's judgement: this makes
RoktEventManagerinstantiate whenjs/rokt/rokt-event-manager.tsis first imported, which happens viaindex.tsx. Previously theNativeModulesproxy 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
AppDelegateafter[super application:...]returns, simulating a brownfield host that never goes throughRCTRootViewFactory.RCTRootViewFactoryenables 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):
RoktEventManageris never instantiated, so JS receivesundefined. Because the layout view built its emitter at module scope, the throw happened during import and preventedAppRegistry.registerComponentfrom running — the app failed to start entirely, rather than merely losing events.After (this branch):
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.mminstallslegacyModuleProvideronly when interop is enabled, and in bridgeless__turboModuleProxyis never installed, so a legacyRCTEventEmitteris unreachable from JS without it. Registering the emitter via codegen removes that dependency.