fix(ios): register RoktEventManager as a TurboModule so events survive bridgeless - #376
Draft
jamesnrokt wants to merge 1 commit into
Draft
fix(ios): register RoktEventManager as a TurboModule so events survive bridgeless#376jamesnrokt wants to merge 1 commit into
jamesnrokt 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.