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';