diff --git a/ios/RNMParticle/RNMPRokt.h b/ios/RNMParticle/RNMPRokt.h index 8cbf140d..30f5c5d8 100644 --- a/ios/RNMParticle/RNMPRokt.h +++ b/ios/RNMParticle/RNMPRokt.h @@ -2,10 +2,8 @@ #ifdef RCT_NEW_ARCH_ENABLED #import -#import @interface RNMPRokt : NSObject -@property (nonatomic, weak, nullable) RCTBridge *bridge; #else #import diff --git a/ios/RNMParticle/RNMPRokt.mm b/ios/RNMParticle/RNMPRokt.mm index 123f37ba..107a7b04 100644 --- a/ios/RNMParticle/RNMPRokt.mm +++ b/ios/RNMParticle/RNMPRokt.mm @@ -19,8 +19,8 @@ #import #import #import -#import -#import +#import +#import #import #import "RoktEventManager.h" @@ -56,7 +56,8 @@ @interface RNMPRokt () @implementation RNMPRokt -@synthesize bridge = _bridge; +// Maps React tags to UIViews in both bridge and bridgeless modes, unlike bridge.uiManager. +@synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED; RCT_EXTERN void RCTRegisterModule(Class); @@ -71,10 +72,9 @@ + (void)load { - (dispatch_queue_t)methodQueue { - BOOL bridgeNil = (self.bridge == nil); - BOOL uiManagerNil = (self.bridge.uiManager == nil); - _rokt_log(@"[mParticle-Rokt] methodQueue called, bridge %@, uiManager %@", bridgeNil ? @"nil" : @"non-nil", uiManagerNil ? @"nil" : @"non-nil"); - return self.bridge.uiManager.methodQueue; + // selectPlacements mutates the placeholder view hierarchy, so SDK calls must run on + // the main thread. Matches Android's UiThreadUtil.runOnUiThread (MPRoktModule.kt). + return dispatch_get_main_queue(); } - (void)setMethodQueue:(dispatch_queue_t)methodQueue @@ -147,20 +147,11 @@ - (void)selectPlacements:(NSString *)identifer [self ensureEventManager]; __weak __typeof__(self) weakSelf = self; - BOOL bridgeNil = (self.bridge == nil); - BOOL uiManagerNil = (self.bridge.uiManager == nil); - _rokt_log(@"[mParticle-Rokt] bridge %@, uiManager %@", bridgeNil ? @"nil" : @"non-nil", uiManagerNil ? @"nil" : @"non-nil"); - - if (bridgeNil || uiManagerNil) { - _rokt_log(@"[mParticle-Rokt] addUIBlock skipped: self.bridge%@ is nil. selectPlacements will not be called. This can occur in New Architecture bridgeless production builds.", bridgeNil ? @"" : @".uiManager"); - } else { - _rokt_log(@"[mParticle-Rokt] queuing addUIBlock for identifier: %@", identifer); - } - [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary *viewRegistry) { + // Replaces [self.bridge.uiManager addUIBlock:], which silently drops the call (no + // event emitted) when RCT_REMOVE_LEGACY_ARCH is set, React Native 0.84's default. + RCTExecuteOnMainQueue(^{ __strong __typeof__(weakSelf) strongSelf = weakSelf; - _rokt_log(@"[mParticle-Rokt] addUIBlock executing for identifier: %@, viewRegistry count: %lu", identifer, (unsigned long)viewRegistry.count); - - NSMutableDictionary *nativePlaceholders = strongSelf ? [strongSelf getNativePlaceholders:placeholders viewRegistry:viewRegistry] : [NSMutableDictionary dictionary]; + NSMutableDictionary *nativePlaceholders = strongSelf ? [strongSelf resolvePlaceholders:placeholders] : [NSMutableDictionary dictionary]; id mpInstance = [MParticle sharedInstance]; id roktKit = mpInstance ? [mpInstance rokt] : nil; @@ -173,8 +164,7 @@ - (void)selectPlacements:(NSString *)identifer onEvent:^(RoktEvent * _Nonnull event) { [weakSelf.eventManager onRoktEvents:event viewName:identifer]; }]; - }]; - _rokt_log(@"[mParticle-Rokt] addUIBlock enqueued for identifier: %@", identifer); + }); } #ifdef RCT_NEW_ARCH_ENABLED @@ -328,22 +318,30 @@ - (RoktConfig *)buildRoktConfigFromDict:(NSDictionary *)configMa return isConfigEmpty ? nil : [builder build]; } -- (NSMutableDictionary *)getNativePlaceholders:(NSDictionary *)placeholders viewRegistry:(NSDictionary *)viewRegistry +// Main thread only — RCTViewRegistry reads the mounted view hierarchy. +- (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders { - _rokt_log(@"[mParticle-Rokt] getNativePlaceholders: placeholders %lu, viewRegistry %lu", (unsigned long)placeholders.count, (unsigned long)viewRegistry.count); + _rokt_log(@"[mParticle-Rokt] resolvePlaceholders: %lu placeholder(s)", (unsigned long)placeholders.count); NSMutableDictionary *nativePlaceholders = [[NSMutableDictionary alloc]initWithCapacity:placeholders.count]; for(id key in placeholders){ + // The spec allows `number | null`; viewForReactTag: would throw on NSNull. + NSNumber *reactTag = [placeholders objectForKey:key]; + if (![reactTag isKindOfClass:[NSNumber class]]) { + RCTLogError(@"Invalid react tag for placeholder %@", key); + continue; + } + + // nil fails isKindOfClass:, covering both "not mounted" and "wrong class". + UIView *view = [_viewRegistry_DEPRECATED viewForReactTag:reactTag]; #ifdef RCT_NEW_ARCH_ENABLED - RoktNativeLayoutComponentView *wrapperView = (RoktNativeLayoutComponentView *)viewRegistry[[placeholders objectForKey:key]]; - if (!wrapperView || ![wrapperView isKindOfClass:[RoktNativeLayoutComponentView class]]) { - RCTLogError(@"Cannot find RoktNativeWidgetComponentView with tag #%@", key); + if (![view isKindOfClass:[RoktNativeLayoutComponentView class]]) { + RCTLogError(@"Cannot find RoktNativeLayoutComponentView for placeholder %@ (reactTag %@)", key, reactTag); continue; } - nativePlaceholders[key] = wrapperView.roktEmbeddedView; + nativePlaceholders[key] = ((RoktNativeLayoutComponentView *)view).roktEmbeddedView; #else - RoktEmbeddedView *view = viewRegistry[[placeholders objectForKey:key]]; - if (!view || ![view isKindOfClass:[RoktEmbeddedView class]]) { + if (![view isKindOfClass:[RoktEmbeddedView class]]) { RCTLogError(@"Cannot find RoktEmbeddedView with tag #%@", key); continue; } @@ -352,14 +350,12 @@ - (NSMutableDictionary *)getNativePlaceholders:(NSDictionary *)placeholders view #endif // RCT_NEW_ARCH_ENABLED } - _rokt_log(@"[mParticle-Rokt] getNativePlaceholders: resolved %lu native placeholder(s)", (unsigned long)nativePlaceholders.count); + _rokt_log(@"[mParticle-Rokt] resolvePlaceholders: resolved %lu native placeholder(s)", (unsigned long)nativePlaceholders.count); return nativePlaceholders; } #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { - self.bridge = params.instance.bridge; - _rokt_log(@"[mParticle-Rokt] getTurboModule: bridge set to %@", self.bridge == nil ? @"nil" : @"non-nil"); return std::make_shared(params); } #endif // RCT_NEW_ARCH_ENABLED diff --git a/sample/ios/MParticleSample.xcodeproj/project.pbxproj b/sample/ios/MParticleSample.xcodeproj/project.pbxproj index c97202ff..fd40637f 100644 --- a/sample/ios/MParticleSample.xcodeproj/project.pbxproj +++ b/sample/ios/MParticleSample.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 00E356F31AD99517003FC87E /* MParticleSampleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* MParticleSampleTests.m */; }; - B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */; }; 0C80B921A6F3F58F76C31292 /* libPods-MParticleSample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-MParticleSample.a */; }; 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; @@ -16,6 +15,8 @@ 4B55574964776A1532DBA98C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 7699B88040F8A987B510C191 /* libPods-MParticleSample-MParticleSampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19F6CBCC0A4E27FBF8BF4A61 /* libPods-MParticleSample-MParticleSampleTests.a */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */; }; + B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -32,7 +33,6 @@ 00E356EE1AD99517003FC87E /* MParticleSampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MParticleSampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 00E356F21AD99517003FC87E /* MParticleSampleTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MParticleSampleTests.m; sourceTree = ""; }; - B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTConvertCommerceMappingTests.m; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* MParticleSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MParticleSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = MParticleSample/AppDelegate.h; sourceTree = ""; }; 13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = MParticleSample/AppDelegate.mm; sourceTree = ""; }; @@ -47,6 +47,8 @@ 5DCACB8F33CDC322A6C60F78 /* libPods-MParticleSample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MParticleSample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = MParticleSample/LaunchScreen.storyboard; sourceTree = ""; }; 89C6BE57DB24E9ADA2F236DE /* Pods-MParticleSample-MParticleSampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MParticleSample-MParticleSampleTests.release.xcconfig"; path = "Target Support Files/Pods-MParticleSample-MParticleSampleTests/Pods-MParticleSample-MParticleSampleTests.release.xcconfig"; sourceTree = ""; }; + B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTConvertCommerceMappingTests.m; sourceTree = ""; }; + B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNMPRoktPlaceholderTests.m; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -75,6 +77,7 @@ children = ( 00E356F21AD99517003FC87E /* MParticleSampleTests.m */, B7C10E902E50AA1100000001 /* RCTConvertCommerceMappingTests.m */, + B7C10E922E50AA1100000003 /* RNMPRoktPlaceholderTests.m */, 00E356F01AD99517003FC87E /* Supporting Files */, ); path = MParticleSampleTests; @@ -393,6 +396,7 @@ files = ( 00E356F31AD99517003FC87E /* MParticleSampleTests.m in Sources */, B7C10E912E50AA1100000002 /* RCTConvertCommerceMappingTests.m in Sources */, + B7C10E932E50AA1100000004 /* RNMPRoktPlaceholderTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/sample/ios/MParticleSample/AppDelegate.mm b/sample/ios/MParticleSample/AppDelegate.mm index 4070959d..58c70b75 100644 --- a/sample/ios/MParticleSample/AppDelegate.mm +++ b/sample/ios/MParticleSample/AppDelegate.mm @@ -10,6 +10,7 @@ #import "AppDelegate.h" #import +#import #import "mParticle.h" @implementation AppDelegate @@ -18,6 +19,11 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( { self.moduleName = @"MParticleSample"; self.initialProps = @{}; + + // Required since React Native 0.76. Without it no third-party Fabric component is + // registered, so mounts as RCTUnimplementedViewComponentView and + // embedded placements resolve no placeholder view. + self.dependencyProvider = [RCTAppDependencyProvider new]; MPNetworkOptions *networkOptions = [[MPNetworkOptions alloc] init]; networkOptions.pinningDisabled = true; diff --git a/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m new file mode 100644 index 00000000..e7d3d280 --- /dev/null +++ b/sample/ios/MParticleSampleTests/RNMPRoktPlaceholderTests.m @@ -0,0 +1,143 @@ +#import +#import +#import +#import "../../../ios/RNMParticle/RNMPRokt.h" + +// Implemented in RNMPRokt.mm. +@interface RNMPRokt (PlaceholderTests) +- (NSMutableDictionary *)resolvePlaceholders:(NSDictionary *)placeholders; +@end + +/** + * Guards how `-[RNMPRokt resolvePlaceholders:]` turns placeholder react tags into the + * embedded views handed to `MPRokt selectPlacements`. + * + * Resolution goes through `RCTViewRegistry` rather than the legacy + * `self.bridge.uiManager addUIBlock:` view registry, because the latter is a no-op method + * body when RCT_REMOVE_LEGACY_ARCH is defined (React Native 0.84's default) and a no-op + * when `self.bridge` is nil — either way selectPlacements was discarded with no event + * emitted. The registry is exercised for real here: the tests install a bridgeless + * component-view provider, the same hook RCTInstance wires to the surface presenter in + * production. + * + * Scope limit, deliberate: this test target statically links the react-native-mparticle + * pod a second time on top of the app it hosts, so `RoktNativeLayoutComponentView` and + * `RNMPRokt` each exist in two binaries and `isKindOfClass:` cannot match across them + * ("Class ... is implemented in both", i.e. the runtime's spurious-casting-failure + * warning). Asserting a mounted placeholder resolves all the way to its `RoktEmbeddedView` + * would therefore be testing the linkage, not the code. That step is verified by running + * an embedded placement in the sample app instead. What is covered below is + * binary-independent: that the module is wired to a working registry, and every branch + * that refuses to resolve a tag. + */ +@interface RNMPRoktPlaceholderTests : XCTestCase +@end + +@implementation RNMPRoktPlaceholderTests { + RNMPRokt *_rokt; + // viewRegistry_DEPRECATED is a weak property (React Native retains the registry via + // RCTBridgeModuleDecorator for the instance's lifetime), so the test has to own it. + RCTViewRegistry *_viewRegistry; + NSMutableDictionary *_views; + NSInteger _loggedErrorCount; + NSMutableArray *_loggedErrors; + RCTLogFunction _originalLogFunction; +} + +- (void)setUp +{ + [super setUp]; + _rokt = [RNMPRokt new]; + _views = [NSMutableDictionary new]; + + _viewRegistry = [RCTViewRegistry new]; + __weak __typeof__(self) weakSelf = self; + [_viewRegistry setBridgelessComponentViewProvider:^UIView *(NSNumber *reactTag) { + __strong __typeof__(weakSelf) strongSelf = weakSelf; + return strongSelf ? strongSelf->_views[reactTag] : nil; + }]; + _rokt.viewRegistry_DEPRECATED = _viewRegistry; + + // Unresolvable placeholders are reported via RCTLogError. Capture instead of letting + // it surface as test noise, so the diagnostic itself can be asserted. + _loggedErrorCount = 0; + _loggedErrors = [NSMutableArray new]; + _originalLogFunction = RCTGetLogFunction(); + RCTSetLogFunction(^(RCTLogLevel level, + __unused RCTLogSource source, + __unused NSString *fileName, + __unused NSNumber *lineNumber, + NSString *message) { + __strong __typeof__(weakSelf) strongSelf = weakSelf; + if (strongSelf && level >= RCTLogLevelError) { + strongSelf->_loggedErrorCount++; + [strongSelf->_loggedErrors addObject:message ?: @""]; + } + }); +} + +- (void)tearDown +{ + RCTSetLogFunction(_originalLogFunction); + _rokt = nil; + _viewRegistry = nil; + _views = nil; + [super tearDown]; +} + +// Regression guard for the change itself: without `@synthesize viewRegistry_DEPRECATED` +// in RNMPRokt.mm the module has no way to reach a view, and every embedded placement +// silently resolves to nothing. +- (void)testModuleIsWiredToAViewRegistryThatResolvesMountedViews +{ + UIView *mountedView = [[UIView alloc] init]; + _views[@101] = mountedView; + + XCTAssertNotNil(_rokt.viewRegistry_DEPRECATED); + XCTAssertEqualObjects([_rokt.viewRegistry_DEPRECATED viewForReactTag:@101], mountedView); + XCTAssertNil([_rokt.viewRegistry_DEPRECATED viewForReactTag:@999]); +} + +- (void)testSkipsTagThatIsNotMounted +{ + NSDictionary *resolved = [_rokt resolvePlaceholders:@{@"Location1" : @999}]; + + XCTAssertEqual(resolved.count, 0u); + XCTAssertEqual(_loggedErrorCount, 1, @"errors: %@", _loggedErrors); +} + +- (void)testSkipsTagResolvingToUnexpectedViewClass +{ + _views[@101] = [[UIView alloc] init]; + + NSDictionary *resolved = [_rokt resolvePlaceholders:@{@"Location1" : @101}]; + + XCTAssertEqual(resolved.count, 0u); + XCTAssertEqual(_loggedErrorCount, 1, @"errors: %@", _loggedErrors); +} + +- (void)testSkipsNonNumericTagWithoutThrowing +{ + // `placeholders?: {[key: string]: number | null}` in js/codegenSpecs/rokt/NativeMPRokt.ts. + // The old dictionary-subscript lookup tolerated NSNull; viewForReactTag: would throw + // on it, so resolvePlaceholders: has to reject non-numeric tags itself. + NSDictionary *resolved = + [_rokt resolvePlaceholders:@{@"Location1" : [NSNull null], @"Location2" : @"101"}]; + + XCTAssertEqual(resolved.count, 0u); + XCTAssertEqual(_loggedErrorCount, 2, @"errors: %@", _loggedErrors); + XCTAssertTrue([_loggedErrors.firstObject hasPrefix:@"Invalid react tag"], + @"errors: %@", _loggedErrors); +} + +- (void)testEmptyPlaceholdersResolveToEmptyDictionary +{ + // Overlay / bottom-sheet placements pass no placeholders at all, so this path must not + // depend on the view hierarchy in any way. + NSDictionary *resolved = [_rokt resolvePlaceholders:@{}]; + + XCTAssertEqual(resolved.count, 0u); + XCTAssertEqual(_loggedErrorCount, 0, @"errors: %@", _loggedErrors); +} + +@end