diff --git a/CHANGELOG.md b/CHANGELOG.md
index b24094125c..6daef8c4f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,29 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
+## Unreleased
+
+### Features
+
+- Add Replay Custom Masking for iOS ([#4224](https://github.com/getsentry/sentry-react-native/pull/4224))
+
+ ```jsx
+ import * as Sentry from '@sentry/react-native';
+
+ const Example = () => {
+ return (
+
+
+ ${"All children of Sentry.Mask will be masked."}
+
+
+ ${"Only direct children of Sentry.Unmask will be unmasked."}
+
+
+ );
+ };
+ ```
+
## 6.3.0
### Features
diff --git a/packages/core/.npmignore b/packages/core/.npmignore
index 6a301a4784..bf5fdefe0f 100644
--- a/packages/core/.npmignore
+++ b/packages/core/.npmignore
@@ -11,7 +11,13 @@
!react-native.config.js
!/ios/**/*
!/android/**/*
+
+# New Architecture Codegen
!src/js/NativeRNSentry.ts
+!src/js/RNSentryReplayMaskNativeComponent.ts
+!src/js/RNSentryReplayUnmaskNativeComponent.ts
+
+# Scripts
!scripts/collect-modules.sh
!scripts/copy-debugid.js
!scripts/has-sourcemap-debugid.js
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj
index 59c10b17ad..f78b1be0e0 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTester.xcodeproj/project.pbxproj
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
332D33472CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */; };
336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336084382C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift */; };
+ 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */; };
33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */; };
33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFEC2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m */; };
33AFDFF12B8D15E500AAB120 /* RNSentryDependencyContainerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 33AFDFF02B8D15E500AAB120 /* RNSentryDependencyContainerTests.m */; };
@@ -28,6 +29,9 @@
3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayBreadcrumbConverter.h; path = ../ios/RNSentryReplayBreadcrumbConverter.h; sourceTree = ""; };
3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryBreadcrumbTests.swift; sourceTree = ""; };
3360898D29524164007C7730 /* RNSentryCocoaTesterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RNSentryCocoaTesterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3380C6C12CDEC5850018B9B6 /* RNSentryReplayUnmask.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayUnmask.h; path = ../ios/Replay/RNSentryReplayUnmask.h; sourceTree = SOURCE_ROOT; };
+ 3380C6C22CDEC6630018B9B6 /* RNSentryReplayMask.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplayMask.h; path = ../ios/Replay/RNSentryReplayMask.h; sourceTree = SOURCE_ROOT; };
+ 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RNSentryReplayPostInitTests.swift; sourceTree = ""; };
338739072A7D7D2800950DDD /* RNSentryReplay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RNSentryReplay.h; path = ../ios/RNSentryReplay.h; sourceTree = ""; };
33958C672BFCEF5A00AD1FB6 /* RNSentryOnDrawReporter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RNSentryOnDrawReporter.h; path = ../ios/RNSentryOnDrawReporter.h; sourceTree = ""; };
33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RNSentryOnDrawReporterTests.m; sourceTree = ""; };
@@ -95,13 +99,24 @@
33958C682BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m */,
3360843C2C340C76008CC412 /* RNSentryBreadcrumbTests.swift */,
332D33462CDBDBB600547D76 /* RNSentryReplayOptionsTests.swift */,
+ 3380C6C32CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift */,
);
path = RNSentryCocoaTesterTests;
sourceTree = "";
};
+ 3380C6C02CDEC56B0018B9B6 /* Replay */ = {
+ isa = PBXGroup;
+ children = (
+ 3380C6C22CDEC6630018B9B6 /* RNSentryReplayMask.h */,
+ 3380C6C12CDEC5850018B9B6 /* RNSentryReplayUnmask.h */,
+ );
+ path = Replay;
+ sourceTree = "";
+ };
33AFE0122B8F319000AAB120 /* RNSentry */ = {
isa = PBXGroup;
children = (
+ 3380C6C02CDEC56B0018B9B6 /* Replay */,
332D33482CDBDC7300547D76 /* RNSentry.h */,
3360843A2C32E3A8008CC412 /* RNSentryReplayBreadcrumbConverter.h */,
330F308D2C0F385A002A0D4E /* RNSentryBreadcrumb.h */,
@@ -228,6 +243,7 @@
336084392C32E382008CC412 /* RNSentryReplayBreadcrumbConverterTests.swift in Sources */,
33F58AD02977037D008F60EA /* RNSentryTests.mm in Sources */,
33958C692BFCF12600AD1FB6 /* RNSentryOnDrawReporterTests.m in Sources */,
+ 3380C6C42CE25ECA0018B9B6 /* RNSentryReplayPostInitTests.swift in Sources */,
33AFDFED2B8D14B300AAB120 /* RNSentryFramesTrackerListenerTests.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h
index 43f9933853..bc2bdd0304 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryCocoaTesterTests-Bridging-Header.h
@@ -5,3 +5,5 @@
#import "RNSentryBreadcrumb.h"
#import "RNSentryReplay.h"
#import "RNSentryReplayBreadcrumbConverter.h"
+#import "RNSentryReplayMask.h"
+#import "RNSentryReplayUnmask.h"
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift
index 04a825c682..4e8b35c477 100644
--- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayOptionsTests.swift
@@ -109,11 +109,7 @@ final class RNSentryReplayOptions: XCTestCase {
let sessionReplay = experimental["sessionReplay"] as! [String: Any]
let maskedViewClasses = sessionReplay["maskedViewClasses"] as! [String]
- XCTAssertEqual(maskedViewClasses.count, 1)
- XCTAssertEqual(maskedViewClasses[0], "RNSVGSvgView")
-
- let actualOptions = try! Options(dict: optionsDict as! [String: Any])
- XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 0)
+ XCTAssertTrue(maskedViewClasses.contains("RNSVGSvgView"))
}
func testMaskAllImages() {
@@ -128,9 +124,7 @@ final class RNSentryReplayOptions: XCTestCase {
let actualOptions = try! Options(dict: optionsDict as! [String: Any])
XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllImages, true)
- XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 1)
- XCTAssertNotNil(actualOptions.experimental.sessionReplay.maskedViewClasses[0])
- XCTAssertEqual(ObjectIdentifier(actualOptions.experimental.sessionReplay.maskedViewClasses[0]), ObjectIdentifier(NSClassFromString("RCTImageView")!))
+ assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTImageView")
}
func testMaskAllImagesFalse() {
@@ -160,11 +154,16 @@ final class RNSentryReplayOptions: XCTestCase {
let actualOptions = try! Options(dict: optionsDict as! [String: Any])
XCTAssertEqual(actualOptions.experimental.sessionReplay.maskAllText, true)
- XCTAssertEqual(actualOptions.experimental.sessionReplay.maskedViewClasses.count, 2)
- XCTAssertNotNil(actualOptions.experimental.sessionReplay.maskedViewClasses[0])
- XCTAssertNotNil(actualOptions.experimental.sessionReplay.maskedViewClasses[1])
- XCTAssertEqual(ObjectIdentifier(actualOptions.experimental.sessionReplay.maskedViewClasses[0]), ObjectIdentifier(NSClassFromString("RCTTextView")!))
- XCTAssertEqual(ObjectIdentifier(actualOptions.experimental.sessionReplay.maskedViewClasses[1]), ObjectIdentifier(NSClassFromString("RCTParagraphComponentView")!))
+ assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTTextView")
+ assertContainsClass(classArray: actualOptions.experimental.sessionReplay.maskedViewClasses, stringClass: "RCTParagraphComponentView")
+ }
+
+ func assertContainsClass(classArray: [AnyClass], stringClass: String) {
+ XCTAssertTrue(mapToObjectIdentifiers(classArray: classArray).contains(ObjectIdentifier(NSClassFromString(stringClass)!)))
+ }
+
+ func mapToObjectIdentifiers(classArray: [AnyClass]) -> [ObjectIdentifier] {
+ return classArray.map { ObjectIdentifier($0) }
}
func testMaskAllTextFalse() {
diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayPostInitTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayPostInitTests.swift
new file mode 100644
index 0000000000..6008746256
--- /dev/null
+++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayPostInitTests.swift
@@ -0,0 +1,13 @@
+import Sentry
+import XCTest
+
+final class RNSentryReplayPostInitTests: XCTestCase {
+
+ func testMask() {
+ XCTAssertEqual(ObjectIdentifier(RNSentryReplay.getMaskClass()), ObjectIdentifier(RNSentryReplayMask.self))
+ }
+
+ func testUnmask() {
+ XCTAssertEqual(ObjectIdentifier(RNSentryReplay.getUnmaskClass()), ObjectIdentifier(RNSentryReplayUnmask.self))
+ }
+}
diff --git a/packages/core/ios/RNSentryReplay.h b/packages/core/ios/RNSentryReplay.h
index f3cb5a6ef8..452914af15 100644
--- a/packages/core/ios/RNSentryReplay.h
+++ b/packages/core/ios/RNSentryReplay.h
@@ -5,4 +5,8 @@
+ (void)postInit;
++ (Class)getMaskClass;
+
++ (Class)getUnmaskClass;
+
@end
diff --git a/packages/core/ios/RNSentryReplay.m b/packages/core/ios/RNSentryReplay.mm
similarity index 72%
rename from packages/core/ios/RNSentryReplay.m
rename to packages/core/ios/RNSentryReplay.mm
index 9cb17d612a..8c941741c4 100644
--- a/packages/core/ios/RNSentryReplay.m
+++ b/packages/core/ios/RNSentryReplay.mm
@@ -1,5 +1,9 @@
#import "RNSentryReplay.h"
-#import "RNSentryReplayBreadcrumbConverter.h"
+#import "RNSentryReplayBreadcrumbConverterHelper.h"
+#import "React/RCTTextView.h"
+#import "Replay/RNSentryReplayMask.h"
+#import "Replay/RNSentryReplayUnmask.h"
+#import
#if SENTRY_TARGET_REPLAY_SUPPORTED
@@ -56,9 +60,21 @@ + (NSArray *_Nonnull)getReplayRNRedactClasses:(NSDictionary *_Nullable)replayOpt
+ (void)postInit
{
- RNSentryReplayBreadcrumbConverter *breadcrumbConverter =
- [[RNSentryReplayBreadcrumbConverter alloc] init];
- [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter screenshotProvider:nil];
+ // We can't import RNSentryReplayMask.h here because it's Objective-C++
+ // To avoid typos, we test the class existence in the tests
+ [PrivateSentrySDKOnly setRedactContainerClass:[RNSentryReplay getMaskClass]];
+ [PrivateSentrySDKOnly setIgnoreContainerClass:[RNSentryReplay getUnmaskClass]];
+ [RNSentryReplayBreadcrumbConverterHelper configureSessionReplayWithConverter];
+}
+
++ (Class)getMaskClass
+{
+ return RNSentryReplayMask.class;
+}
+
++ (Class)getUnmaskClass
+{
+ return RNSentryReplayUnmask.class;
}
@end
diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.h b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.h
new file mode 100644
index 0000000000..727cf17dc7
--- /dev/null
+++ b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.h
@@ -0,0 +1,7 @@
+#import
+
+@interface RNSentryReplayBreadcrumbConverterHelper : NSObject
+
++ (void)configureSessionReplayWithConverter;
+
+@end
diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m
new file mode 100644
index 0000000000..ef3da7ec38
--- /dev/null
+++ b/packages/core/ios/RNSentryReplayBreadcrumbConverterHelper.m
@@ -0,0 +1,17 @@
+#import "RNSentryReplayBreadcrumbConverterHelper.h"
+
+#if SENTRY_TARGET_REPLAY_SUPPORTED
+# import "RNSentryReplayBreadcrumbConverter.h"
+
+@implementation RNSentryReplayBreadcrumbConverterHelper
+
++ (void)configureSessionReplayWithConverter
+{
+ RNSentryReplayBreadcrumbConverter *breadcrumbConverter =
+ [[RNSentryReplayBreadcrumbConverter alloc] init];
+ [PrivateSentrySDKOnly configureSessionReplayWith:breadcrumbConverter screenshotProvider:nil];
+}
+
+@end
+
+#endif
diff --git a/packages/core/ios/Replay/RNSentryReplayMask.h b/packages/core/ios/Replay/RNSentryReplayMask.h
new file mode 100644
index 0000000000..3903d413e7
--- /dev/null
+++ b/packages/core/ios/Replay/RNSentryReplayMask.h
@@ -0,0 +1,24 @@
+#import
+
+#if SENTRY_HAS_UIKIT
+
+# import
+
+# ifdef RCT_NEW_ARCH_ENABLED
+# import
+# else
+# import
+# endif
+
+@interface RNSentryReplayMaskManager : RCTViewManager
+@end
+
+@interface RNSentryReplayMask :
+# ifdef RCT_NEW_ARCH_ENABLED
+ RCTViewComponentView
+# else
+ RCTView
+# endif
+@end
+
+#endif
diff --git a/packages/core/ios/Replay/RNSentryReplayMask.mm b/packages/core/ios/Replay/RNSentryReplayMask.mm
new file mode 100644
index 0000000000..bc39f229e2
--- /dev/null
+++ b/packages/core/ios/Replay/RNSentryReplayMask.mm
@@ -0,0 +1,51 @@
+#import
+
+#if SENTRY_HAS_UIKIT
+
+# import "RNSentryReplayMask.h"
+
+# ifdef RCT_NEW_ARCH_ENABLED
+# import
+# import
+// RCTFabricComponentsPlugins needed for RNSentryReplayMaskCls
+# import
+# endif
+
+@implementation RNSentryReplayMaskManager
+
+RCT_EXPORT_MODULE(RNSentryReplayMask)
+
+- (UIView *)view
+{
+ return [RNSentryReplayMask new];
+}
+
+@end
+
+# ifdef RCT_NEW_ARCH_ENABLED
+@interface
+RNSentryReplayMask ()
+@end
+# endif
+
+@implementation RNSentryReplayMask
+
+# ifdef RCT_NEW_ARCH_ENABLED
++ (facebook::react::ComponentDescriptorProvider)componentDescriptorProvider
+{
+ return facebook::react::concreteComponentDescriptorProvider<
+ facebook::react::RNSentryReplayMaskComponentDescriptor>();
+}
+# endif
+
+@end
+
+# ifdef RCT_NEW_ARCH_ENABLED
+Class
+RNSentryReplayMaskCls(void)
+{
+ return RNSentryReplayMask.class;
+}
+# endif
+
+#endif
diff --git a/packages/core/ios/Replay/RNSentryReplayUnmask.h b/packages/core/ios/Replay/RNSentryReplayUnmask.h
new file mode 100644
index 0000000000..c1dc8a3479
--- /dev/null
+++ b/packages/core/ios/Replay/RNSentryReplayUnmask.h
@@ -0,0 +1,24 @@
+#import
+
+#if SENTRY_HAS_UIKIT
+
+# import
+
+# ifdef RCT_NEW_ARCH_ENABLED
+# import
+# else
+# import
+# endif
+
+@interface RNSentryReplayUnmaskManager : RCTViewManager
+@end
+
+@interface RNSentryReplayUnmask :
+# ifdef RCT_NEW_ARCH_ENABLED
+ RCTViewComponentView
+# else
+ RCTView
+# endif
+@end
+
+#endif
diff --git a/packages/core/ios/Replay/RNSentryReplayUnmask.mm b/packages/core/ios/Replay/RNSentryReplayUnmask.mm
new file mode 100644
index 0000000000..8dd0f06611
--- /dev/null
+++ b/packages/core/ios/Replay/RNSentryReplayUnmask.mm
@@ -0,0 +1,51 @@
+#import
+
+#if SENTRY_HAS_UIKIT
+
+# import "RNSentryReplayUnmask.h"
+
+# ifdef RCT_NEW_ARCH_ENABLED
+# import
+# import
+// RCTFabricComponentsPlugins needed for RNSentryReplayUnmaskCls
+# import
+# endif
+
+@implementation RNSentryReplayUnmaskManager
+
+RCT_EXPORT_MODULE(RNSentryReplayUnmask)
+
+- (UIView *)view
+{
+ return [RNSentryReplayUnmask new];
+}
+
+@end
+
+# ifdef RCT_NEW_ARCH_ENABLED
+@interface
+RNSentryReplayUnmask ()
+@end
+# endif
+
+@implementation RNSentryReplayUnmask
+
+# ifdef RCT_NEW_ARCH_ENABLED
++ (facebook::react::ComponentDescriptorProvider)componentDescriptorProvider
+{
+ return facebook::react::concreteComponentDescriptorProvider<
+ facebook::react::RNSentryReplayUnmaskComponentDescriptor>();
+}
+# endif
+
+@end
+
+# ifdef RCT_NEW_ARCH_ENABLED
+Class
+RNSentryReplayUnmaskCls(void)
+{
+ return RNSentryReplayUnmask.class;
+}
+# endif
+
+#endif
diff --git a/packages/core/package.json b/packages/core/package.json
index 85df713ac4..4c08268a34 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -118,7 +118,7 @@
},
"codegenConfig": {
"name": "RNSentrySpec",
- "type": "modules",
+ "type": "all",
"jsSrcsDir": "src",
"android": {
"javaPackageName": "io.sentry.react"
diff --git a/packages/core/src/js/RNSentryReplayMaskNativeComponent.ts b/packages/core/src/js/RNSentryReplayMaskNativeComponent.ts
new file mode 100644
index 0000000000..794dcb2e8c
--- /dev/null
+++ b/packages/core/src/js/RNSentryReplayMaskNativeComponent.ts
@@ -0,0 +1,10 @@
+import type { HostComponent, ViewProps } from 'react-native';
+// The default export exists in the file but eslint doesn't see it
+// eslint-disable-next-line import/default
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
+
+// If changed to type NativeProps = ViewProps, react native codegen will fail finding the NativeProps type
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface NativeProps extends ViewProps {}
+
+export default codegenNativeComponent('RNSentryReplayMask') as HostComponent;
diff --git a/packages/core/src/js/RNSentryReplayUnmaskNativeComponent.ts b/packages/core/src/js/RNSentryReplayUnmaskNativeComponent.ts
new file mode 100644
index 0000000000..928499c747
--- /dev/null
+++ b/packages/core/src/js/RNSentryReplayUnmaskNativeComponent.ts
@@ -0,0 +1,10 @@
+import type { HostComponent, ViewProps } from 'react-native';
+// The default export exists in the file but eslint doesn't see it
+// eslint-disable-next-line import/default
+import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
+
+// If changed to type NativeProps = ViewProps, react native codegen will fail finding the NativeProps type
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface NativeProps extends ViewProps {}
+
+export default codegenNativeComponent('RNSentryReplayUnmask') as HostComponent;
diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts
index 89ed3b6412..b52131fbd6 100644
--- a/packages/core/src/js/index.ts
+++ b/packages/core/src/js/index.ts
@@ -78,3 +78,5 @@ export {
} from './tracing';
export type { TimeToDisplayProps } from './tracing';
+
+export { Mask, Unmask } from './replay/CustomMask';
diff --git a/packages/core/src/js/replay/CustomMask.tsx b/packages/core/src/js/replay/CustomMask.tsx
new file mode 100644
index 0000000000..df4e3c1cfc
--- /dev/null
+++ b/packages/core/src/js/replay/CustomMask.tsx
@@ -0,0 +1,63 @@
+import { logger } from '@sentry/utils';
+import * as React from 'react';
+import type { HostComponent, ViewProps } from 'react-native';
+import { UIManager, View } from 'react-native';
+
+const NativeComponentRegistry: {
+ get>(componentName: string, createViewConfig: () => C): HostComponent;
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+} = require('react-native/Libraries/NativeComponent/NativeComponentRegistry');
+
+const MaskNativeComponentName = 'RNSentryReplayMask';
+const UnmaskNativeComponentName = 'RNSentryReplayUnmask';
+
+const warnMessage = (component: string): string => `[SentrySessionReplay] ${component} component is not supported on the current platform. If ${component} should be supported, please ensure that the application build is up to date.`;
+
+const warn = (component: string): void => {
+ setTimeout(() => {
+ // Missing mask component could cause leaking PII, we have to ensure that the warning is visible
+ // even if the app is running without debug.
+ // eslint-disable-next-line no-console
+ console.warn(warnMessage(component));
+ }, 0);
+};
+
+const MaskFallback = (viewProps: ViewProps): React.ReactElement => {
+ warn('Mask');
+ return ;
+};
+
+const UnmaskFallback = (viewProps: ViewProps): React.ReactElement => {
+ warn('Unmask');
+ return ;
+};
+
+const hasViewManagerConfig = (nativeComponentName: string): boolean => UIManager.hasViewManagerConfig && UIManager.hasViewManagerConfig(nativeComponentName);
+
+const Mask = ((): HostComponent | React.ComponentType => {
+ if (!hasViewManagerConfig(MaskNativeComponentName)) {
+ logger.warn(`[SentrySessionReplay] Can't load ${MaskNativeComponentName}.`);
+ return MaskFallback;
+ }
+
+ // Based on @react-native/babel-plugin-codegen output
+ // https://github.com/facebook/react-native/blob/b32c6c2cc1bc566a85a883901dbf5e23b5a75b61/packages/react-native-codegen/src/generators/components/GenerateViewConfigJs.js#L139
+ return NativeComponentRegistry.get(MaskNativeComponentName, () => ({
+ uiViewClassName: MaskNativeComponentName,
+ }));
+})()
+
+const Unmask = ((): HostComponent | React.ComponentType => {
+ if (!hasViewManagerConfig(UnmaskNativeComponentName)) {
+ logger.warn(`[SentrySessionReplay] Can't load ${UnmaskNativeComponentName}.`);
+ return UnmaskFallback;
+ }
+
+ // Based on @react-native/babel-plugin-codegen output
+ // https://github.com/facebook/react-native/blob/b32c6c2cc1bc566a85a883901dbf5e23b5a75b61/packages/react-native-codegen/src/generators/components/GenerateViewConfigJs.js#L139
+ return NativeComponentRegistry.get(UnmaskNativeComponentName, () => ({
+ uiViewClassName: UnmaskNativeComponentName,
+ }));
+})();
+
+export { Mask, Unmask, MaskFallback, UnmaskFallback };
diff --git a/packages/core/src/js/replay/CustomMask.web.tsx b/packages/core/src/js/replay/CustomMask.web.tsx
new file mode 100644
index 0000000000..87275b9986
--- /dev/null
+++ b/packages/core/src/js/replay/CustomMask.web.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import type { ViewProps } from 'react-native';
+import { View } from 'react-native';
+
+const Mask = (props: ViewProps): React.ReactElement => {
+ // We have to ensure that the warning is visible even if the app is running without debug
+ // eslint-disable-next-line no-console
+ console.warn('[SentrySessionReplay] Mask component is not supported on web.');
+ return ;
+};
+const Unmask = (props: ViewProps): React.ReactElement => {
+ // We have to ensure that the warning is visible even if the app is running without debug
+ // eslint-disable-next-line no-console
+ console.warn('[SentrySessionReplay] Unmask component is not supported on web.');
+ return ;
+};
+
+export { Mask, Unmask };
diff --git a/packages/core/test/replay/CustomMask.test.ts b/packages/core/test/replay/CustomMask.test.ts
new file mode 100644
index 0000000000..7ae7978665
--- /dev/null
+++ b/packages/core/test/replay/CustomMask.test.ts
@@ -0,0 +1,67 @@
+import { beforeEach, describe, expect, it } from '@jest/globals';
+
+describe('CustomMask', () => {
+ beforeEach(() => {
+ jest.resetModules();
+ });
+
+ it('returns a fallback when native view manager is missing', () => {
+ jest.mock('react-native', () => ({
+ UIManager: {},
+ View: jest.fn(),
+ }));
+
+ const { Mask, Unmask, MaskFallback, UnmaskFallback } = require('../../src/js/replay/CustomMask');
+
+ expect(Mask).toBe(MaskFallback);
+ expect(Unmask).toBe(UnmaskFallback);
+ });
+
+ it('returns a fallback component when native view manager config is missing', () => {
+ jest.mock('react-native', () => ({
+ UIManager: {
+ hasViewManagerConfig: () => false,
+ },
+ View: jest.fn(),
+ }));
+
+ const { Mask, Unmask, MaskFallback, UnmaskFallback } = require('../../src/js/replay/CustomMask');
+
+ expect(Mask).toBe(MaskFallback);
+ expect(Unmask).toBe(UnmaskFallback);
+ });
+
+ it('returns native components when native components exist', () => {
+ const mockMaskComponent = jest.fn();
+ const mockUnmaskComponent = jest.fn();
+ const mockNativeComponentRegistryGet = jest.fn().mockImplementation((componentName: string) => {
+ if (componentName === 'RNSentryReplayMask') {
+ return mockMaskComponent;
+ } else if (componentName === 'RNSentryReplayUnmask') {
+ return mockUnmaskComponent;
+ } else {
+ throw new Error(`Unknown component name: ${componentName}`);
+ }
+ });
+
+ jest.mock('react-native/Libraries/NativeComponent/NativeComponentRegistry', () => ({
+ get: mockNativeComponentRegistryGet,
+ }));
+
+ jest.mock('react-native', () => ({
+ UIManager: {
+ hasViewManagerConfig: () => true,
+ },
+ View: jest.fn(),
+ }));
+
+ const { Mask, Unmask } = require('../../src/js/replay/CustomMask');
+
+ expect(Mask).toBe(mockMaskComponent);
+ expect(Unmask).toBe(mockUnmaskComponent);
+
+ expect(mockNativeComponentRegistryGet).toBeCalledTimes(2);
+ expect(mockNativeComponentRegistryGet).toBeCalledWith('RNSentryReplayMask', expect.any(Function));
+ expect(mockNativeComponentRegistryGet).toBeCalledWith('RNSentryReplayUnmask', expect.any(Function));
+ });
+});
diff --git a/packages/core/test/replay/CustomMask.web.test.ts b/packages/core/test/replay/CustomMask.web.test.ts
new file mode 100644
index 0000000000..1ee8d61e99
--- /dev/null
+++ b/packages/core/test/replay/CustomMask.web.test.ts
@@ -0,0 +1,10 @@
+import { describe } from '@jest/globals';
+
+describe('CustomMask', () => {
+ it('returns a react native view', () => {
+ const { Mask, Unmask } = require('../../src/js/replay/CustomMask');
+
+ expect(Mask).toBeDefined();
+ expect(Unmask).toBeDefined();
+ });
+});
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index b0a099d1e9..7f757b53ce 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -4,6 +4,7 @@
"src/js/tools/**/*.ts",
"src/js/*.ts",
"src/js/**/*.web.ts",
+ "src/js/**/*.web.tsx",
"src/js/*.tsx",
"test/**/*.ts",
"test/**/*.tsx",
diff --git a/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx b/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx
index 7a5d212892..96575f2aa9 100644
--- a/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx
+++ b/samples/react-native-macos/src/Screens/PlaygroundScreen.tsx
@@ -14,6 +14,7 @@ import {
Pressable,
} from 'react-native';
import SvgGraphic from '../components/SvgGraphic';
+import * as Sentry from '@sentry/react-native';
const multilineText = `This
is
@@ -32,6 +33,16 @@ const PlaygroundScreen = () => {
Text:
{'This is '}
+ Custom Mask:
+
+ {/* Replay is not supported on macOS. This sample demonstrates that unavailable components do not cause the app to crash. */}
+
+ This is masked
+
+
+ This is unmasked
+
+
TextInput:
{
props.navigation.navigate('Webview');
}}
/>
+ Custom Mask:
+
+
+ This is unmasked because it's direct child of Sentry.Unmask (can be masked if Sentry.Masked is used higher in the hierarchy)
+
+ This is masked always because it's a child of a Sentry.Mask
+
+ {/* Sentry.Unmask does not override the Sentry.Mask from above in the hierarchy */}
+ This is masked always because it's a child of Sentry.Mask
+
+
+
+
Text:
{'This is '}