From 7c0d383ae91abfa0bc7b39a534f312c14ce6931d Mon Sep 17 00:00:00 2001 From: James Newman Date: Thu, 13 Aug 2026 21:49:26 -0400 Subject: [PATCH] fix(ios): register RoktEventManager as a TurboModule for bridgeless 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) --- ios/RNMParticle.xcodeproj/project.pbxproj | 8 +-- ios/RNMParticle/RoktEventManager.h | 14 +++++ ...RoktEventManager.m => RoktEventManager.mm} | 14 ++++- js/__tests__/attribute-normalization.test.ts | 1 + js/__tests__/rokt-event-manager.test.ts | 57 +++++++++++++++++++ js/__tests__/rokt-layout-view-style.test.tsx | 5 +- .../rokt/NativeRoktEventManager.ts | 20 +++++++ js/rokt/rokt-event-manager.ts | 20 +++++++ js/rokt/rokt-layout-view.ios.tsx | 20 +++++-- js/rokt/rokt.ts | 3 +- 10 files changed, 149 insertions(+), 13 deletions(-) rename ios/RNMParticle/{RoktEventManager.m => RoktEventManager.mm} (93%) create mode 100644 js/__tests__/rokt-event-manager.test.ts create mode 100644 js/codegenSpecs/rokt/NativeRoktEventManager.ts create mode 100644 js/rokt/rokt-event-manager.ts diff --git a/ios/RNMParticle.xcodeproj/project.pbxproj b/ios/RNMParticle.xcodeproj/project.pbxproj index 1d0f2298..703d38e3 100644 --- a/ios/RNMParticle.xcodeproj/project.pbxproj +++ b/ios/RNMParticle.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - B39BCD8F2E2A06D700FC90B8 /* RoktEventManager.m in Sources */ = {isa = PBXBuildFile; fileRef = B39BCD8C2E2A06D700FC90B8 /* RoktEventManager.m */; }; + B39BCD8F2E2A06D700FC90B8 /* RoktEventManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = B39BCD8C2E2A06D700FC90B8 /* RoktEventManager.mm */; }; B39BCD962E30562400FC90B8 /* RoktLayoutManager.m in Sources */ = {isa = PBXBuildFile; fileRef = B39BCD932E30562400FC90B8 /* RoktLayoutManager.m */; }; B39BCD972E30562400FC90B8 /* RNMPRokt.mm in Sources */ = {isa = PBXBuildFile; fileRef = B39BCD922E30562400FC90B8 /* RNMPRokt.mm */; }; B39BCD982E30562400FC90B8 /* RNMParticle.mm in Sources */ = {isa = PBXBuildFile; fileRef = B39BCD912E30562400FC90B8 /* RNMParticle.mm */; }; @@ -31,7 +31,7 @@ /* Begin PBXFileReference section */ B39BCD892E2A06D700FC90B8 /* RNMPRokt.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RNMPRokt.h; sourceTree = ""; }; B39BCD8B2E2A06D700FC90B8 /* RoktEventManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RoktEventManager.h; sourceTree = ""; }; - B39BCD8C2E2A06D700FC90B8 /* RoktEventManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktEventManager.m; sourceTree = ""; }; + B39BCD8C2E2A06D700FC90B8 /* RoktEventManager.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RoktEventManager.mm; sourceTree = ""; }; B39BCD912E30562400FC90B8 /* RNMParticle.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNMParticle.mm; sourceTree = ""; }; B39BCD922E30562400FC90B8 /* RNMPRokt.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RNMPRokt.mm; sourceTree = ""; }; B39BCD932E30562400FC90B8 /* RoktLayoutManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RoktLayoutManager.m; sourceTree = ""; }; @@ -78,7 +78,7 @@ B39BCD952E30562400FC90B8 /* RoktNativeLayoutComponentView.mm */, B39BCD892E2A06D700FC90B8 /* RNMPRokt.h */, B39BCD8B2E2A06D700FC90B8 /* RoktEventManager.h */, - B39BCD8C2E2A06D700FC90B8 /* RoktEventManager.m */, + B39BCD8C2E2A06D700FC90B8 /* RoktEventManager.mm */, DBDF24DA1E007EB1000F3D73 /* RNMParticle.h */, ); path = RNMParticle; @@ -146,7 +146,7 @@ B39BCD972E30562400FC90B8 /* RNMPRokt.mm in Sources */, B39BCD982E30562400FC90B8 /* RNMParticle.mm in Sources */, B39BCD992E30562400FC90B8 /* RoktNativeLayoutComponentView.mm in Sources */, - B39BCD8F2E2A06D700FC90B8 /* RoktEventManager.m in Sources */, + B39BCD8F2E2A06D700FC90B8 /* RoktEventManager.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/RNMParticle/RoktEventManager.h b/ios/RNMParticle/RoktEventManager.h index af630ff8..6d1cc121 100644 --- a/ios/RNMParticle/RoktEventManager.h +++ b/ios/RNMParticle/RoktEventManager.h @@ -1,11 +1,25 @@ #import #import +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif // RCT_NEW_ARCH_ENABLED + @class RoktEvent; NS_ASSUME_NONNULL_BEGIN +// Conforms to the codegen'd spec under the New Architecture so the emitter is +// registered as a TurboModule. Without it this class is a plain RCTBridgeModule, +// which bridgeless only instantiates when the host app enables TurboModule interop +// -- otherwise NativeModules.RoktEventManager is undefined and every Rokt event is +// dropped before reaching JS. +#ifdef RCT_NEW_ARCH_ENABLED +@interface RoktEventManager : RCTEventEmitter +#else @interface RoktEventManager : RCTEventEmitter +#endif // RCT_NEW_ARCH_ENABLED + + (instancetype _Nonnull)allocWithZone:(NSZone * _Nullable)zone; - (void)onWidgetHeightChanges:(CGFloat)widgetHeight placement:(NSString * _Nonnull)selectedPlacement; - (void)onFirstPositiveResponse; diff --git a/ios/RNMParticle/RoktEventManager.m b/ios/RNMParticle/RoktEventManager.mm similarity index 93% rename from ios/RNMParticle/RoktEventManager.m rename to ios/RNMParticle/RoktEventManager.mm index 9870f56e..e8a5d924 100644 --- a/ios/RNMParticle/RoktEventManager.m +++ b/ios/RNMParticle/RoktEventManager.mm @@ -1,5 +1,11 @@ #import "RoktEventManager.h" -@import RoktContracts; +// Not `@import RoktContracts` -- this file is Objective-C++ and the pod builds without +// -fcxx-modules, so the module import fails. Matches how RNMPRokt.mm imports the same types. +#if __has_include() + #import +#elif __has_include() + #import +#endif #import static os_log_t _rokt_events_os_log(void) { @@ -233,4 +239,10 @@ - (void)onRoktEvents:(RoktEvent * _Nonnull)event viewName:(NSString * _Nullable) } } +#ifdef RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { + return std::make_shared(params); +} +#endif // RCT_NEW_ARCH_ENABLED + @end diff --git a/js/__tests__/attribute-normalization.test.ts b/js/__tests__/attribute-normalization.test.ts index 3d624814..ed685523 100644 --- a/js/__tests__/attribute-normalization.test.ts +++ b/js/__tests__/attribute-normalization.test.ts @@ -12,6 +12,7 @@ jest.mock( Platform: { OS: 'ios' }, TurboModuleRegistry: { getEnforcing: jest.fn(() => mockNativeModule), + get: jest.fn(() => null), }, }), { virtual: true } diff --git a/js/__tests__/rokt-event-manager.test.ts b/js/__tests__/rokt-event-manager.test.ts new file mode 100644 index 00000000..28521bcd --- /dev/null +++ b/js/__tests__/rokt-event-manager.test.ts @@ -0,0 +1,57 @@ +/** + * The Rokt event emitter is an `RCTEventEmitter`, which conforms only to `RCTBridgeModule`. + * Bridgeless does not instantiate those unless the host app enabled TurboModule interop, so + * resolving it purely through `NativeModules` left it undefined and dropped every Rokt event + * before it reached JS. It is now registered via codegen and must be resolved through the + * TurboModule registry first, with a `NativeModules` fallback for the old architecture. + */ + +function loadRoktEventManager( + turboModule: unknown, + nativeModules: Record +) { + jest.resetModules(); + jest.doMock( + 'react-native', + () => ({ + NativeModules: nativeModules, + TurboModuleRegistry: { get: jest.fn(() => turboModule) }, + }), + { virtual: true } + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('../rokt/rokt-event-manager').RoktEventManager; +} + +afterEach(() => { + jest.resetModules(); +}); + +describe('RoktEventManager resolution', () => { + it('resolves through the TurboModule registry when NativeModules has no entry', () => { + const turboModule = { addListener: jest.fn(), removeListeners: jest.fn() }; + + expect(loadRoktEventManager(turboModule, {})).toBe(turboModule); + }); + + it('prefers the TurboModule registry over NativeModules', () => { + const turboModule = { addListener: jest.fn(), removeListeners: jest.fn() }; + const legacyModule = { addListener: jest.fn() }; + + expect( + loadRoktEventManager(turboModule, { RoktEventManager: legacyModule }) + ).toBe(turboModule); + }); + + it('falls back to NativeModules on the old architecture', () => { + const legacyModule = { addListener: jest.fn() }; + + expect(loadRoktEventManager(null, { RoktEventManager: legacyModule })).toBe( + legacyModule + ); + }); + + it('is null when neither is available, so NativeEventEmitter is not handed undefined', () => { + expect(loadRoktEventManager(null, {})).toBeNull(); + }); +}); diff --git a/js/__tests__/rokt-layout-view-style.test.tsx b/js/__tests__/rokt-layout-view-style.test.tsx index 54d628ae..d0694cd3 100644 --- a/js/__tests__/rokt-layout-view-style.test.tsx +++ b/js/__tests__/rokt-layout-view-style.test.tsx @@ -41,7 +41,10 @@ jest.mock( NativeModules: { RoktEventManager: {} }, NativeEventEmitter: jest.fn(() => ({ addListener: mockAddListener })), requireNativeComponent: jest.fn(() => 'RoktLegacyLayout'), - TurboModuleRegistry: { getEnforcing: jest.fn(() => ({})) }, + TurboModuleRegistry: { + getEnforcing: jest.fn(() => ({})), + get: jest.fn(() => null), + }, }), { virtual: true } ); diff --git a/js/codegenSpecs/rokt/NativeRoktEventManager.ts b/js/codegenSpecs/rokt/NativeRoktEventManager.ts new file mode 100644 index 00000000..b440f45e --- /dev/null +++ b/js/codegenSpecs/rokt/NativeRoktEventManager.ts @@ -0,0 +1,20 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +/** + * Event channel for Rokt placement events. + * + * The native side is an `RCTEventEmitter`, which only conforms to `RCTBridgeModule`. + * Under bridgeless that is not enough to be instantiated unless the host app has + * opted into TurboModule interop, so this spec exists to register the emitter as a + * TurboModule and keep `RoktEvents` reaching JS in every architecture. + * + * iOS only. Android delivers the same events over `RCTDeviceEventEmitter`, so this + * module is absent there and `TurboModuleRegistry.get` returns null by design. + */ +export interface Spec extends TurboModule { + addListener(eventName: string): void; + removeListeners(count: number): void; +} + +export default TurboModuleRegistry.get('RoktEventManager'); diff --git a/js/rokt/rokt-event-manager.ts b/js/rokt/rokt-event-manager.ts new file mode 100644 index 00000000..db2246d6 --- /dev/null +++ b/js/rokt/rokt-event-manager.ts @@ -0,0 +1,20 @@ +import { NativeModules, TurboModuleRegistry } from 'react-native'; + +const ROKT_EVENT_MANAGER_MODULE_NAME = 'RoktEventManager'; + +/** + * The native emitter that carries `RoktEvents` and `LayoutHeightChanges` to JS. + * + * Resolved through the TurboModule registry first: the native class is an + * `RCTEventEmitter`, and under bridgeless those are only instantiated when the host app + * has enabled TurboModule interop. Registering it via codegen makes it resolvable either + * way; without this the module is undefined and every Rokt event is dropped silently. + * + * Falls back to `NativeModules` for the old architecture, and is null on Android, where + * the same events arrive over `RCTDeviceEventEmitter` and `NativeEventEmitter` accepts a + * null module. + */ +export const RoktEventManager = + TurboModuleRegistry.get(ROKT_EVENT_MANAGER_MODULE_NAME) ?? + NativeModules[ROKT_EVENT_MANAGER_MODULE_NAME] ?? + null; diff --git a/js/rokt/rokt-layout-view.ios.tsx b/js/rokt/rokt-layout-view.ios.tsx index a8546a8b..c4372723 100644 --- a/js/rokt/rokt-layout-view.ios.tsx +++ b/js/rokt/rokt-layout-view.ios.tsx @@ -2,15 +2,13 @@ import { requireNativeComponent, StyleSheet, NativeEventEmitter, - NativeModules, ViewProps, NativeModule, } from 'react-native'; import React, { Component } from 'react'; import { isFabricEnabled } from '../utils/architecture'; import RoktLayoutNativeComponent from '../codegenSpecs/rokt/RoktLayoutNativeComponent'; - -const RoktEventManager = NativeModules.RoktEventManager as NativeModule; +import { RoktEventManager } from './rokt-event-manager'; export interface HeightChangedEvent extends Event { height: string; @@ -43,13 +41,25 @@ const LayoutNativeComponent = ( : requireNativeComponent('RoktLegacyLayout') ) as any; -const eventManagerEmitter = new NativeEventEmitter(RoktEventManager); +// 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; +} export class RoktLayoutView extends Component< RoktLayoutViewProps, RoktLayoutViewState > { - subscription = eventManagerEmitter.addListener( + subscription = getEventManagerEmitter().addListener( 'LayoutHeightChanges', (widgetChanges: WidgetChangeEvent) => { if (widgetChanges.selectedPlacement == this.state.placeholderName) { diff --git a/js/rokt/rokt.ts b/js/rokt/rokt.ts index f7eee054..7af8e00f 100644 --- a/js/rokt/rokt.ts +++ b/js/rokt/rokt.ts @@ -1,6 +1,7 @@ import { NativeModules, Platform, TurboModuleRegistry } from 'react-native'; import { getNativeModule, isNewArchitecture } from '../utils/architecture'; import type { Spec as NativeMPRoktInterface } from '../codegenSpecs/rokt/NativeMPRokt'; +import { RoktEventManager } from './rokt-event-manager'; const ROKT_MODULE_NAME = 'RNMPRokt'; const MPRokt = @@ -124,8 +125,6 @@ class RoktConfig implements IRoktConfig { this.cacheConfig = cacheConfig; } } -const { RoktEventManager } = NativeModules; - export { RoktEventManager }; export type ColorMode = 'light' | 'dark' | 'system';