diff --git a/.gitignore b/.gitignore index a18501839..3b6813db1 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,10 @@ example/android/app/src/main/assets/index.android* # Credentials for example app datadog-ci.json + + +# Babel Plugin assets +packages/react-native-babel-plugin/assets/* + +# Session Replay assets +packages/react-native-session-replay/assets/* diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 85afa7e26..2d12ca9ea 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -32,3 +32,7 @@ dev,react-native-webview,MIT,"Copyright (c) 2015-present, Facebook, Inc." dev,react-test-renderer,MIT,"Copyright (c) Facebook, Inc. and its affiliates." dev,typescript,Apache-2.0,"Copyright Microsoft Corporation" dev,genversion,MIT,"Copyright (c) 2021 Akseli Palén" +prod,chokidar,MIT,"Copyright (c) 2012 Paul Miller (https://paulmillr.com), Elan Shanker" +prod,fast-glob,MIT,"Copyright (c) Denis Malinochkin" +prod,svgo,MIT,"Copyright (c) Kir Belevich" +prod,uuid,MIT,"Copyright (c) 2010-2020 Robert Kieffer and other contributors" diff --git a/example-new-architecture/ios/DdSdkReactNativeExample.xcodeproj/project.pbxproj b/example-new-architecture/ios/DdSdkReactNativeExample.xcodeproj/project.pbxproj index 2147632d7..ed252adbc 100644 --- a/example-new-architecture/ios/DdSdkReactNativeExample.xcodeproj/project.pbxproj +++ b/example-new-architecture/ios/DdSdkReactNativeExample.xcodeproj/project.pbxproj @@ -10,9 +10,9 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; - 38FDA727C2ADFB612BC59CAF /* libPods-DdSdkReactNativeExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 392B93CDAA416A9B63770F89 /* libPods-DdSdkReactNativeExample.a */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; E05973ABEC106467505BAF84 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 91A6167299744A7A5E90FD00 /* PrivacyInfo.xcprivacy */; }; + F59778933728396AC9586AC3 /* libPods-DdSdkReactNativeExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F15FCB308661F5FE9CD1E2F0 /* libPods-DdSdkReactNativeExample.a */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -24,12 +24,12 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = DdSdkReactNativeExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = DdSdkReactNativeExample/Info.plist; sourceTree = ""; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = DdSdkReactNativeExample/main.m; sourceTree = ""; }; - 392B93CDAA416A9B63770F89 /* libPods-DdSdkReactNativeExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DdSdkReactNativeExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 6F4909953279AC89EEC11F97 /* Pods-DdSdkReactNativeExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DdSdkReactNativeExample.debug.xcconfig"; path = "Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample.debug.xcconfig"; sourceTree = ""; }; + 4A4B8157669F7C844ABF982B /* Pods-DdSdkReactNativeExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DdSdkReactNativeExample.release.xcconfig"; path = "Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample.release.xcconfig"; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = DdSdkReactNativeExample/LaunchScreen.storyboard; sourceTree = ""; }; - 82767EEEA8B17AE9E7964826 /* Pods-DdSdkReactNativeExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DdSdkReactNativeExample.release.xcconfig"; path = "Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample.release.xcconfig"; sourceTree = ""; }; 91A6167299744A7A5E90FD00 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = DdSdkReactNativeExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 943593C03DDD65A0F77DAD08 /* Pods-DdSdkReactNativeExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DdSdkReactNativeExample.debug.xcconfig"; path = "Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample.debug.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F15FCB308661F5FE9CD1E2F0 /* libPods-DdSdkReactNativeExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-DdSdkReactNativeExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -37,7 +37,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 38FDA727C2ADFB612BC59CAF /* libPods-DdSdkReactNativeExample.a in Frameworks */, + F59778933728396AC9586AC3 /* libPods-DdSdkReactNativeExample.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,7 +79,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 392B93CDAA416A9B63770F89 /* libPods-DdSdkReactNativeExample.a */, + F15FCB308661F5FE9CD1E2F0 /* libPods-DdSdkReactNativeExample.a */, ); name = Frameworks; sourceTree = ""; @@ -117,8 +117,8 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - 6F4909953279AC89EEC11F97 /* Pods-DdSdkReactNativeExample.debug.xcconfig */, - 82767EEEA8B17AE9E7964826 /* Pods-DdSdkReactNativeExample.release.xcconfig */, + 943593C03DDD65A0F77DAD08 /* Pods-DdSdkReactNativeExample.debug.xcconfig */, + 4A4B8157669F7C844ABF982B /* Pods-DdSdkReactNativeExample.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -130,14 +130,14 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "DdSdkReactNativeExample" */; buildPhases = ( - E7FEDEC891CFB376C01E3757 /* [CP] Check Pods Manifest.lock */, + 6D376D298F855F5CC23E9ED5 /* [CP] Check Pods Manifest.lock */, FD10A7F022414F080027D42C /* Start Packager */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 24FCB8A28DAC48C3D46EDEDA /* [CP] Embed Pods Frameworks */, - 883B42E77DC977535F0A70C3 /* [CP] Copy Pods Resources */, + 07743436841738907FE3FD72 /* [CP] Embed Pods Frameworks */, + 961EBCFFF8E0F6A3F5781446 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -209,7 +209,7 @@ shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; }; - 24FCB8A28DAC48C3D46EDEDA /* [CP] Embed Pods Frameworks */ = { + 07743436841738907FE3FD72 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -226,43 +226,43 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 883B42E77DC977535F0A70C3 /* [CP] Copy Pods Resources */ = { + 6D376D298F855F5CC23E9ED5 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-DdSdkReactNativeExample-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - E7FEDEC891CFB376C01E3757 /* [CP] Check Pods Manifest.lock */ = { + 961EBCFFF8E0F6A3F5781446 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-DdSdkReactNativeExample-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-DdSdkReactNativeExample/Pods-DdSdkReactNativeExample-resources.sh\"\n"; showEnvVarsInLog = 0; }; FD10A7F022414F080027D42C /* Start Packager */ = { @@ -301,7 +301,7 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6F4909953279AC89EEC11F97 /* Pods-DdSdkReactNativeExample.debug.xcconfig */; + baseConfigurationReference = 943593C03DDD65A0F77DAD08 /* Pods-DdSdkReactNativeExample.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -328,7 +328,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 82767EEEA8B17AE9E7964826 /* Pods-DdSdkReactNativeExample.release.xcconfig */; + baseConfigurationReference = 4A4B8157669F7C844ABF982B /* Pods-DdSdkReactNativeExample.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; diff --git a/example-new-architecture/ios/Podfile.lock b/example-new-architecture/ios/Podfile.lock index 25ee5e66c..d98fdb435 100644 --- a/example-new-architecture/ios/Podfile.lock +++ b/example-new-architecture/ios/Podfile.lock @@ -1,22 +1,22 @@ PODS: - boost (1.84.0) - - DatadogCore (2.30.0): - - DatadogInternal (= 2.30.0) - - DatadogCrashReporting (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogCore (2.30.2): + - DatadogInternal (= 2.30.2) + - DatadogCrashReporting (2.30.2): + - DatadogInternal (= 2.30.2) - PLCrashReporter (~> 1.12.0) - - DatadogInternal (2.30.0) - - DatadogLogs (2.30.0): - - DatadogInternal (= 2.30.0) - - DatadogRUM (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogInternal (2.30.2) + - DatadogLogs (2.30.2): + - DatadogInternal (= 2.30.2) + - DatadogRUM (2.30.2): + - DatadogInternal (= 2.30.2) - DatadogSDKReactNative (2.12.3): - - DatadogCore (= 2.30.0) - - DatadogCrashReporting (= 2.30.0) - - DatadogLogs (= 2.30.0) - - DatadogRUM (= 2.30.0) - - DatadogTrace (= 2.30.0) - - DatadogWebViewTracking (= 2.30.0) + - DatadogCore (= 2.30.2) + - DatadogCrashReporting (= 2.30.2) + - DatadogLogs (= 2.30.2) + - DatadogRUM (= 2.30.2) + - DatadogTrace (= 2.30.2) + - DatadogWebViewTracking (= 2.30.2) - DoubleConversion - glog - hermes-engine @@ -38,12 +38,12 @@ PODS: - ReactCommon/turbomodule/core - Yoga - DatadogSDKReactNative/Tests (2.12.3): - - DatadogCore (= 2.30.0) - - DatadogCrashReporting (= 2.30.0) - - DatadogLogs (= 2.30.0) - - DatadogRUM (= 2.30.0) - - DatadogTrace (= 2.30.0) - - DatadogWebViewTracking (= 2.30.0) + - DatadogCore (= 2.30.2) + - DatadogCrashReporting (= 2.30.2) + - DatadogLogs (= 2.30.2) + - DatadogRUM (= 2.30.2) + - DatadogTrace (= 2.30.2) + - DatadogWebViewTracking (= 2.30.2) - DoubleConversion - glog - hermes-engine @@ -64,11 +64,11 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - DatadogTrace (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogTrace (2.30.2): + - DatadogInternal (= 2.30.2) - OpenTelemetrySwiftApi (= 1.13.1) - - DatadogWebViewTracking (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogWebViewTracking (2.30.2): + - DatadogInternal (= 2.30.2) - DoubleConversion (1.1.6) - fast_float (6.1.4) - FBLazyVector (0.76.9) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 05ca0d0de..8d2d460f8 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,34 +1,34 @@ PODS: - boost (1.84.0) - - DatadogCore (2.30.0): - - DatadogInternal (= 2.30.0) - - DatadogCrashReporting (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogCore (2.30.2): + - DatadogInternal (= 2.30.2) + - DatadogCrashReporting (2.30.2): + - DatadogInternal (= 2.30.2) - PLCrashReporter (~> 1.12.0) - - DatadogInternal (2.30.0) - - DatadogLogs (2.30.0): - - DatadogInternal (= 2.30.0) - - DatadogRUM (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogInternal (2.30.2) + - DatadogLogs (2.30.2): + - DatadogInternal (= 2.30.2) + - DatadogRUM (2.30.2): + - DatadogInternal (= 2.30.2) - DatadogSDKReactNative (2.12.3): - - DatadogCore (= 2.30.0) - - DatadogCrashReporting (= 2.30.0) - - DatadogLogs (= 2.30.0) - - DatadogRUM (= 2.30.0) - - DatadogTrace (= 2.30.0) - - DatadogWebViewTracking (= 2.30.0) + - DatadogCore (= 2.30.2) + - DatadogCrashReporting (= 2.30.2) + - DatadogLogs (= 2.30.2) + - DatadogRUM (= 2.30.2) + - DatadogTrace (= 2.30.2) + - DatadogWebViewTracking (= 2.30.2) - React-Core - DatadogSDKReactNative/Tests (2.12.3): - - DatadogCore (= 2.30.0) - - DatadogCrashReporting (= 2.30.0) - - DatadogLogs (= 2.30.0) - - DatadogRUM (= 2.30.0) - - DatadogTrace (= 2.30.0) - - DatadogWebViewTracking (= 2.30.0) + - DatadogCore (= 2.30.2) + - DatadogCrashReporting (= 2.30.2) + - DatadogLogs (= 2.30.2) + - DatadogRUM (= 2.30.2) + - DatadogTrace (= 2.30.2) + - DatadogWebViewTracking (= 2.30.2) - React-Core - DatadogSDKReactNativeSessionReplay (2.12.3): - DatadogSDKReactNative - - DatadogSessionReplay (= 2.30.0) + - DatadogSessionReplay (= 2.30.2) - DoubleConversion - glog - hermes-engine @@ -51,7 +51,7 @@ PODS: - Yoga - DatadogSDKReactNativeSessionReplay/Tests (2.12.3): - DatadogSDKReactNative - - DatadogSessionReplay (= 2.30.0) + - DatadogSessionReplay (= 2.30.2) - DoubleConversion - glog - hermes-engine @@ -74,24 +74,24 @@ PODS: - ReactCommon/turbomodule/core - Yoga - DatadogSDKReactNativeWebView (2.12.3): - - DatadogInternal (= 2.30.0) + - DatadogInternal (= 2.30.2) - DatadogSDKReactNative - - DatadogWebViewTracking (= 2.30.0) + - DatadogWebViewTracking (= 2.30.2) - React-Core - DatadogSDKReactNativeWebView/Tests (2.12.3): - - DatadogInternal (= 2.30.0) + - DatadogInternal (= 2.30.2) - DatadogSDKReactNative - - DatadogWebViewTracking (= 2.30.0) + - DatadogWebViewTracking (= 2.30.2) - React-Core - react-native-webview - React-RCTText - - DatadogSessionReplay (2.30.0): - - DatadogInternal (= 2.30.0) - - DatadogTrace (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogSessionReplay (2.30.2): + - DatadogInternal (= 2.30.2) + - DatadogTrace (2.30.2): + - DatadogInternal (= 2.30.2) - OpenTelemetrySwiftApi (= 1.13.1) - - DatadogWebViewTracking (2.30.0): - - DatadogInternal (= 2.30.0) + - DatadogWebViewTracking (2.30.2): + - DatadogInternal (= 2.30.2) - DoubleConversion (1.1.6) - fast_float (6.1.4) - FBLazyVector (0.76.9) diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index a3c426527..ff3b91232 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -19,14 +19,14 @@ Pod::Spec.new do |s| s.dependency "React-Core" # /!\ Remember to keep the versions in sync with DatadogSDKReactNativeSessionReplay.podspec - s.dependency 'DatadogCore', '2.30.0' - s.dependency 'DatadogLogs', '2.30.0' - s.dependency 'DatadogTrace', '2.30.0' - s.dependency 'DatadogRUM', '2.30.0' - s.dependency 'DatadogCrashReporting', '2.30.0' + s.dependency 'DatadogCore', '2.30.2' + s.dependency 'DatadogLogs', '2.30.2' + s.dependency 'DatadogTrace', '2.30.2' + s.dependency 'DatadogRUM', '2.30.2' + s.dependency 'DatadogCrashReporting', '2.30.2' # DatadogWebViewTracking is not available for tvOS - s.ios.dependency 'DatadogWebViewTracking', '2.30.0' + s.ios.dependency 'DatadogWebViewTracking', '2.30.2' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/**/*.{swift,json}' diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index c7c6300ef..0396ae323 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -201,16 +201,16 @@ dependencies { // This breaks builds if the React Native target is below 0.76.0. as it relies on Gradle 8.5.0. // To avoid this, we enforce 1.0.0-beta01 on RN < 0.76.0 if (reactNativeMinorVersion < 76) { - implementation("com.datadoghq:dd-sdk-android-rum:2.25.0") { + implementation("com.datadoghq:dd-sdk-android-rum:2.26.2") { exclude group: "androidx.metrics", module: "metrics-performance" } implementation "androidx.metrics:metrics-performance:1.0.0-beta01" } else { - implementation "com.datadoghq:dd-sdk-android-rum:2.25.0" + implementation "com.datadoghq:dd-sdk-android-rum:2.26.2" } - implementation "com.datadoghq:dd-sdk-android-logs:2.25.0" - implementation "com.datadoghq:dd-sdk-android-trace:2.25.0" - implementation "com.datadoghq:dd-sdk-android-webview:2.25.0" + implementation "com.datadoghq:dd-sdk-android-logs:2.26.2" + implementation "com.datadoghq:dd-sdk-android-trace:2.26.2" + implementation "com.datadoghq:dd-sdk-android-webview:2.26.2" implementation "com.google.code.gson:gson:2.10.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2" diff --git a/packages/react-native-babel-plugin/README.md b/packages/react-native-babel-plugin/README.md index e83d36fff..af4d09e60 100644 --- a/packages/react-native-babel-plugin/README.md +++ b/packages/react-native-babel-plugin/README.md @@ -40,10 +40,19 @@ You can configure the plugin to adjust how it processes your code, giving you co | Option | Type | Default | Description | |-----------------------|--------|---------|-------------| | `actionNameAttribute` | string | – | The chosen attribute name to use for action names. | +| `sessionReplay` | object | – | Session Replay configuration. | | `components` | object | – | Component tracking configuration. | --- +#### `sessionReplay` options + +| Option | Type | Default | Description | +|-----------------|---------|---------|-------------| +| `svgTracking` | boolean | true | Whether to track SVG assets in the context of Session Replay. | + +--- + #### `components` options | Option | Type | Default | Description | @@ -85,8 +94,11 @@ module.exports = { plugins: [ [ '@datadog/mobile-react-native-babel-plugin', - {actionNameAttribute: 'custom-prop-value'}, { + actionNameAttribute: 'custom-prop-value', + sessionReplay: { + svgTracking: true + }, components: { useContent: true, useNamePrefix: true, diff --git a/packages/react-native-babel-plugin/jest.config.js b/packages/react-native-babel-plugin/jest.config.js index dffbd51b0..ad893f9e1 100644 --- a/packages/react-native-babel-plugin/jest.config.js +++ b/packages/react-native-babel-plugin/jest.config.js @@ -1,5 +1,6 @@ module.exports = { transform: { '^.+\\.(t|j)sx?$': '@swc/jest' - } + }, + transformIgnorePatterns: ['node_modules/(?!(uuid)/)'] }; diff --git a/packages/react-native-babel-plugin/package.json b/packages/react-native-babel-plugin/package.json index 08751aa1e..208fa1a23 100644 --- a/packages/react-native-babel-plugin/package.json +++ b/packages/react-native-babel-plugin/package.json @@ -17,6 +17,9 @@ }, "license": "Apache-2.0", "main": "lib/commonjs/index", + "bin": { + "datadog-generate-sr-assets": "./lib/commonjs/cli/generate-sr-assets.js" + }, "files": [ "src/**", "lib" @@ -39,8 +42,14 @@ }, "dependencies": { "@babel/core": "^7.27.1", + "@babel/generator": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/parser": "^7.28.4", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.27.1", + "fast-glob": "^3.3.3", + "svgo": "^4.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@babel/cli": "^7.27.2", diff --git a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts new file mode 100644 index 000000000..0f3eabd48 --- /dev/null +++ b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { transformSync } from '@babel/core'; +import glob from 'fast-glob'; +import fs from 'fs'; +import path from 'path'; + +import babelPlugin from '../index'; +import { + clearAssetsDir, + getAssetsPath +} from '../libraries/react-native-svg/processing/fs'; + +type SvgIndexEntry = { + offset: number; + length: number; +}; + +type SvgIndex = Record; + +/** + * Merges all individual SVG files into assets.bin and creates an index in assets.json. + * This function reads all .svg files from the assets directory and packs them into + * a single binary file with an accompanying JSON index for efficient lookup. + * + * @param assetsDir - Absolute path to the assets directory + */ +function mergeSvgAssets(assetsDir: string) { + try { + const binName = 'assets.bin'; + const jsonName = 'assets.json'; + + const binPath = path.resolve(assetsDir, binName); + const jsonPath = path.resolve(assetsDir, jsonName); + const index: SvgIndex = {}; + + let offset = 0; + + // Read SVG files from directory + let files: string[] = []; + files = fs + .readdirSync(assetsDir) + .filter(f => f.endsWith('.svg')) + .sort(); + + let added = 0; + + for (const f of files) { + const id = path.basename(f, path.extname(f)); + if (index[id]) { + continue; + } + + try { + const svg = fs.readFileSync(path.join(assetsDir, f), 'utf8'); + const buf = Buffer.from(svg, 'utf8'); + const length = buf.length; + + fs.appendFileSync(binPath, buf); + index[id] = { offset, length }; + offset += length; + added++; + } catch (err) { + console.warn(`[mergeSvgAssets] Failed to process ${f}:`, err); + } + } + + // Write final index + try { + fs.writeFileSync(jsonPath, JSON.stringify(index, null, 2)); + } catch (err) { + console.error('[mergeSvgAssets] Failed to write assets index', err); + return; + } + + if (added > 0) { + console.info( + `\nPacked ${added} new Session Replay assets -> total: ${ + Object.keys(index).length + }` + ); + } + } catch (err) { + console.error( + '[mergeSvgAssets] Unexpected error during asset merge', + err + ); + } +} + +/** + * CLI tool to pre-generate SVG assets for Session Replay. + * + * This command scans the user's codebase for React components that use SVG elements, + * processes them through the Datadog Babel plugin, and extracts assets + * into the Session Replay module's assets directory. + * + * This should be ran before `pod install` on iOS to ensure that native asset + * references are available during the build process. + * + * Usage: + * npx @datadog/mobile-react-native-babel-plugin generate-sr-assets + * or + * npx datadog-generate-sr-assets + */ +function generateSessionReplayAssets() { + const rootDir = process.cwd(); + const assetsPath = getAssetsPath(); + + if (!assetsPath) { + process.exit(0); + } + + console.info(`Scanning for session replay assets in ${rootDir}...`); + + // Clear existing assets to ensure a fresh state + clearAssetsDir(assetsPath); + + const files = glob.sync(['**/*.{js,jsx,ts,tsx}'], { + cwd: rootDir, + absolute: true, + ignore: [ + '**/node_modules/**', + '**/lib/**', + '**/dist/**', + '**/build/**', + '**/*.d.ts', + '**/*.test.*', + '**/*.spec.*', + '**/*.config.js', + '**/__tests__/**', + '**/__mocks__/**' + ] + }); + + let errorCount = 0; + const errors: Array<{ file: string; error: string }> = []; + + for (const file of files) { + try { + const code = fs.readFileSync(file, 'utf8'); + + // Transform the file using the Babel plugin with SVG tracking enabled + transformSync(code, { + filename: file, + plugins: [ + [ + babelPlugin, + { + sessionReplay: { + svgTracking: true + } + } + ] + ], + presets: [ + [ + '@babel/preset-typescript', + { isTSX: true, allExtensions: true } + ], + '@babel/preset-react' + ], + // Don't generate actual output, we just want the asset generation + code: false, + ast: false + }); + } catch (error) { + errorCount++; + const errorMessage = + error instanceof Error ? error.message : String(error); + errors.push({ file, error: errorMessage }); + } + } + + if (errorCount > 0) { + console.warn(`${errorCount} files had errors`); + } + + // Merge all individual SVG files into assets.bin and assets.json + mergeSvgAssets(assetsPath); + + if (errorCount > 0) { + console.info( + 'Asset generation finished, but some files encountered errors.' + ); + } + + console.info('Your assets are now ready to be used by Session Replay.'); +} + +// TODO: Add flag support [e.g., --verbose] (RUM-12186) +generateSessionReplayAssets(); diff --git a/packages/react-native-babel-plugin/src/constants/global.ts b/packages/react-native-babel-plugin/src/constants/global.ts index edbefe531..019974356 100644 --- a/packages/react-native-babel-plugin/src/constants/global.ts +++ b/packages/react-native-babel-plugin/src/constants/global.ts @@ -9,6 +9,9 @@ export const PluginConstants = { } as const; export const defaultPluginOptions = { + sessionReplay: { + svgTracking: true + }, components: { useContent: true, useNamePrefix: true, diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index d5968dd87..d49caa24d 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -13,6 +13,8 @@ import { insertRumActionImport } from './actions/rum'; import { defaultPluginOptions } from './constants'; +import { getAssetsPath } from './libraries/react-native-svg/processing/fs'; +import { ReactNativeSVG } from './libraries/react-native-svg'; import type { PluginAPI, PluginOptions, @@ -27,18 +29,42 @@ export default declare( const options = { ...opt, + sessionReplay: { + ...defaultPluginOptions.sessionReplay, + ...opt.sessionReplay + }, components: { ...defaultPluginOptions.components, ...opt.components } }; + let reactNativeSVG: ReactNativeSVG | null = null; + + let assetsPath: string | null = null; + return { + pre() { + if (!options.sessionReplay.svgTracking) { + return; + } + + if (!assetsPath) { + assetsPath = getAssetsPath(); + } + + if (!reactNativeSVG && assetsPath) { + reactNativeSVG = new ReactNativeSVG( + api.types, + process.cwd(), + assetsPath + ); + } + }, visitor: { Program: { enter(path, state) { const pluginState: PluginPassState = state; - const { path: p, name } = getFileInfo(this); if (p?.includes('node_modules')) { @@ -46,6 +72,7 @@ export default declare( } pluginState.fileInfo = { path: p, name }; + pluginState.reactNativeSVG = reactNativeSVG; insertSetupFlag(path, state, api.types); loadImportMap(path, api.types, pluginState, options); @@ -102,6 +129,8 @@ export default declare( pluginState, options ); + + pluginState.reactNativeSVG?.processItem(path, name); } } }; diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/constants.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/constants.ts new file mode 100644 index 000000000..9a25f7bc7 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/constants.ts @@ -0,0 +1,291 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +export const svgSupportedNames = ['Svg', 'SvgUri']; + +export const xmlNamespace = ['xmlns', 'http://www.w3.org/2000/svg']; + +export const rnAttributeNames = new Set([ + '__self', + 'accessibilityLabel', + 'accessibilityRole', + 'style' +]); + +export const rnSvgArrayAttributeValues = new Set([ + 'stroke-dasharray', // this attribute is already in the web compliant format, since the RN to web conversion already occured when this is used + 'points', + 'gradientTransform', + 'stdDeviation', + 'values' +]); + +// All these values are deprecated, but still allowed in react-native-svg +export const rnSvgTransformAttributeValues = new Set([ + 'translateX', + 'translateY', + 'scaleX', + 'scaleY', + 'rotate', + 'skewX', + 'skewY', + 'matrix' +]); + +export const svgElements = new Set([ + 'a', + 'animate', + 'animateMotion', + 'animateTransform', + 'circle', + 'clipPath', + 'defs', + 'desc', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'filter', + 'foreignObject', + 'g', + 'image', + 'line', + 'linearGradient', + 'marker', + 'mask', + 'metadata', + 'mpath', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialGradient', + 'rect', + 'script', + 'set', + 'stop', + 'style', + 'svg', + 'switch', + 'symbol', + 'text', + 'textPath', + 'title', + 'tspan', + 'use', + 'view' +]); + +export const svgAttributesCC = new Set([ + 'attributeName', + 'attributeType', + 'baseFrequency', + 'calcMode', + 'clipPathUnits', + 'diffuseConstant', + 'edgeMode', + 'gradientTransform', + 'gradientUnits', + 'kernelMatrix', + 'kernelUnitLength', + 'keyPoints', + 'keySplines', + 'keyTimes', + 'lengthAdjust', + 'limitingConeAngle', + 'markerHeight', + 'markerUnits', + 'markerWidth', + 'maskContentUnits', + 'maskUnits', + 'numOctaves', + 'pathLength', + 'patternContentUnits', + 'patternTransform', + 'patternUnits', + 'pointsAtX', + 'pointsAtY', + 'pointsAtZ', + 'preserveAlpha', + 'preserveAspectRatio', + 'primitiveUnits', + 'refX', + 'refY', + 'repeatCount', + 'repeatDur', + 'specularConstant', + 'specularExponent', + 'spreadMethod', + 'startOffset', + 'stdDeviation', + 'stitchTiles', + 'surfaceScale', + 'systemLanguage', + 'tableValues', + 'textLength', + 'viewBox', + 'xChannelSelector', + 'yChannelSelector' +]); + +export const svgAttributesKC = new Set([ + 'accumulate', + 'additive', + 'alignment-baseline', + 'amplitude', + 'azimuth', + 'baseline-shift', + 'begin', + 'bias', + 'by', + 'class', + 'clip', + 'clip-path', + 'clip-rule', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'cursor', + 'cx', + 'cy', + 'd', + 'decoding', + 'direction', + 'display', + 'divisor', + 'dominant-baseline', + 'dur', + 'dx', + 'dy', + 'elevation', + 'end', + 'exponent', + 'fetchpriority', + 'fill', + 'fill-opacity', + 'fill-rule', + 'filter', + 'filterUnits', + 'flood-color', + 'flood-opacity', + 'font-family', + 'font-size', + 'font-size-adjust', + 'font-style', + 'font-variant', + 'font-weight', + 'fr', + 'from', + 'fx', + 'fy', + 'height', + 'href', + 'id', + 'image-rendering', + 'in', + 'in2', + 'intercept', + 'k1', + 'k2', + 'k3', + 'k4', + 'lang', + 'letter-spacing', + 'lighting-color', + 'marker-end', + 'marker-mid', + 'marker-start', + 'mask', + 'mask-type', + 'matrix', + 'max', + 'media', + 'method', + 'min', + 'mode', + 'offset', + 'opacity', + 'operator', + 'order', + 'orient', + 'origin', + 'overflow', + 'paint-order', + 'path', + 'pointer-events', + 'points', + 'r', + 'radius', + 'restart', + 'result', + 'rotate', + 'rx', + 'ry', + 'scale', + 'seed', + 'shape-rendering', + 'side', + 'slope', + 'spacing', + 'stop-color', + 'stop-opacity', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'style', + 'tabindex', + 'target', + 'targetX', + 'targetY', + 'text-anchor', + 'text-decoration', + 'text-rendering', + 'to', + 'transform', + 'transform-origin', + 'type', + 'unicode-bidi', + 'values', + 'vector-effect', + 'visibility', + 'width', + 'word-spacing', + 'writing-mode', + 'x', + 'x1', + 'x2', + 'y', + 'y1', + 'y2', + 'z' +]); diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts new file mode 100644 index 000000000..baf55807f --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/HandlerResolver.ts @@ -0,0 +1,76 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; + +import { LocalSvgHandler } from './LocalSvgHandler'; +import { RNSvgHandler } from './RNSvgHandler'; +import type { SvgHandler } from './SvgHandler'; +import { UriSvgHandler } from './UriSvgHandler'; + +type Resolver = () => SvgHandler; + +type Dependencies = { + t: typeof Babel.types; + path: Babel.NodePath; + name: string; + localSvgMap: Record; +}; + +export class HandlerResolver { + private static registry: Record; + private static dependencies: Dependencies | null = null; + + /** + * Registers handler factories for supported JSX element types and stores shared dependencies. + * This method must be called before invoking `create()`, as it initializes the internal registry + * with handler constructors that are parameterized with the provided Babel context and configuration. + * + * @param dependencies - Shared Babel-related dependencies and contextual information, + * including `types`, the current JSX `path`, tag `name`, and the `localSvgMap`. + */ + static configure(dependencies: Dependencies) { + this.dependencies = dependencies; + const { t, path, name, localSvgMap } = dependencies; + + HandlerResolver.registry = { + RNSvgHandler: () => new RNSvgHandler(t, path, name), + UriSvgHandler: () => new UriSvgHandler(t, path, name), + LocalSvgHandler: () => + new LocalSvgHandler(t, path, name, localSvgMap) + }; + } + + /** + * Resolves and returns the appropriate handler instance based on the JSX tag name. + * @throws Error if `configure()` has not been called prior to invocation. + * + * @returns The resolved handler instance or `null` if no match exists. + */ + static create() { + if (!this.dependencies) { + throw new Error('HandlerResolver must be configured before use.'); + } + + const { name, localSvgMap } = this.dependencies; + + switch (name) { + case 'Svg': { + return HandlerResolver.registry.RNSvgHandler(); + } + + case 'SvgUri': { + return HandlerResolver.registry.UriSvgHandler(); + } + + default: { + return localSvgMap[name] + ? HandlerResolver.registry.LocalSvgHandler() + : null; + } + } + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts new file mode 100644 index 000000000..0504e0a10 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/LocalSvgHandler.ts @@ -0,0 +1,105 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; +import fs from 'fs'; + +import { getNodeName } from '../../../utils'; +import { handleSvgDimensions } from '../processing/attributes'; + +import type { SvgHandler } from './SvgHandler'; + +/** + * Internal handler that inlines locally imported SVG components into + * JSX output during Babel transformation. + * + * The `LocalSvgHandler` resolves SVG imports from disk, caches their raw + * contents, and extracts relevant dimension attributes (e.g., width, height) + * from the JSX element for use in the generated SVG markup. + */ +export class LocalSvgHandler implements SvgHandler { + constructor( + private types: typeof Babel.types, + private path: Babel.NodePath, + private name: string, + private localSvgMap: Record + ) { + // no-op + } + + /** + * Retrieves and returns the contents of a local SVG file corresponding to the JSXElement tag name. + * If the file hasn't been read yet, it reads the SVG content from disk and caches it in `localSvgMap`. + * Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object. + * + * @param dimensions - Object to collect extracted width/height info. + * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`. + */ + transformSvgNode(dimensions: Record) { + if (!this.localSvgMap[this.name]) { + return undefined; + } + + const { path, content } = this.localSvgMap[this.name]; + + if (!content) { + this.localSvgMap[this.name].content = fs.readFileSync(path, 'utf8'); + } + + this.processAttributes( + this.types, + this.path, + this.path.node, + dimensions + ); + + return this.localSvgMap[this.name].content; + } + + /** + * Processes the attributes of a JSXElement to extract relevant SVG metadata. + * Specifically identifies and handles dimension-related attributes (e.g., width, height), + * storing them into the provided `dimensions` object. Ignores spread attributes. + * + * @param t - Babel types helper. + * + * @param rootElementPath - The path of the root JSX element containing the SVG. + * Used to locate lexical scopes (component or program) for resolving variable references. + * May be `null` if no traversal context is available. + * @param jsxElement - The JSXElement whose attributes will be processed. + * @param dimensions - Object to collect extracted width/height info. + */ + private processAttributes( + t: typeof Babel.types, + rootElementPath: Babel.NodePath | null, + jsxElement: Babel.types.JSXElement, + dimensions: Record + ) { + const el = jsxElement.openingElement; + + for (const attr of el.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + continue; + } + + const attrName = getNodeName(t, attr); + if (!attrName) { + continue; + } + + // Handle SVG dimensions + handleSvgDimensions( + t, + rootElementPath, + attr, + attrName, + dimensions, + this.name, + this.name + ); + } + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts new file mode 100644 index 000000000..d65c3cfdb --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/RNSvgHandler.ts @@ -0,0 +1,295 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; +import generate from '@babel/generator'; +import { jsxAttribute, jSXIdentifier, stringLiteral } from '@babel/types'; + +import { getNodeName } from '../../../utils'; +import { svgElements, svgSupportedNames, xmlNamespace } from '../constants'; +import { + buildTransformStringAttribute, + handleArrayAttributes, + handleJoinedTransformAttributes, + handleRegularAttributes, + handleRNSpecificAttributes, + handleSeparateTransformAttributes, + handleSvgDimensions, + validateAttribute +} from '../processing/attributes'; +import { convertAttributeCasing } from '../utils'; + +import type { SvgHandler } from './SvgHandler'; + +/** + * Internal handler that transforms React Native–style SVG JSXElements into + * web-compatible SVG output during Babel processing. + * + * The `RNSvgHandler` normalizes tag names and attributes, extracts + * width/height dimensions, consolidates transform attributes, and ensures + * correct SVG namespace declarations. + */ +export class RNSvgHandler implements SvgHandler { + constructor( + private types: typeof Babel.types, + private path: Babel.NodePath, + private name: string + ) { + // no-op + } + + /** + * Processes a JSXElement representing an SVG node and transforms it into + * a web compliant SVG string with updated attributes and dimensions. + * Stores the transformed SVG string and dimensions in `svgMap`, keyed by a UUID. + * + * @param dimensions - Object to collect extracted width/height info. + * @returns Transformed SVG JSX string, or undefined if the tag is not supported. + */ + transformSvgNode(dimensions: Record) { + if (!svgSupportedNames.includes(this.name)) { + return undefined; + } + + const clone = this.types.cloneNode(this.path.node, true); + + this.transformElement(this.types, this.path, clone, dimensions); + this.setNamespace(this.types, clone); + + const output = generate(clone).code; + + return output; + } + + /** + * Sets the `xmlns` attribute on the root `` tag to ensure proper namespacing in web output. + * + * @param t - Babel types helper. + * @param el - JSXElement node, expected to be an `` element. + */ + private setNamespace(t: typeof Babel.types, el: Babel.types.JSXElement) { + const name = getNodeName(t, el.openingElement.name); + + if (name === 'svg') { + el.openingElement.attributes.push( + jsxAttribute( + jSXIdentifier(xmlNamespace[0]), + stringLiteral(xmlNamespace[1]) + ) + ); + } + } + + /** + * Transforms an individual JSXElement by: + * - Converting tag casing to be web-compatible. + * - Processing and sanitizing attributes. + * - Recursively handling children. + * + * @param t - Babel types helper. + * @param el - JSXElement node to transform. + * @param dimensions - Optional object to collect extracted width/height info. + */ + private transformElement( + t: typeof Babel.types, + rootElementPath: Babel.NodePath | null, + el: Babel.types.JSXElement, + dimensions: Record + ) { + const openingNode = el.openingElement.name; + const isJSXIdentifierOpen = t.isJSXIdentifier(openingNode); + + // Fix casing for openingElement + if (isJSXIdentifierOpen) { + openingNode.name = convertAttributeCasing(openingNode.name); + if (!svgElements.has(openingNode.name)) { + throw new Error( + `RNSvgHandler[transformElement]: Failed to transform element: "${openingNode.name}" is not supported` + ); + } + } + + const closingNode = el.closingElement?.name; + const isJSXIdentifierClose = t.isJSXIdentifier(closingNode); + + // Fix casing for closingElement + if (isJSXIdentifierClose) { + closingNode.name = convertAttributeCasing(closingNode.name); + + if (!svgElements.has(closingNode.name)) { + throw new Error( + `RNSvgHandler[transformElement]: Failed to transform element: "${closingNode.name}" is not supported` + ); + } + } + + this.processAttributes(t, rootElementPath, el, dimensions); + } + + /** + * Recursively traverses the children of a JSXElement and applies `transformElement` + * to each child that is itself a JSXElement. + * + * @param t - Babel types helper. + * @param rootElementPath - The path of the root JSX element containing the SVG. + * Used to locate lexical scopes (component or program) for resolving variable references. + * May be `null` if no traversal context is available. + * @param jsxElement - Parent JSXElement whose children will be transformed. + * @param dimensions - Optional object to propagate width/height info through child elements. + */ + private traverseAndTransformChildren( + t: typeof Babel.types, + rootElementPath: Babel.NodePath | null, + jsxElement: Babel.types.JSXElement, + dimensions: Record = {} + ) { + for (const child of jsxElement.children) { + if (t.isJSXElement(child)) { + this.transformElement(t, rootElementPath, child, dimensions); + } + } + } + + /** + * Processes and transforms all attributes of a given JSXElement: + * - Removes invalid or unsupported attributes. + * - Normalizes attribute casing and naming. + * - Consolidates transform-related attributes into a single `transform` string. + * - Extracts dimensions and stores them in the provided `dimensions` object. + * - Recursively applies transformations to child elements. + * + * @param t - Babel types helper. + * @param rootElementPath - The path of the root JSX element containing the SVG. + * Used to locate lexical scopes (component or program) for resolving variable references. + * May be `null` if no traversal context is available. + * @param jsxElement - JSXElement whose attributes are to be processed. + * @param dimensions - Optional object to collect extracted width/height info. + */ + private processAttributes( + t: typeof Babel.types, + rootElementPath: Babel.NodePath | null, + jsxElement: Babel.types.JSXElement, + dimensions: Record = {} + ) { + const el = jsxElement.openingElement; + const name = getNodeName(t, el); + const transformsArray: { name: string; value: string | number }[] = []; + const attributes = Array.from(el.attributes.entries()).reverse(); + + for (const [index, attr] of attributes) { + try { + if (!t.isJSXAttribute(attr)) { + el.attributes.splice(index, 1); + continue; + } + + if (!t.isJSXIdentifier(attr.name)) { + el.attributes.splice(index, 1); + continue; + } + + // Handle RN style attribute & non-supported attributes + const rnAttributesHandled = handleRNSpecificAttributes( + t, + attr, + attr.name.name, + transformsArray + ); + + if (rnAttributesHandled) { + el.attributes.splice(index, 1); + continue; + } + + // Validate whether the attribute is valid + const { attrName, isInvalidAttribute } = validateAttribute( + attr.name.name + ); + + if (isInvalidAttribute) { + el.attributes.splice(index, 1); + continue; + } + + /* If we reach this point we know we have a valid attribute name */ + + // Handle SVG dimensions + + const { + resolved: dimensionsHandled, + remove: removeDimension + } = handleSvgDimensions( + t, + rootElementPath, + attr, + attrName, + dimensions, + name, + 'svg' + ); + + if (dimensionsHandled) { + // If dimension is invalid or if it's a variable that was not initialized in the file + // We remove the attribute and assign a value in the native layer where we have access to wireframe's dimensions + if (removeDimension) { + el.attributes.splice(index, 1); + } + continue; + } + + // Set the formatted attibute name to our cloned element + attr.name.name = attrName; + + // Handle array attributes + const arrayAttributesHandled = handleArrayAttributes( + t, + attr, + attrName + ); + + if (arrayAttributesHandled) { + continue; + } + + // Handle separate transform attributes + const separateTransformAttributesHandled = handleSeparateTransformAttributes( + t, + attr, + attrName, + transformsArray + ); + + if (separateTransformAttributesHandled) { + el.attributes.splice(index, 1); + continue; + } + + // Handle joined transform attributes + const joinedTransformAttributesHandled = handleJoinedTransformAttributes( + t, + attr, + attrName, + transformsArray + ); + + if (joinedTransformAttributesHandled) { + el.attributes.splice(index, 1); + continue; + } + + handleRegularAttributes(t, attr); + } catch (error) { + console.error('ReactNativeSVG[processAttributes]: ', error); + } + } + + // Create & Set a new transform attribute based on the element's transform attributes + buildTransformStringAttribute(el, transformsArray); + + // Goes through an elements children and transforms its properties + this.traverseAndTransformChildren(t, null, jsxElement, dimensions); + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/SvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/SvgHandler.ts new file mode 100644 index 000000000..c0d5369fd --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/SvgHandler.ts @@ -0,0 +1,9 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +export interface SvgHandler { + transformSvgNode(dimensions: Record): string | undefined; +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts new file mode 100644 index 000000000..89caccf03 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/handlers/UriSvgHandler.ts @@ -0,0 +1,100 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; + +import { getNodeName } from '../../../utils'; +import { handleSvgDimensions } from '../processing/attributes'; + +import type { SvgHandler } from './SvgHandler'; + +/** + * Handles extraction and transformation of SVG data from JSX elements that reference SVGs by URI. + * + * The `UriSvgHandler` inspects a JSXElement, extracts its `uri` attribute to locate the SVG source, + * and captures its dimensional attributes (e.g., `width`, `height`) for further processing. + * + */ +export class UriSvgHandler implements SvgHandler { + constructor( + private types: typeof Babel.types, + private path: Babel.NodePath, + private name: string + ) { + // no-op + } + + /** + * Retrieves and returns the URI of an SVG corresponding. + * Also extracts and stores width/height dimensions from the JSX attributes into the `dimensions` object. + * + * @param dimensions - Object to collect extracted width/height info. + * @returns Raw SVG string content from the local file, or undefined if the tag is not found in `localSvgMap`. + */ + transformSvgNode(dimensions: Record) { + const uri = this.processAttributes( + this.types, + this.path, + this.path.node, + dimensions + ); + + return uri; + } + + /** + * Processes the attributes of a JSXElement to extract relevant SVG metadata. + * Specifically identifies and handles dimension-related attributes (e.g., width, height), + * storing them into the provided `dimensions` object. Ignores spread attributes. + * + * @param t - Babel types helper. + * @param rootElementPath - The path of the root JSX element containing the SVG. + * Used to locate lexical scopes (component or program) for resolving variable references. + * May be `null` if no traversal context is available. + * @param jsxElement - The JSXElement whose attributes will be processed. + * @param dimensions - Object to collect extracted width/height info. + */ + private processAttributes( + t: typeof Babel.types, + rootElementPath: Babel.NodePath | null, + jsxElement: Babel.types.JSXElement, + dimensions: Record + ) { + const el = jsxElement.openingElement; + + let uri: string | undefined; + + for (const attr of el.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + continue; + } + + const attrName = getNodeName(t, attr); + if (!attrName) { + continue; + } + + // Handle SVG dimensions + handleSvgDimensions( + t, + rootElementPath, + attr, + attrName, + dimensions, + this.name, + this.name + ); + + if (attrName === 'uri') { + if (t.isStringLiteral(attr.value)) { + uri = attr.value.value; + } + } + } + + return uri; + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts new file mode 100644 index 000000000..42d2a2a08 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts @@ -0,0 +1,398 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; +import * as parser from '@babel/parser'; +import traverse from '@babel/traverse'; +import { jsxIdentifier, stringLiteral } from '@babel/types'; +import { createHash } from 'crypto'; +import glob from 'fast-glob'; +import fs from 'fs'; +import pathN from 'path'; +import { optimize } from 'svgo'; +import { v4 as uuidv4 } from 'uuid'; + +import { getNodeName } from '../../utils'; + +import { HandlerResolver } from './handlers/HandlerResolver'; +import { writeAssetToDisk } from './processing/fs'; + +type SvgOffset = { + start: number; + length: number; +}; + +/** + * Internal processor responsible for detecting, transforming, and wrapping + * React Native SVG components for use with Session Replay. + * + * This class scans the project for `.svg` imports, builds a mapping between + * JSX identifiers and SVG files, and transforms JSX SVG nodes into + * optimized, web-compatible SVG markup. Each transformed element is then + * wrapped in a `SessionReplayView.Privacy` component with metadata used by + * the native Session Replay layer. + */ +export class ReactNativeSVG { + svgMap: Record = {}; + + svgOffset: Record = {}; + + localSvgMap: Record = {}; + + constructor( + private t: typeof Babel.types, + private rootDir: string, + private assetsPath: string + ) { + this.buildSvgMap(); + } + + /** + * Scans all source files in the project to detect `.svg` imports and builds a mapping + * of JSX identifiers to their corresponding SVG file paths. This is done by parsing each + * file's AST and collecting `import` or `export` declarations that reference `.svg` files. + * + * The collected mappings are stored in `localSvgMap`, keyed by the local/imported variable + * names (e.g., `Logo`, `IconSearch`), with their values pointing to the resolved file path. + * + * This method ignores files in `node_modules`, `lib`, and `dist`, as well as `.d.ts`, test, + * and config files. + */ + buildSvgMap() { + // TODO: Support aliased paths (RUM-12185) + const files = glob.sync( + ['**/*.{js,jsx,ts,tsx}', '**/*.{js,jsx,ts,tsx}'], + { + cwd: this.rootDir, + absolute: true, + ignore: [ + '**/node_modules/**', + '**/lib/**', + '**/dist/**', + '**/*.d.ts', + '**/*.test.*', + '**/*.config.js' + ] + } + ); + + for (const file of files) { + try { + const code = fs.readFileSync(file, 'utf8'); + if (!code) { + continue; + } + + const ast = parser.parse(code, { + sourceType: 'module', + plugins: [ + 'jsx', + 'typescript', + 'exportDefaultFrom', + 'classProperties', + 'dynamicImport' + ] + }); + + traverse(ast, { + ImportDeclaration: path => { + const source = path.node.source.value; + if (!source.endsWith('.svg')) { + return; + } + + const resolved = pathN.resolve( + pathN.dirname(file), + source + ); + for (const spec of path.node.specifiers) { + const name = getNodeName(this.t, spec.local.name); + if (name) { + this.localSvgMap[name] = { + path: resolved + }; + } + } + }, + ExportNamedDeclaration: path => { + const source = path.node.source?.value; + if (!source?.endsWith('.svg')) { + return; + } + + const resolved = pathN.resolve( + pathN.dirname(file), + source + ); + for (const spec of path.node.specifiers) { + if (spec.type === 'ExportSpecifier') { + const name = getNodeName( + this.t, + spec.local.name + ); + if (name) { + this.localSvgMap[name] = { + path: resolved + }; + } + } else { + console.warn( + `[buildSvgMap]: Unhandled export specifier type: ${spec.type}` + ); + } + } + } + }); + } catch (err) { + console.error(`[buildSvgMap]: \n File: ${file}\n`, err); + } + } + } + + /** + * Processes a JSXElement representing an SVG-based component and transforms it into + * a web-compliant SVG string with normalized attributes and extracted dimensions. + * The resulting SVG content and its metadata (e.g., width/height) are stored in `svgMap`, + * keyed by a generated UUID for later reference. + * + * Internally, the appropriate handler is selected based on the tag name and used to + * perform the transformation. + * + * @param path - Babel NodePath pointing to the JSXElement to process. + * @param name - JSX tag name (e.g., 'Svg', 'Logo') used to resolve the appropriate handler. + * @returns An object containing the original SVG string and its optimized version, + * or `undefined` if no transformation could be performed. + */ + processItem(path: Babel.NodePath, name: string) { + try { + const dimensions: { width?: string; height?: string } = {}; + + if (path.node?.extra?.__wrappedForSR) { + return; + } + + HandlerResolver.configure({ + t: this.t, + path, + name, + localSvgMap: this.localSvgMap + }); + + const handler = HandlerResolver.create(); + const output = handler?.transformSvgNode(dimensions); + + if (!output) { + return; + } + + const id = uuidv4(); + + const optimized = output.startsWith('http') + ? output + : optimize(output, { + multipass: true, + plugins: ['preset-default'] + }).data; + + const hash = createHash('md5') + .update(optimized, 'utf8') + .digest('hex'); + + const wrapper = this.wrapElementForSessionReplay( + this.t, + path, + id, + hash, + dimensions + ); + path.replaceWith(wrapper); + + path.node.extra = { + __wrappedForSR: true + }; + + this.svgMap[id] = { + file: optimized, + ...dimensions + }; + + writeAssetToDisk(this.assetsPath, id, hash, optimized); + + return { original: output, optimized }; + } catch (err) { + console.warn(err); + return { original: null, optimized: null }; + } + } + + /** + * Wraps a JSX element with a `SessionReplayView.Privacy` component + * and injects metadata attributes used by Session Replay. + * + * The resulting element is transformed into: + * ```tsx + * + * {originalElement} + * + * ``` + * + * This transformation ensures the element is identifiable on the native side + * while preserving its layout and interaction behavior. + * + * @param t - Babel types helper used to build and manipulate AST nodes. + * @param path - The current JSXElement node path being transformed. + * @param id - The unique native identifier assigned to the element. + * @param hash - A content hash used to reference the corresponding resource. + * @param dimensions - Optional width and height metadata to include in the attributes. + * @returns A new `JSXElement` AST node wrapped in `SessionReplayView.Privacy`. + */ + private wrapElementForSessionReplay( + t: typeof Babel.types, + path: Babel.NodePath, + id: string, + hash: string, + dimensions: { width?: string; height?: string } + ) { + const el = path.node; + const { width, height } = dimensions; + + el.extra = { + __wrappedForSR: true + }; + + const styleProp = t.jsxAttribute( + t.jsxIdentifier('style'), + t.jsxExpressionContainer( + t.objectExpression([ + t.objectProperty( + t.identifier('flexShrink'), + t.numericLiteral(1) + ) + ]) + ) + ); + + const props = [ + t.objectProperty(t.identifier('type'), t.stringLiteral('svg')), + t.objectProperty(t.identifier('hash'), t.stringLiteral(hash)) + ]; + + if (width) { + props.push( + t.objectProperty(t.identifier('width'), t.stringLiteral(width)) + ); + } + + if (height) { + props.push( + t.objectProperty( + t.identifier('height'), + t.stringLiteral(height) + ) + ); + } + + const attributeProp = t.jsxAttribute( + t.jsxIdentifier('attributes'), + t.jsxExpressionContainer(t.objectExpression(props)) + ); + + const attributesNode = [ + t.jsxAttribute(jsxIdentifier('nativeID'), stringLiteral(id)), + // https://reactnative.dev/docs/view#collapsable + t.jsxAttribute( + t.jsxIdentifier('collapsable'), + t.jsxExpressionContainer(t.booleanLiteral(false)) + ), + // https://reactnative.dev/docs/view#pointerevents + t.jsxAttribute( + t.jsxIdentifier('pointerEvents'), + t.stringLiteral('box-none') + ), + attributeProp, + styleProp + ]; + + const viewWrapper = t.jsxElement( + t.jsxOpeningElement( + t.jsxMemberExpression( + t.jsxIdentifier('SessionReplayView'), + t.jsxIdentifier('Privacy') + ), + attributesNode, + false + ), + t.jsxClosingElement( + t.jsxMemberExpression( + t.jsxIdentifier('SessionReplayView'), + t.jsxIdentifier('Privacy') + ) + ), + [el], + false + ); + + this.ensureSessionReplayImport(t, path); + return viewWrapper; + } + + /** + * Ensures that the `SessionReplayView` import from + * `@datadog/mobile-react-native-session-replay` exists in the file. + * + * If the import is not already present, this method injects a new + * `import { SessionReplayView } from '@datadog/mobile-react-native-session-replay'` + * declaration at the top of the program. + * + * @param t - Babel types helper used to create and check AST nodes. + * @param path - The current JSXElement node path from which to locate the program root. + */ + private ensureSessionReplayImport( + t: typeof Babel.types, + path: Babel.NodePath + ) { + const program = path.findParent(p => + p.isProgram() + ) as Babel.NodePath; + + const alreadyImported = program.node.body.some(node => { + return ( + t.isImportDeclaration(node) && + node.source.value === + '@datadog/mobile-react-native-session-replay' && + node.specifiers.some( + spec => + t.isImportSpecifier(spec) && + getNodeName(t, spec.imported) === 'SessionReplayView' + ) + ); + }); + + if (!alreadyImported) { + const importDecl = t.importDeclaration( + [ + t.importSpecifier( + t.identifier('SessionReplayView'), + t.identifier('SessionReplayView') + ) + ], + t.stringLiteral('@datadog/mobile-react-native-session-replay') + ); + program.unshiftContainer('body', importDecl); + } + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts new file mode 100644 index 000000000..7a413be11 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/attributes.ts @@ -0,0 +1,369 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; +import { jsxAttribute, jSXIdentifier, stringLiteral } from '@babel/types'; + +import { + evaluateStaticNode, + getJSXAttributeData, + parseStyleNode, + findIdentifierInScope, + getNodeName +} from '../../../utils/nodeProcessing'; +import { + rnAttributeNames, + rnSvgArrayAttributeValues, + rnSvgTransformAttributeValues, + svgAttributesCC, + svgAttributesKC +} from '../constants'; +import { convertStyleObjToCssObj, kebabCase } from '../utils'; + +import { + convertAttributeArrayValue, + convertAttributeTransformArray, + convertTransformArrayToString +} from './svg'; + +/** + * Handles React Native–specific attributes that are not directly supported in web SVG. + * Currently processes the `style` attribute by converting it to a flat inline CSS string. + * Extracts transform properties from style objects and adds them to the transforms array. + * + * @param t - Babel types helper. + * @param attr - JSX attribute node to process. + * @param attrName - Name of the attribute (e.g., 'style'). + * @param transformsArray - Optional array to collect transform operations from style objects. + * @returns True if the attribute was handled (and should be removed), false otherwise. + */ +export function handleRNSpecificAttributes( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute, + attrName: string, + transformsArray: { name: string; value: string | number }[] = [] +) { + if (rnAttributeNames.has(attrName)) { + if (attrName === 'style') { + const styleObj = parseStyleNode(t, attr); + if (styleObj) { + // Extract transform properties and add to transforms array + const transformProps = [ + 'translateX', + 'translateY', + 'scaleX', + 'scaleY', + 'rotate', + 'skewX', + 'skewY' + ]; + + for (const prop of transformProps) { + if (styleObj[prop] != null) { + transformsArray.push({ + name: prop, + value: styleObj[prop] + }); + delete styleObj[prop]; + } + } + + // Convert remaining (non-transform) properties to CSS + const cssObj = convertStyleObjToCssObj(styleObj); + const styleString = Object.entries(cssObj) + .map(([k, v]) => `${k}:${v}`) + .join(';'); + + if (styleString) { + attr.value = t.stringLiteral(styleString); + return false; // Keep the style attribute + } + + // If no CSS properties remain, the style should be removed + return true; + } + + return true; + } + } + + return false; +} + +/** + * Validates and normalizes an attribute name for use in web SVG: + * - Converts camelCase to kebab-case if needed. + * - Flags attributes not included in the allowed SVG attribute list. + * + * @param attrName - Name of the attribute to process. + * @returns An object with the normalized name and a flag indicating invalidity. + */ +export function validateAttribute(attrName: string) { + const result = { attrName, isInvalidAttribute: false }; + + if (rnSvgTransformAttributeValues.has(attrName)) { + return result; + } + + // This means that the attribute name is already in the right format + if (!svgAttributesCC.has(attrName)) { + result.attrName = kebabCase(attrName); + + // This means that the attribute name is not a valid SVG attribute and should be ignored + if (!svgAttributesKC.has(result.attrName)) { + result.isInvalidAttribute = true; + } + } + + return result; +} + +/** + * Extracts and serializes width/height values from `` elements, + * adding them to the provided `dimensions` object. + * + * @param t - Babel types helper. + * @param rootElementPath - The path of the root JSX element containing the SVG. + * Used to locate lexical scopes (component or program) for resolving variable references. + * May be `null` if no traversal context is available. + * @param attr - JSX attribute node (e.g., width or height). + * @param attrName - Name of the attribute. + * @param dimensions - Object to collect dimension key/value pairs. + * @param nodeName - Tag name (should be 'svg'). + * @returns An object describing the result of the operation: + * - `resolved`: `true` if the attribute was processed (even if unresolved or removed). + * - `remove`: `true` if the attribute should be removed from the AST (unresolved variable or fallback case). + */ +export function handleSvgDimensions( + t: typeof Babel.types, + rootElementPath: Babel.NodePath | null, + attr: Babel.types.JSXAttribute, + attrName: string, + dimensions: Record = {}, + nodeName: string | null, + expectedName: string +) { + const dimensionAttributes = ['width', 'height']; + + // Early exit for irrelevant attributes + if (nodeName !== expectedName || !dimensionAttributes.includes(attrName)) { + return { resolved: false, remove: false }; + } + + const setDimension = (value: string) => { + attr.value = t.stringLiteral(value); + dimensions[attrName] = value; + }; + + const val = attr.value; + + // Handle expression container (e.g., width={widthVal}) + if (t.isJSXExpressionContainer(val) && t.isIdentifier(val.expression)) { + const variableName = getNodeName(t, val.expression); + let resolvedValue: string | number | null = null; + + // If no root element, fallback immediately + if (!rootElementPath) { + return { resolved: true, remove: false }; + } + + // Try to find a variable in the nearest component/function/class scope + const componentPath = rootElementPath.findParent( + p => + p.isFunctionDeclaration() || + p.isArrowFunctionExpression() || + p.isFunctionExpression() || + p.isClassDeclaration() + ); + + if (componentPath && variableName) { + resolvedValue = findIdentifierInScope( + t, + componentPath, + variableName + ); + } + + // If not found, check the program (global) scope + if (resolvedValue === null && variableName) { + const programPath = rootElementPath.findParent(p => p.isProgram()); + if (programPath) { + resolvedValue = findIdentifierInScope( + t, + programPath, + variableName + ); + } + } + + // Use resolved value or fallback + if (resolvedValue != null) { + setDimension(resolvedValue.toString()); + return { resolved: true, remove: false }; + } else { + return { resolved: true, remove: true }; + } + } + + // Handle static JSX attribute values + const result = getJSXAttributeData(t, attr); + if (result.value) { + setDimension(result.value.toString()); + return { resolved: true, remove: false }; + } + + // Default fallback if unrecognized case + return { resolved: true, remove: true }; +} + +/** + * Converts JSX array-based attributes (e.g., `points`, `values`) into string literals + * for valid SVG usage. + * + * @param t - Babel types helper. + * @param attr - JSX attribute node to process. + * @param attrName - Name of the attribute (e.g., 'points'). + * @returns True if the attribute was converted, false otherwise. + */ +export function handleArrayAttributes( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute, + attrName: string +) { + if ( + rnSvgArrayAttributeValues.has(attrName) && + t.isJSXExpressionContainer(attr.value) && + t.isArrayExpression(attr.value.expression) + ) { + const result = convertAttributeArrayValue( + t, + attr.value.expression, + attrName + ); + + if (result) { + attr.value = t.stringLiteral(result); + return true; + } + } + return false; +} + +/** + * Handles transform-related attributes (e.g., `translateX`, `scaleY`) that are passed + * separately as individual props. Extracts them and stores in the transforms array. + * + * @param t - Babel types helper. + * @param attr - JSX attribute node. + * @param attrName - Name of the transform attribute. + * @param transformsArray - Accumulator array for transform operations. + * @returns True if the attribute was handled and added to transforms array. + */ +export function handleSeparateTransformAttributes( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute, + attrName: string, + transformsArray: { name: string; value: string | number }[] = [] +) { + if (rnSvgTransformAttributeValues.has(attrName)) { + convertAttributeTransformArray(t, transformsArray, attr); + return true; + } + + return false; +} + +/** + * Handles a single `transform` JSX attribute that holds an array of transform objects + * (e.g., [{ translateX: 10 }, { rotate: '90deg' }]). + * Converts it into discrete transform operations and stores in the transforms array. + * + * @param t - Babel types helper. + * @param attr - JSX attribute node with `transform` key. + * @param attrName - Name of the attribute (should be 'transform'). + * @param transformsArray - Accumulator array for transform operations. + * @returns True if the transform list was extracted successfully. + */ +export function handleJoinedTransformAttributes( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute, + attrName: string, + transformsArray: { name: string; value: string | number }[] = [] +) { + if ( + attrName !== 'transform' || + !t.isJSXExpressionContainer(attr.value) || + !t.isArrayExpression(attr.value.expression) + ) { + return false; + } + + const transformList = evaluateStaticNode(t, attr.value.expression); + + if (!Array.isArray(transformList)) { + return false; + } + + for (const entry of transformList) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + + for (const key of Object.keys(entry)) { + transformsArray.push({ + name: key, + value: entry[key] + }); + } + } + + return true; +} + +/** + * Converts a standard JSX attribute (e.g., `stroke`, `fill`, `opacity`) to a string literal + * if it holds a valid value. + * + * @param t - Babel types helper. + * @param attr - JSX attribute node. + */ +export function handleRegularAttributes( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute +) { + const result = getJSXAttributeData(t, attr); + + if (result.value) { + attr.value = t.stringLiteral(result.value.toString()); + } +} + +/** + * Builds a final `transform` string from all accumulated transform operations, + * and pushes it as a new attribute into the JSXOpeningElement. + * + * @param el - JSXOpeningElement where the `transform` attribute will be added. + * @param transformsArray - Array of parsed transform operations. + */ +export function buildTransformStringAttribute( + el: Babel.types.JSXOpeningElement, + transformsArray: { name: string; value: string | number }[] = [] +) { + if (transformsArray.length) { + const transformAttrString = convertTransformArrayToString( + transformsArray + ); + + if (transformAttrString) { + el.attributes.push( + jsxAttribute( + jSXIdentifier('transform'), + stringLiteral(transformAttrString) + ) + ); + } + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/fs.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/fs.ts new file mode 100644 index 000000000..72a003677 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/fs.ts @@ -0,0 +1,136 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import fs from 'fs'; +import path from 'path'; + +/** + * Finds the module path by walking up the directory tree. + * It starts with the current directory and goes up the tree in case we're dealing with monorepos. + * + * @param moduleName - The name of the module to find. + * @param startPath - The directory to start searching from (defaults to cwd). + * @returns The absolute path to the module, or null if not found. + */ +function findModulePath( + moduleName: string, + startPath: string = process.cwd() +): string | null { + const maxDepth = 5; + let depth = 0; + let currentPath = startPath; + + while (depth < maxDepth) { + const modulePath = path.join(currentPath, 'node_modules', moduleName); + + if (fs.existsSync(modulePath)) { + return modulePath; + } + + const parentPath = path.dirname(currentPath); + + // Reached the root without finding the module + if (parentPath === currentPath) { + break; + } + + currentPath = parentPath; + depth++; + } + + return null; +} + +/** + * Determines the output path for storing transformed SVG assets. + * In production mode (no `pluginDev` env flag), it targets the Session Replay node module. + * In development mode (`pluginDev=true`), it writes to a local `./assets` directory. + * + * @returns Absolute path to the directory where assets should be written. + */ +export function getAssetsPath() { + const hasDevFlag = process.env.pluginDev; + const moduleName = '@datadog/mobile-react-native-session-replay'; + + if (!hasDevFlag) { + const modulePath = findModulePath(moduleName); + + if (!modulePath) { + return null; + } + + return path.join(modulePath, 'assets'); + } + + return path.resolve('./assets'); +} + +/** + * Deletes all files (non-recursively) in the provided assets directory. + * Only removes files, leaving subdirectories untouched. + * + * @param assetsPath - Absolute path to the assets directory to clear. + */ +export function clearAssetsDir(assetsPath: string) { + try { + const files = fs.readdirSync(assetsPath); + for (const file of files) { + const filePath = path.join(assetsPath, file); + + if (fs.lstatSync(filePath).isFile()) { + fs.unlinkSync(filePath); + } + } + } catch (error) { + console.error('[clearAssetsDir]: ', error); + } +} + +/** + * Writes a given SVG asset to disk using a hash-based filename. + * + * The asset is first written to a temporary file (`.svg`) to avoid race conditions + * when multiple workers attempt to write simultaneously. Once successfully written, + * the file is renamed to `.svg`. If a file with the same hash already exists, + * the temporary file is removed to prevent duplicates. + * + * @param assetsPath - Absolute path to the assets directory where the file should be stored. + * @param nativeID - Unique identifier of the source component or view. + * @param hash - Hash string used as the final filename to ensure uniqueness. + * @param svgCode - The SVG string content to be written to disk. + * @returns `true` if a new file was written, or `false` if the file already existed or an error occurred. + */ +export function writeAssetToDisk( + assetsPath: string, + nativeID: string, + hash: string, + svgCode: string +): boolean { + try { + const tmpPath = path.join(assetsPath, `${nativeID}.svg`); + const outputPath = path.join(assetsPath, `${hash}.svg`); + + if (!fs.existsSync(assetsPath)) { + fs.mkdirSync(assetsPath, { recursive: true }); + } + + // Write first to a tmp file to prevent multiple workers from trying to write to the same path + fs.writeFileSync(tmpPath, svgCode); + + // Once finished, rename the file + // If multiple workers get the same hash, the last one wins, but the content should always be valid + if (!fs.existsSync(outputPath)) { + fs.renameSync(tmpPath, outputPath); + return true; + } + + fs.unlinkSync(tmpPath); + return false; + } catch (error) { + console.error('[writeJSONToDisk]: ', error); + return false; + } +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/svg.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/svg.ts new file mode 100644 index 000000000..7cc480153 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/processing/svg.ts @@ -0,0 +1,211 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type * as Babel from '@babel/core'; + +import { getJSXAttributeData } from '../../../utils/nodeProcessing'; + +/** + * Converts a Babel ArrayExpression from a JSX attribute into a string suitable for SVG. + * The conversion is based on the SVG attribute name, e.g. `points`, `values`, or `matrix`. + * + * @param t - Babel types helper. + * @param expression - Babel AST ArrayExpression node. + * @param attrName - Name of the attribute (e.g., 'points', 'matrix'). + * @returns A formatted string for the attribute value, or null if unsupported. + */ +export function convertAttributeArrayValue( + t: typeof Babel.types, + expression: Babel.types.ArrayExpression, + attrName: string +): string | null { + const value = convertArrayExpressionToArray(t, expression, []); + if (!Array.isArray(value)) { + return null; + } + + switch (attrName) { + case 'gradientTransform': + case 'matrix': + return `matrix(${value.join(' ')})`; + + case 'values': + return value.join(';'); + + case 'points': { + return value + .map(v => (Array.isArray(v) ? v.join(',') : v)) + .join(' '); + } + + default: + return value.join(' '); + } +} + +/** + * Recursively converts a Babel ArrayExpression into a flat or nested string array + * depending on the content, supporting numbers, strings, unary expressions, and simple templates. + * + * @param t - Babel types helper. + * @param expression - Babel AST ArrayExpression node to convert. + * @param data - Initial accumulator array (flat or nested). + * @returns A string array (or nested string array) representing the array literal. + */ +export function convertArrayExpressionToArray( + t: typeof Babel.types, + expression: Babel.types.ArrayExpression, + data: string[] | string[][] +): typeof data { + for (const element of expression.elements) { + if (!element) { + continue; + } + + // Used when targeting nested arrays + if (t.isArrayExpression(element)) { + const nested = convertArrayExpressionToArray(t, element, []); + (data as string[][]).push(nested as string[]); + continue; + } + + // Used when targeting numeric values + if (t.isNumericLiteral(element)) { + (data as string[]).push(element.value.toString()); + continue; + } + + // Used when targeting string values + if (t.isStringLiteral(element)) { + (data as string[]).push(element.value); + continue; + } + + // Used when targeting negative/positive signed values + if ( + t.isUnaryExpression(element) && + t.isNumericLiteral(element.argument) + ) { + if (element.operator === '-') { + (data as string[]).push(`-${element.argument.value}`); + } else { + (data as string[]).push(`${element.argument.value}`); + } + continue; + } + + // Used when targeting string templates (``) + if ( + t.isTemplateLiteral(element) && + element.quasis.length === 1 && + element.expressions.length === 0 + ) { + const { cooked } = element.quasis[0].value; + if (cooked) { + (data as string[]).push(cooked); + } + continue; + } + + // Ignore unsupported elements (identifiers, spreads) + console.warn( + '[convertArrayExpressionToArray] Unsupported array element in SVG prop:', + element + ); + } + + return data; +} + +/** + * Extracts name/value data from a JSX transform-related attribute and appends it + * to the provided transforms array for later stringification. + * + * @param t - Babel types helper. + * @param transformsArray - Accumulator array collecting transform operations. + * @param attr Babel JSXAttribute node. + */ +export function convertAttributeTransformArray( + t: typeof Babel.types, + transformsArray: { name: string; value: string | number }[], + attr: Babel.types.JSXAttribute +) { + const data = getJSXAttributeData(t, attr); + if (data.name && data.value) { + transformsArray.push(data as typeof transformsArray[0]); + } +} + +/** + * Converts an array of parsed transform operations into a single SVG-compliant + * `transform` string (e.g., "translate(10, 20) scale(2, 2)"). + * + * Supports standard transform keys such as: + * - translateX / translateY + * - scaleX / scaleY + * - rotate + * - skewX / skewY + * - matrix + * + * @param transformsArray - Array of transform operations with name/value pairs. + * @returns - Formatted `transform` string, or undefined if no transforms are present. + */ +export function convertTransformArrayToString( + transformsArray: { name: string; value: string | number }[] +): string | undefined { + const transforms: string[] = []; + + const get = (key: string) => + transformsArray.find(t => t.name === key)?.value; + + const tx = get('translateX'); + const ty = get('translateY'); + if (tx !== undefined && ty !== undefined) { + transforms.push(`translate(${tx}, ${ty})`); + } else if (tx !== undefined) { + transforms.push(`translate(${tx})`); + } else if (ty !== undefined) { + transforms.push(`translate(0, ${ty})`); + } + + const sx = get('scaleX'); + const sy = get('scaleY'); + if (sx !== undefined && sy !== undefined) { + transforms.push(`scale(${sx}, ${sy})`); + } else if (sx !== undefined) { + transforms.push(`scale(${sx})`); + } else if (sy !== undefined) { + transforms.push(`scale(1, ${sy})`); + } + + const rot = get('rotate'); + if (rot !== undefined) { + const value = typeof rot === 'string' ? rot.replace(/deg$/, '') : rot; + transforms.push(`rotate(${value})`); + } + + const skewX = get('skewX'); + if (skewX !== undefined) { + const value = + typeof skewX === 'string' ? skewX.replace(/deg$/, '') : skewX; + transforms.push(`skewX(${value})`); + } + + const skewY = get('skewY'); + if (skewY !== undefined) { + const value = + typeof skewY === 'string' ? skewY.replace(/deg$/, '') : skewY; + transforms.push(`skewY(${value})`); + } + + const matrix = get('matrix'); + if (Array.isArray(matrix) && matrix.length === 6) { + const matrixValues = matrix.map(v => v.toString()).join(' '); + transforms.push(`matrix(${matrixValues})`); + } + + return transforms.length ? transforms.join(' ') : undefined; +} diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/utils.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/utils.ts new file mode 100644 index 000000000..61fb25411 --- /dev/null +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/utils.ts @@ -0,0 +1,368 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +type RNShadow = { width?: number; height?: number }; + +/** + * Converts a React Native-style `style` object into a flat CSS-compatible object, + * mapping values like `marginTop`, `paddingHorizontal`, `shadowColor`, `transform`, etc., + * to kebab-case CSS keys and serializing values appropriately (e.g., with `px`, `rgba`). + * + * @param style - The input React Native style object. + * @param options - Optional settings like RTL layout direction. + * @returns A plain object mapping CSS keys to string/number values for inline style injection. + */ +export function convertStyleObjToCssObj( + style: any, + { isRTL = false }: { isRTL?: boolean } = {} +) { + const css: Record = {}; + + // Sizing + for (const k of [ + 'width', + 'height', + 'minWidth', + 'minHeight', + 'maxWidth', + 'maxHeight' + ]) { + if (style[k] != null) { + css[kebabCase(k)] = addPx(style[k]); + } + } + + // Positioning / display / overflow / opacity + if (style.position) { + css.position = style.position; + } + if (style.top != null) { + css.top = addPx(style.top); + } + if (style.bottom != null) { + css.bottom = addPx(style.bottom); + } + if (style.left != null) { + css.left = addPx(style.left); + } + if (style.right != null) { + css.right = addPx(style.right); + } + if (style.start != null) { + css[isRTL ? 'right' : 'left'] = addPx(style.start); + } + if (style.end != null) { + css[isRTL ? 'left' : 'right'] = addPx(style.end); + } + if (style.zIndex != null) { + css['z-index'] = style.zIndex; + } + if (style.display) { + css.display = style.display; + } + if (style.overflow) { + css.overflow = style.overflow; + } + if (style.opacity != null) { + css.opacity = style.opacity; + } + + // Margin + if (style.margin != null) { + css.margin = addPx(style.margin); + } + if (style.marginTop != null) { + css['margin-top'] = addPx(style.marginTop); + } + if (style.marginRight != null) { + css['margin-right'] = addPx(style.marginRight); + } + if (style.marginBottom != null) { + css['margin-bottom'] = addPx(style.marginBottom); + } + if (style.marginLeft != null) { + css['margin-left'] = addPx(style.marginLeft); + } + if (style.marginHorizontal != null) { + css['margin-left'] = addPx(style.marginHorizontal); + css['margin-right'] = addPx(style.marginHorizontal); + } + if (style.marginVertical != null) { + css['margin-top'] = addPx(style.marginVertical); + css['margin-bottom'] = addPx(style.marginVertical); + } + + // Padding + if (style.padding != null) { + css.padding = addPx(style.padding); + } + if (style.paddingTop != null) { + css['padding-top'] = addPx(style.paddingTop); + } + if (style.paddingRight != null) { + css['padding-right'] = addPx(style.paddingRight); + } + if (style.paddingBottom != null) { + css['padding-bottom'] = addPx(style.paddingBottom); + } + if (style.paddingLeft != null) { + css['padding-left'] = addPx(style.paddingLeft); + } + if (style.paddingHorizontal != null) { + css['padding-left'] = addPx(style.paddingHorizontal); + css['padding-right'] = addPx(style.paddingHorizontal); + } + if (style.paddingVertical != null) { + css['padding-top'] = addPx(style.paddingVertical); + css['padding-bottom'] = addPx(style.paddingVertical); + } + + // Background / borders + if (style.backgroundColor) { + css['background-color'] = style.backgroundColor; + } + if (style.borderWidth != null) { + css['border-width'] = addPx(style.borderWidth); + } + if (style.borderColor) { + css['border-color'] = style.borderColor; + } + if (style.borderStyle) { + css['border-style'] = style.borderStyle; + } + if (style.borderRadius != null) { + css['border-radius'] = addPx(style.borderRadius); + } + if (style.borderTopLeftRadius != null) { + css['border-top-left-radius'] = addPx(style.borderTopLeftRadius); + } + if (style.borderTopRightRadius != null) { + css['border-top-right-radius'] = addPx(style.borderTopRightRadius); + } + if (style.borderBottomRightRadius != null) { + css['border-bottom-right-radius'] = addPx( + style.borderBottomRightRadius + ); + } + if (style.borderBottomLeftRadius != null) { + css['border-bottom-left-radius'] = addPx(style.borderBottomLeftRadius); + } + + // Per-side border widths/colors + if (style.borderTopWidth != null) { + css['border-top-width'] = addPx(style.borderTopWidth); + } + if (style.borderRightWidth != null) { + css['border-right-width'] = addPx(style.borderRightWidth); + } + if (style.borderBottomWidth != null) { + css['border-bottom-width'] = addPx(style.borderBottomWidth); + } + if (style.borderLeftWidth != null) { + css['border-left-width'] = addPx(style.borderLeftWidth); + } + if (style.borderTopColor) { + css['border-top-color'] = style.borderTopColor; + } + if (style.borderRightColor) { + css['border-right-color'] = style.borderRightColor; + } + if (style.borderBottomColor) { + css['border-bottom-color'] = style.borderBottomColor; + } + if (style.borderLeftColor) { + css['border-left-color'] = style.borderLeftColor; + } + + // Flex + if (style.flex != null) { + css.flex = style.flex; + } + if (style.flexDirection) { + css['flex-direction'] = style.flexDirection; + } + if (style.flexWrap) { + css['flex-wrap'] = style.flexWrap; + } + if (style.flexGrow != null) { + css['flex-grow'] = style.flexGrow; + } + if (style.flexShrink != null) { + css['flex-shrink'] = style.flexShrink; + } + if (style.flexBasis != null) { + css['flex-basis'] = addPx(style.flexBasis); + } + if (style.justifyContent) { + css['justify-content'] = style.justifyContent; + } + if (style.alignItems) { + css['align-items'] = style.alignItems; + } + if (style.alignContent) { + css['align-content'] = style.alignContent; + } + if (style.alignSelf) { + css['align-self'] = style.alignSelf; + } + if (style.gap != null) { + css['gap'] = addPx(style.gap); + } + if (style.rowGap != null) { + css['row-gap'] = addPx(style.rowGap); + } + if (style.columnGap != null) { + css['column-gap'] = addPx(style.columnGap); + } + + // Transform properties (translateX, scaleX, rotate, etc.) are NOT handled here as they are igonore if set on a style attribute + // They should be extracted separately and set as SVG transform attributes, + + // Shadows / elevation → box-shadow + if ( + style.shadowColor || + style.shadowOffset || + style.shadowOpacity != null || + style.shadowRadius != null + ) { + const color = rgba( + style.shadowColor || '#000', + style.shadowOpacity ?? 1 + ); + const off: RNShadow = style.shadowOffset || {}; + const blur = style.shadowRadius != null ? style.shadowRadius : 0; + + css['box-shadow'] = `${addPx(off.width || 0)} ${addPx( + off.height || 0 + )} ${addPx(blur)} 0 ${color}`; + } else if (style.elevation != null) { + const e = style.elevation; + css['box-shadow'] = `0 ${addPx(e)} ${addPx(1.5 * e)} 0 rgba(0,0,0,0.3)`; + } + + // Pointer events + if (style.pointerEvents === 'none') { + css['pointer-events'] = 'none'; + } else if (style.pointerEvents === 'auto') { + css['pointer-events'] = 'auto'; + } + + // Other + if (style.direction) { + css.direction = style.direction; + } + if (style.aspectRatio != null) { + css['aspect-ratio'] = String(style.aspectRatio); + } + + // SVG-specific properties + if (style.fill) { + css.fill = style.fill; + } + if (style.fillOpacity != null) { + css['fill-opacity'] = style.fillOpacity; + } + if (style.fillRule) { + css['fill-rule'] = style.fillRule; + } + if (style.stroke) { + css.stroke = style.stroke; + } + if (style.strokeWidth != null) { + css['stroke-width'] = style.strokeWidth; + } + if (style.strokeOpacity != null) { + css['stroke-opacity'] = style.strokeOpacity; + } + if (style.strokeLinecap) { + css['stroke-linecap'] = style.strokeLinecap; + } + if (style.strokeLinejoin) { + css['stroke-linejoin'] = style.strokeLinejoin; + } + if (style.strokeDasharray) { + css['stroke-dasharray'] = Array.isArray(style.strokeDasharray) + ? style.strokeDasharray.join(',') + : style.strokeDasharray; + } + if (style.strokeDashoffset != null) { + css['stroke-dashoffset'] = style.strokeDashoffset; + } + if (style.strokeMiterlimit != null) { + css['stroke-miterlimit'] = style.strokeMiterlimit; + } + + return css; +} + +/** + * Adds a `px` suffix to a numeric value. Leaves string values unchanged. + * + * @param v - Value to format (number or string). + * @returns - The value as a pixel string (e.g., "10px") or unchanged string. + */ +function addPx(v: any) { + return typeof v === 'number' ? `${v}px` : v; +} + +/** + * Converts a hex color (e.g., `#000`, `#336699`) and an alpha value into an `rgba(...)` string. + * Supports 3-digit and 6-digit hex codes. Returns input string if not a valid hex code. + * + * @param hexOrName - A hex color string or named color (fallback). + * @param alpha - A numeric alpha value between 0 and 1. + * @returns A valid CSS `rgba(...)` string or the original input color. + */ +function rgba(hexOrName: string, alpha: number) { + if (!hexOrName) { + return `rgba(0,0,0,${alpha})`; + } + + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(hexOrName)) { + const hex = + hexOrName.length === 4 + ? `#${[...hexOrName.slice(1)].map(c => c + c).join('')}` + : hexOrName; + + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + + return `rgba(${r},${g},${b},${alpha})`; + } + + return hexOrName; +} + +/** + * Converts a camelCase string into kebab-case (e.g., `marginTop` → `margin-top`). + * Uses Unicode-aware regex to catch capital letters in all scripts. + * + * @param str - The camelCase input string. + * @returns - The kebab-case equivalent string. + */ +export function kebabCase(str: string) { + const KEBAB_REGEX = /\p{Lu}/gu; + const result = str.replace(KEBAB_REGEX, match => `-${match.toLowerCase()}`); + + return result.startsWith('-') ? result.slice(1) : result; +} + +/** + * Converts a React Native SVG tag or attribute name to start with a lowercase letter, + * preserving the rest of the casing (used for JSX tag/attribute normalization). + * + * Example: `LinearGradient` → `linearGradient` + * + * @param attribute - The original attribute or tag name. + * @returns The transformed string with lowercase first letter. + */ +export function convertAttributeCasing(attribute: string) { + const firstLetter = attribute.slice(0, 1).toLowerCase(); + const text = attribute.slice(1); + + return `${firstLetter}${text}`; +} diff --git a/packages/react-native-babel-plugin/src/types/general.ts b/packages/react-native-babel-plugin/src/types/general.ts index b94c9021b..e1c1a60f1 100644 --- a/packages/react-native-babel-plugin/src/types/general.ts +++ b/packages/react-native-babel-plugin/src/types/general.ts @@ -7,6 +7,7 @@ import type * as Babel from '@babel/core'; import type { RumAction } from '../constants'; +import type { ReactNativeSVG } from '../libraries/react-native-svg'; export const MemoTypes = { USE_CALLBACK: 'useCallback', @@ -29,8 +30,13 @@ export type TrackedComponent = { }[]; }; +export type SessionReplayOptions = { + svgTracking: boolean; +}; + export type PluginOptions = { actionNameAttribute?: string; + sessionReplay: SessionReplayOptions; components: { useContent: boolean; useNamePrefix: boolean; @@ -43,6 +49,7 @@ export type PluginPassState = Babel.PluginPass & { memoization?: Record; hasValidTapAction?: boolean; trackedComponents?: Record>; + reactNativeSVG?: ReactNativeSVG | null; }; export type PluginResult = Babel.PluginObj; diff --git a/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts b/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts index cf4135c42..10cbc1d7d 100644 --- a/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts +++ b/packages/react-native-babel-plugin/src/utils/nodeProcessing.ts @@ -288,3 +288,296 @@ export function toExpression( return t.nullLiteral(); } + +/** + * Extracts the name and statically-evaluable value from a given JSX attribute. + * + * This function supports JSX attributes where the value is either: + * - A string literal (e.g., `fill="red"`) + * - An expression container that can be statically evaluated (e.g., `fill={"red"}`, or arrays like `transform={[{ rotate: "45deg" }]}`) + * + * If the value is dynamic, non-literal, or not supported (e.g., function calls, identifiers, complex objects), + * the function returns `{ name: null, value: null }`. + * + * @param t - Babel types helper for AST manipulation. + * @param attr - The JSXAttribute node to extract data from. + * @returns An object with the statically-evaluable attribute name and value: + * - `name`: The attribute's name (e.g., "fill", "translateX") or `null` if not extractable. + * - `value`: A string, number, or array of strings/numbers if statically evaluable, otherwise `null`. + */ +export function getJSXAttributeData( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute +): { + name: string | null; + value: string | number | (string | number)[] | null; +} { + const name = getNodeName(t, attr.name); + const result: { + name: string | null; + value: string | number | (string | number)[] | null; + } = { + name: null, + value: null + }; + + if (!name || !attr.value) { + return result; + } + + let expression: Babel.types.Node | null = null; + + if (t.isJSXExpressionContainer(attr.value)) { + expression = attr.value.expression; + } else if (t.isStringLiteral(attr.value)) { + expression = attr.value; + } else { + return result; // Not supported + } + + const staticValue = evaluateStaticNode(t, expression); + + if ( + typeof staticValue === 'string' || + typeof staticValue === 'number' || + (Array.isArray(staticValue) && + staticValue.every( + v => typeof v === 'string' || typeof v === 'number' + )) + ) { + result.name = name; + result.value = staticValue; + } + + return result; +} + +/** + * Recursively evaluates a Babel AST node into a static JS value. + * Returns `null` if any non-static construct is encountered. + * + * Supported: + * - ObjectExpression (with simple keys and nested values) + * - ArrayExpression (e.g., style arrays, transform arrays) + * - NumericLiteral, StringLiteral, BooleanLiteral, NullLiteral + * - UnaryExpression (+/-) over NumericLiteral + * - TemplateLiteral without expressions + * - ConditionalExpression if both sides static + * - LogicalExpression (||, &&, ??) if both sides static + * + * NOT supported (returns null): + * - Identifier, MemberExpression, CallExpression, NewExpression, SpreadElement, Function nodes, etc. + */ +export function evaluateStaticNode( + t: typeof Babel.types, + node: Babel.types.Node | null | undefined +): any { + if (!node) { + return null; + } + + // --- Literals + if (t.isNullLiteral(node)) { + return null; + } + if (t.isNumericLiteral(node)) { + return node.value; + } + if (t.isBooleanLiteral(node)) { + return node.value; + } + if (t.isStringLiteral(node)) { + return node.value; + } + if (t.isTemplateLiteral(node)) { + if (node.expressions.length === 0 && node.quasis.length === 1) { + return node.quasis[0].value.cooked ?? ''; + } + return null; // dynamic template + } + + // --- Unary +/- on numeric literal + if (t.isUnaryExpression(node) && t.isNumericLiteral(node.argument)) { + if (node.operator === '-') { + return -node.argument.value; + } + if (node.operator === '+') { + return +node.argument.value; + } + return null; + } + + // --- Arrays + if (t.isArrayExpression(node)) { + const out: any[] = []; + for (const el of node.elements) { + if (!el) { + out.push(null); + continue; + } + if (t.isSpreadElement(el)) { + return null; // dynamic spread in array + } + const v = evaluateStaticNode(t, el as any); + if (v === undefined) { + // keep undefined? RN style ignores undefined; we can drop it + continue; + } + out.push(v); + } + return out; + } + + // --- Objects + if (t.isObjectExpression(node)) { + const obj: Record = {}; + for (const p of node.properties) { + if (t.isSpreadElement(p)) { + return null; // dynamic spread + } + if (!t.isObjectProperty(p)) { + return null; // methods/getters/setters not expected in style + } + + // Key can be Identifier or StringLiteral (computed keys are not supported here) + let key: string | null = null; + if (t.isIdentifier(p.key) && !p.computed) { + key = p.key.name; + } else if (t.isStringLiteral(p.key) && !p.computed) { + key = p.key.value; + } else { + return null; // computed or unsupported key + } + + const value = evaluateStaticNode(t, p.value as any); + // RN style ignores undefined; omit null/undefined keys + if (key && value !== undefined) { + obj[key] = value; + } + } + return obj; + } + + // --- Conditional (ternary): only if both sides static + if (t.isConditionalExpression(node)) { + const c = evaluateStaticNode(t, node.consequent); + const a = evaluateStaticNode(t, node.alternate); + if (c === null && a === null) { + return null; + } + if (c !== null && a !== null) { + // We can't evaluate the test safely; choose neither → bail or pick one? + // Safer: bail to avoid wrong choice. + return null; + } + return null; // keep conservative; you can relax if you want to pick a branch + } + + // --- Logical expressions (||, &&, ??) if both sides static (conservative) + if (t.isLogicalExpression(node)) { + const left = evaluateStaticNode(t, node.left); + const right = evaluateStaticNode(t, node.right); + if (left === null && right === null) { + return null; + } + // Still conservative: without evaluating truthiness rules exactly, bail + return null; + } + + // Everything else considered dynamic for our purposes + // (Identifiers, MemberExpressions, CallExpressions, NewExpressions, etc.) + return null; +} + +export function parseStyleNode( + t: typeof Babel.types, + attr: Babel.types.JSXAttribute +): Record | null { + if (!attr.value) { + return null; + } + + // style="..." → not a valid RN style object + if (t.isStringLiteral(attr.value)) { + return null; + } + + if (t.isJSXExpressionContainer(attr.value)) { + const v = attr.value.expression; + + // style={{ ... }} or style={[ ... ]} + const evaluated = evaluateStaticNode(t, v); + if (evaluated == null) { + return null; + } + + // If the top-level is an array, merge object entries (RN allows style arrays) + if (Array.isArray(evaluated)) { + const merged: Record = {}; + for (const item of evaluated) { + if (item && typeof item === 'object' && !Array.isArray(item)) { + Object.assign(merged, item); + } else if (item == null) { + // ignore null/undefined + } else { + // Non-object entries in a style array are not expected; bail + return null; + } + } + return merged; + } + + // If it's already an object, return it + if (evaluated && typeof evaluated === 'object') { + return evaluated as Record; + } + + // Any other type at top-level is not a valid style object + } + + return null; +} + +/** + * Searches for a variable declaration in the given scope and returns its static value. + * + * @param t - Babel types helper. + * @param scopePath - The scope path to search in (e.g., function, class, or program). + * @param variableName - The name of the variable to find. + * @returns The static value of the variable (string or number), or null if not found or not static. + */ +export function findIdentifierInScope( + t: typeof Babel.types, + scopePath: Babel.NodePath, + variableName: string +): string | number | null { + let foundValue: string | number | null = null; + + scopePath.traverse({ + VariableDeclarator(path) { + // Here we check for `parentPath` twice because nodes like variables are usually inside `BlockStatement` nodes + // So we need to get past those to get the right parentPath + if (path.parentPath?.parentPath !== scopePath) { + return; + } + + const nodeName = getNodeName(t, path.node.id); + if ( + t.isIdentifier(path.node.id) && + nodeName === variableName && + path.node.init + ) { + const staticValue = evaluateStaticNode(t, path.node.init); + if ( + typeof staticValue === 'string' || + typeof staticValue === 'number' + ) { + foundValue = staticValue; + path.stop(); + } + } + } + }); + + return foundValue; +} diff --git a/packages/react-native-babel-plugin/test/react-native-svg.test.ts b/packages/react-native-babel-plugin/test/react-native-svg.test.ts new file mode 100644 index 000000000..e3070a95f --- /dev/null +++ b/packages/react-native-babel-plugin/test/react-native-svg.test.ts @@ -0,0 +1,746 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/* eslint quotes: ["off"] */ +import * as parser from '@babel/parser'; +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; + +import { RNSvgHandler } from '../src/libraries/react-native-svg/handlers/RNSvgHandler'; + +/** + * Helper function to test SVG transformation + */ +function transformSvg(code: string): string | undefined { + const ast = parser.parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'] + }); + + let result: string | undefined; + + traverse(ast, { + JSXElement(path) { + if (t.isJSXIdentifier(path.node.openingElement.name)) { + const name = path.node.openingElement.name.name; + if (name === 'Svg') { + const dimensions: Record = {}; + const handler = new RNSvgHandler(t, path, name); + result = handler.transformSvgNode(dimensions); + } + } + } + }); + + return result; +} + +describe('React Native SVG Processing - RNSvgHandler', () => { + describe('Basic SVG Transformation', () => { + it('should transform a basic SVG element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should transform SVG with self-closing elements', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should transform nested SVG elements (g, rect, circle)', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should transform SVG with multiple child elements', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('ViewBox Preservation', () => { + it('should preserve viewBox attribute on SVG element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should preserve viewBox with different numeric formats (integers, decimals)', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Transform Attributes - Separate Properties', () => { + it('should handle translateX transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle translateY transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle both translateX and translateY attributes', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle scaleX transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle scaleY transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle both scaleX and scaleY attributes', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle rotate transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle skewX transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle skewY transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle matrix transform attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should combine multiple separate transform attributes into single transform string', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Transform Attributes - Array Format', () => { + it('should handle transform attribute as array of objects', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle transform array with translate operations', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle transform array with scale operations', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle transform array with mixed transform operations', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle transform array containing matrix', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Transform Attributes - Style Object', () => { + it('should extract and process transform properties from style object', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle transform in style object alongside other CSS properties', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle multiple transform properties within style object', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert non-transform style properties to inline CSS string', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Dimension Handling - Width and Height', () => { + it('should extract and preserve static width and height values', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle numeric width and height without units', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle percentage-based width and height', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should resolve width and height from variables in component scope', () => { + const input = + 'const width = 300; '; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should remove width/height attributes when variables cannot be resolved', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with only width specified', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with only height specified', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG without width or height attributes', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Attribute Name Conversion', () => { + it('should convert camelCase attribute names to kebab-case', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should preserve attributes already in kebab-case format', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should preserve special SVG attributes that are camelCase in web spec', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Element Name Conversion', () => { + it('should convert Svg component to svg element', () => { + const input = ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Circle component to circle element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Rect component to rect element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Path component to path element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert G component to g element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Line component to line element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Polygon component to polygon element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Polyline component to polyline element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Ellipse component to ellipse element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Text component to text element', () => { + const input = + 'Hello SVG'; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `"Hello SVG"` + ); + }); + + it('should convert ClipPath component to clipPath element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Defs component to defs element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert LinearGradient component to linearGradient element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert RadialGradient component to radialGradient element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert Stop component to stop element', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Array Attributes', () => { + it('should convert points array to space-separated string', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert strokeDasharray array to comma-separated string', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert gradientTransform array to proper format', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert stdDeviation array to space-separated string', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert values array to proper format', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('React Native Specific Attributes', () => { + it('should remove accessibilityLabel attribute from SVG output', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should remove accessibilityRole attribute from SVG output', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should convert style object to inline CSS string', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should remove __self debugging attribute', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Invalid and Unsupported Attributes', () => { + it('should remove attributes not in SVG spec', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should remove JSX spread attributes', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Complex Scenarios', () => { + it('should handle SVG with dimensions, viewBox, transforms, and nested elements', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with linearGradient, radialGradient, and filter elements', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with clipPath', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with text, tspan, and textPath elements', () => { + const input = + 'Hello World'; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `"Hello World"` + ); + }); + + it('should handle deeply nested SVG element hierarchy', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with use elements referencing defs', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty SVG element with no children', () => { + const input = ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle SVG with text-only content', () => { + const input = 'Plain text'; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `"Plain text"` + ); + }); + + it('should handle or strip JSX comments in SVG', () => { + const input = + '{/* This is a comment */}'; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `"{/* This is a comment */}"` + ); + }); + + it('should handle transform attributes with zero values', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + + it('should handle transform attributes with negative values', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); + + describe('Error Handling', () => { + it('should throw error or warn for unsupported element names', () => { + const input = ''; + expect(() => transformSvg(input)).toThrow(); + }); + + it('should handle malformed transform array gracefully', () => { + const input = + ''; + const output = transformSvg(input); + expect(output).toMatchInlineSnapshot( + `""` + ); + }); + }); +}); diff --git a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec index 781e62881..915fc4129 100644 --- a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec +++ b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec @@ -14,12 +14,16 @@ Pod::Spec.new do |s| s.platforms = { :ios => "12.0", :tvos => "12.0" } s.source = { :git => "https://github.com/DataDog/dd-sdk-reactnative.git", :tag => "#{s.version}" } - s.source_files = "ios/Sources/*.{h,m,mm,swift}" + s.source_files = "ios/Sources/**/*.{h,m,mm,swift}" + + s.resource_bundles = { + 'DDSessionReplay' => ['assets/assets.json', 'assets/assets.bin'] + } s.dependency "React-Core" # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogSessionReplay', '2.30.0' + s.dependency 'DatadogSessionReplay', '2.30.2' s.dependency 'DatadogSDKReactNative' s.test_spec 'Tests' do |test_spec| diff --git a/packages/react-native-session-replay/README.md b/packages/react-native-session-replay/README.md index 34853a684..308cf3c0c 100644 --- a/packages/react-native-session-replay/README.md +++ b/packages/react-native-session-replay/README.md @@ -51,4 +51,93 @@ SessionReplay.startRecording(); SessionReplay.stopRecording(); ``` -[1]: https://www.npmjs.com/package/@datadog/mobile-react-native \ No newline at end of file +## SVG Support + +Session Replay provides enhanced support for capturing SVG images in your React Native application. To enable SVG tracking, you need to set up the Datadog Babel plugin and Metro plugin. + +### Prerequisites + +Install the Datadog Babel plugin: + +```sh +npm install @datadog/mobile-react-native-babel-plugin +``` + +or with Yarn: + +```sh +yarn add @datadog/mobile-react-native-babel-plugin +``` + +### Setup Babel Plugin + +Configure the Babel plugin in your `babel.config.js` to enable SVG tracking: + +```js +module.exports = { + presets: ['module:@react-native/babel-preset'], + plugins: [ + [ + '@datadog/mobile-react-native-babel-plugin', + { + sessionReplay: { + // SVG tracking is enabled by default + // Set to false to disable SVG asset extraction + svgTracking: true + } + } + ] + ] +}; +``` + +### Setup Metro Plugin + +Configure the Metro plugin in your `metro.config.js` to enable automatic SVG asset bundling: + +```js +const { withSessionReplayAssetBundler } = require('@datadog/mobile-react-native-session-replay/metro'); + +module.exports = withSessionReplayAssetBundler({ + /* your existing Metro config */ +}); +``` + +The Metro plugin automatically monitors and bundles SVG assets during development and production builds. + +### Installation Workflow + +When setting up your project or after installing new dependencies, follow this workflow to ensure SVG assets are properly generated for native builds: + +```sh +# 1. Install dependencies +yarn install + +# 2. Generate Session Replay SVG assets +npx datadog-generate-sr-assets + +# 3. Install iOS pods (if building for iOS) +cd ios && pod install && cd .. + +# 4. Run your app +yarn ios +# or +yarn android +``` + +The `datadog-generate-sr-assets` CLI utility scans your codebase for SVG elements and pre-generates optimized assets that will be included in your native builds. + +**Note for CI/CD**: If you use continuous integration for your builds, make sure to include these steps in your CI pipeline. The workflow should be: `yarn install` → `npx datadog-generate-sr-assets` → `pod install` (for iOS) → build your app. This ensures SVG assets are properly generated before the native build process. + +### Development Workflow + +During development, the Metro plugin automatically handles SVG assets created by the Babel plugin: + +1. Write your components with SVG elements from `react-native-svg` +2. The Babel plugin extracts and transforms SVG nodes during the build process +3. The Metro plugin detects new SVG assets and automatically bundles them +4. SVG images are seamlessly captured in Session Replay recordings + +No manual asset management is required during development. + +[1]: https://www.npmjs.com/package/@datadog/mobile-react-native diff --git a/packages/react-native-session-replay/android/build.gradle b/packages/react-native-session-replay/android/build.gradle index 9cb376929..afb5f5c09 100644 --- a/packages/react-native-session-replay/android/build.gradle +++ b/packages/react-native-session-replay/android/build.gradle @@ -158,6 +158,7 @@ android { java.srcDirs += ['src/rnpre74/kotlin'] } + assets.srcDirs += ['../assets'] } test { java.srcDir("src/test/kotlin") @@ -213,8 +214,8 @@ dependencies { api "com.facebook.react:react-android:$reactNativeVersion" } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.datadoghq:dd-sdk-android-session-replay:2.25.0" - implementation "com.datadoghq:dd-sdk-android-internal:2.25.0" + implementation "com.datadoghq:dd-sdk-android-session-replay:2.26.2" + implementation "com.datadoghq:dd-sdk-android-internal:2.26.2" implementation project(path: ':datadog_mobile-react-native') testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt index 9a6c1b3c5..ef5467bc4 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementation.kt @@ -49,17 +49,17 @@ class DdSessionReplayImplementation( .setImagePrivacy(privacySettings.imagePrivacyLevel) .setTouchPrivacy(privacySettings.touchPrivacyLevel) .setTextAndInputPrivacy(privacySettings.textAndInputPrivacyLevel) - .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(textViewUtils)) + .addExtensionSupport(ReactNativeSessionReplayExtensionSupport(textViewUtils, internalCallback)) .let { _SessionReplayInternalProxy(it).setInternalCallback(internalCallback) } - if (customEndpoint != "") { configuration.useCustomEndpoint(customEndpoint) } sessionReplayProvider().enable(configuration.build(), sdkCore) + promise.resolve(null) } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeInternalCallback.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeInternalCallback.kt index a287a6b75..1372f0731 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeInternalCallback.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeInternalCallback.kt @@ -7,8 +7,13 @@ package com.datadog.reactnative.sessionreplay import android.app.Activity +import android.util.Log import com.datadog.android.sessionreplay.SessionReplayInternalCallback +import com.datadog.android.sessionreplay.SessionReplayInternalResourceQueue import com.facebook.react.bridge.ReactContext +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException /** * Responsible for defining the internal callback implementation for react-native that will allow @@ -17,7 +22,123 @@ import com.facebook.react.bridge.ReactContext class ReactNativeInternalCallback( private val reactContext: ReactContext, ) : SessionReplayInternalCallback { + private var resourceQueue: SessionReplayInternalResourceQueue? = null + + /** + * Companion object containing constants used by the internal callback implementation. + */ + companion object { + private const val ASSETS_JSON = "assets.json" + private const val ASSETS_BIN = "assets.bin" + private const val TAG = "SessionReplayInternalCallback" + } + + private val jsonObject: JSONObject? = loadAssetsJson() + + private fun loadAssetsJson(): JSONObject? { + return try { + val jsonText = reactContext.assets.open(ASSETS_JSON) + .bufferedReader() + .use { it.readText() } + JSONObject(jsonText) + } catch (e: IOException) { + Log.w(TAG, "Failed to read $ASSETS_JSON from assets: ${e.message}") + null + } catch (e: JSONException) { + Log.w(TAG, "Invalid JSON in $ASSETS_JSON: ${e.message}") + null + } + } + + /** + * Returns the JSON entry for a given asset key, if it exists in the index. + * + * @param key The unique asset key to look up (as defined in `assets.json`). + * @return A [JSONObject] containing the metadata for the asset, or `null` if not found. + */ + fun getJSONEntry(key: String): JSONObject? { + val json = jsonObject ?: return null + return if (json.has(key)) json.getJSONObject(key) else null + } + + /** + * Reads the raw byte data from `assets.bin` corresponding to an entry in `assets.json`. + * Uses the `offset` and `length` fields in the JSON entry to extract the exact byte + * range for the requested asset. + * + * @param key The unique asset key defined in `assets.json`. + * @return A [ByteArray] containing the binary data for the asset, or `null` if the key + * is missing or the read operation fails. + */ + fun getEntryData(key: String): ByteArray? { + val entry = getJSONEntry(key) ?: return null + + val offset = entry.optInt("offset", -1) + val length = entry.optInt("length", -1) + + if (offset < 0 || length <= 0) { + return null + } + + return try { + readAssetBytes(reactContext, offset, length) + } catch (e: IOException) { + Log.w(TAG, "Failed to read entry from binary: ${e.message}") + null + } + } + + + private fun readAssetBytes(context: ReactContext, offset: Int, length: Int): ByteArray { + context.assets.open(ASSETS_BIN).use { input -> + var skipped = 0L + while (skipped < offset) { + val result = input.skip((offset - skipped).toLong()) + if (result <= 0) throw IOException("Unable to skip to offset $offset in $ASSETS_BIN") + skipped += result + } + + // Read `length` bytes + val buffer = ByteArray(length) + var bytesRead = 0 + while (bytesRead < length) { + val read = input.read(buffer, bytesRead, length - bytesRead) + if (read == -1) break + bytesRead += read + } + return buffer + } + } + + /** + * Adds a resource item to the current [SessionReplayInternalResourceQueue]. + * + * @param identifier A unique identifier for the resource. + * @param resourceData The binary content of the resource to enqueue. + * @param mimeType Optional MIME type describing the resource (e.g., `"image/png"` or `"image/svg+xml"`). + */ + override fun addResourceItem(identifier: String, resourceData: ByteArray, mimeType: String?) { + this.resourceQueue?.addResourceItem(identifier, resourceData, mimeType) + } + + /** + * Retrieves the current activity from the React context. + * Used by the Session Replay system to access the active UI context when + * registering lifecycle listeners. + * + * @return The currently active [Activity], or `null` if none is available. + */ override fun getCurrentActivity(): Activity? { return reactContext.currentActivity } + + /** + * Sets the resource queue to be used by this callback. + * + * @param resourceQueue The [SessionReplayInternalResourceQueue] responsible + * for handling resources. + */ + override fun setResourceQueue(resourceQueue: SessionReplayInternalResourceQueue) { + this.resourceQueue = resourceQueue + } } diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt index c2eb3880e..f1e082aba 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupport.kt @@ -15,7 +15,9 @@ import com.datadog.reactnative.sessionreplay.mappers.ReactNativeImageViewMapper import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewModalMapper +import com.datadog.reactnative.sessionreplay.mappers.SvgViewMapper import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils +import com.datadog.reactnative.sessionreplay.views.DdPrivacyView import com.facebook.react.views.image.ReactImageView import com.facebook.react.views.modal.ReactModalHostView import com.facebook.react.views.text.ReactTextView @@ -24,7 +26,8 @@ import com.facebook.react.views.view.ReactViewGroup internal class ReactNativeSessionReplayExtensionSupport( - private val textViewUtils: TextViewUtils + private val textViewUtils: TextViewUtils, + private val internalCallback: ReactNativeInternalCallback ) : ExtensionSupport { override fun name(): String { return ReactNativeSessionReplayExtensionSupport::class.java.simpleName @@ -33,6 +36,7 @@ internal class ReactNativeSessionReplayExtensionSupport( override fun getCustomViewMappers(): List> { return listOf( MapperTypeWrapper(ReactImageView::class.java, ReactNativeImageViewMapper()), + MapperTypeWrapper(DdPrivacyView::class.java, SvgViewMapper(internalCallback)), MapperTypeWrapper(ReactViewGroup::class.java, ReactViewGroupMapper()), MapperTypeWrapper(ReactTextView::class.java, ReactTextMapper(textViewUtils)), MapperTypeWrapper(ReactEditText::class.java, ReactEditTextMapper(textViewUtils)), diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt index 78fba8b30..889c1aea7 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/ReactViewGroupMapper.kt @@ -8,6 +8,7 @@ package com.datadog.reactnative.sessionreplay.mappers import ReactViewBackgroundDrawableUtils import com.datadog.android.sessionreplay.recorder.mapper.TraverseAllChildrenMapper +import com.datadog.reactnative.sessionreplay.ReactNativeInternalCallback import com.datadog.reactnative.sessionreplay.utils.DrawableUtils import com.facebook.react.views.view.ReactViewGroup diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt new file mode 100644 index 000000000..9bb8bf167 --- /dev/null +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/mappers/SvgViewMapper.kt @@ -0,0 +1,145 @@ +package com.datadog.reactnative.sessionreplay.mappers + +import ReactViewBackgroundDrawableUtils +import android.view.View +import com.datadog.android.api.InternalLogger +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.recorder.MappingContext +import com.datadog.android.sessionreplay.recorder.mapper.BaseWireframeMapper +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.DefaultColorStringFormatter +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver +import com.datadog.android.sessionreplay.utils.DefaultViewBoundsResolver.resolveViewGlobalBounds +import com.datadog.android.sessionreplay.utils.DefaultViewIdentifierResolver +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.reactnative.sessionreplay.ReactNativeInternalCallback +import com.datadog.reactnative.sessionreplay.utils.DrawableUtils +import com.datadog.reactnative.sessionreplay.views.DdPrivacyView +import java.util.Collections + +internal open class SvgViewMapper( + private val internalCallback: ReactNativeInternalCallback, + private val drawableUtils: DrawableUtils = + ReactViewBackgroundDrawableUtils() +): BaseWireframeMapper( + viewIdentifierResolver = DefaultViewIdentifierResolver, + colorStringFormatter = DefaultColorStringFormatter, + viewBoundsResolver = DefaultViewBoundsResolver, + drawableToColorMapper = DrawableToColorMapper.getDefault() +) { + private val queuedResourceIds = Collections.synchronizedSet(HashSet()) + + @Suppress("LongMethod", "ComplexMethod") + override fun map( + view: T, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback, + internalLogger: InternalLogger + ): List { + val pixelDensity = mappingContext.systemInformation.screenDensity + val viewGlobalBounds = resolveViewGlobalBounds(view, pixelDensity) + val backgroundDrawable = drawableUtils.getReactBackgroundFromDrawable(view.background) + + val opacity = view.alpha + + val (shapeStyle, border) = + if (backgroundDrawable != null) { + drawableUtils + .resolveShapeAndBorder(backgroundDrawable, opacity, pixelDensity) + } else { + null to null + } + + val wireframes = mutableListOf() + + if (view is DdPrivacyView) { + val hash = view.attributes?.get("hash") ?: return wireframes + val width = view.attributes?.get("width") + val height = view.attributes?.get("height") + + var entryData = internalCallback.getEntryData(hash) + ?: return wireframes + + // This is always guaranteed to be true due to how the babel plugin transformed the data + val subView = view.getChildAt(0) ?: return wireframes + val imageBounds = resolveViewGlobalBounds(subView, pixelDensity) + var entryStr: String? = null + + if (width == null) { + entryStr = injectSvgDimensions(entryData.toString(Charsets.UTF_8), imageBounds.width.toInt(), null) + } + + if (height == null) { + entryStr = injectSvgDimensions(entryStr ?: entryData.toString(Charsets.UTF_8), null, imageBounds.height.toInt()) + } + + if (entryStr != null) { + // Here we update the svg content but keep the original hash without these values + // The goal is to save some time, as it won't matter since the hash is used as an identifier + entryData = entryStr.toByteArray(Charsets.UTF_8); + } + + wireframes.add(MobileSegment.Wireframe.ShapeWireframe( + resolveViewId(view), + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + shapeStyle = shapeStyle, + border = border + )) + + val imgWireframe = MobileSegment.Wireframe.ImageWireframe( + resolveViewId(subView), + imageBounds.x, + imageBounds.y, + imageBounds.width, + imageBounds.height, + null, + null, + null, + null, + hash, + "svg+xml", + false + ) + wireframes.add(imgWireframe) + + if (!queuedResourceIds.contains(hash)) { + queuedResourceIds.add(hash) + internalCallback.addResourceItem( + hash, + entryData, + "image/svg+xml" + ) + } + } + + return wireframes + } + + fun injectSvgDimensions( + svgData: String, + width: Int? = null, + height: Int? = null + ): String? { + val attrs = mutableListOf() + width?.let { attrs.add("""width="$it"""") } + height?.let { attrs.add("""height="$it"""") } + + if (attrs.isEmpty()) return svgData + + val insertIndex = svgData.indexOf("", startIndex = insertIndex) + if (tagEndIndex == -1) return svgData + + val dimensions = " " + attrs.joinToString(" ") + + val builder = StringBuilder(svgData) + builder.insert(tagEndIndex, dimensions) + + return builder.toString() + } +} diff --git a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyView.kt b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyView.kt index 2a98ed9ef..a246c8aab 100644 --- a/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyView.kt +++ b/packages/react-native-session-replay/android/src/main/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyView.kt @@ -50,6 +50,16 @@ class DdPrivacyView(context: Context) : ReactViewGroup(context) { this.setTag(R.id.datadog_hidden, value) } + /** + * Defines an ID value from JS side to uniquely identify a view on both sides. + */ + var nativeID: String? = null + + /** + * Defines a set of attributes used for transformations. + */ + var attributes: Map? = null + init { this.setTag(R.id.datadog_hidden, this.hide) this.setTag(R.id.datadog_image_privacy, this.imagePrivacy) diff --git a/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt b/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt index 7e87dce2b..e933b328a 100644 --- a/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt +++ b/packages/react-native-session-replay/android/src/newarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt @@ -7,6 +7,7 @@ package com.datadog.reactnative.sessionreplay.views import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate @@ -47,4 +48,16 @@ class DdPrivacyViewManager(context: ReactApplicationContext) : ViewGroupManager< override fun setTouchPrivacy(view: DdPrivacyView?, value: String?) { view?.let { view.touchPrivacy = value } } + + @ReactProp(name = "nativeID") + override fun setNativeID(view: DdPrivacyView?, value: String?) { + view?.nativeID = value + } + + @ReactProp(name = "attributes") + override fun setAttributes(view: DdPrivacyView?, map: ReadableMap?) { + view?.attributes = map?.toHashMap()?.mapValues { + it.value.toString() ?: "" + } + } } diff --git a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt index 8d682fba0..7225e7ac7 100644 --- a/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt +++ b/packages/react-native-session-replay/android/src/oldarch/kotlin/com/datadog/reactnative/sessionreplay/views/DdPrivacyViewManager.kt @@ -6,6 +6,7 @@ package com.datadog.reactnative.sessionreplay.views import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.annotations.ReactProp @@ -40,4 +41,16 @@ class DdPrivacyViewManager(context: ReactApplicationContext) : ViewGroupManager< fun setTouchPrivacy(view: DdPrivacyView?, value: String?) { view?.let { view.touchPrivacy = value } } + + @ReactProp(name = "nativeID") + fun setNativeID(view: DdPrivacyView?, value: String?) { + view?.nativeID = value + } + + @ReactProp(name = "attributes") + fun setAttributes(view: DdPrivacyView?, map: ReadableMap?) { + view?.attributes = map?.toHashMap()?.mapValues { + it.value.toString() ?: "" + } + } } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt index 3d6a2c6d5..353c40635 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/DdSessionReplayImplementationTest.kt @@ -6,6 +6,7 @@ package com.datadog.reactnative.sessionreplay +import android.content.res.AssetManager import com.datadog.android.sessionreplay.ImagePrivacy import com.datadog.android.sessionreplay.SessionReplayConfiguration import com.datadog.android.sessionreplay.SessionReplayPrivacy @@ -20,6 +21,7 @@ import fr.xgouchet.elmyr.annotation.BoolForgery import fr.xgouchet.elmyr.annotation.DoubleForgery import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.IOException import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -31,6 +33,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -56,6 +59,9 @@ internal class DdSessionReplayImplementationTest { @Mock lateinit var mockUiManagerModule: UIManagerModule + @Mock + lateinit var mockAssetManager: AssetManager + private val imagePrivacyMap = mapOf( "MASK_ALL" to ImagePrivacy.MASK_ALL, "MASK_NON_BUNDLED_ONLY" to ImagePrivacy.MASK_LARGE_ONLY, @@ -77,6 +83,8 @@ internal class DdSessionReplayImplementationTest { fun `set up`() { whenever(mockReactContext.getNativeModule(any>())) .doReturn(mockUiManagerModule) + whenever(mockReactContext.assets).doReturn(mockAssetManager) + whenever(mockAssetManager.open(any())).doThrow(IOException("No assets in test")) testedSessionReplay = DdSessionReplayImplementation(mockReactContext) { mockSessionReplay } diff --git a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt index ffc144f86..7f1044042 100644 --- a/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt +++ b/packages/react-native-session-replay/android/src/test/kotlin/com/datadog/reactnative/sessionreplay/ReactNativeSessionReplayExtensionSupportTest.kt @@ -6,17 +6,20 @@ package com.datadog.reactnative.sessionreplay +import android.content.res.AssetManager import com.datadog.android.api.InternalLogger import com.datadog.reactnative.sessionreplay.mappers.ReactEditTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactNativeImageViewMapper import com.datadog.reactnative.sessionreplay.mappers.ReactTextMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewGroupMapper import com.datadog.reactnative.sessionreplay.mappers.ReactViewModalMapper +import com.datadog.reactnative.sessionreplay.mappers.SvgViewMapper import com.datadog.reactnative.sessionreplay.utils.text.TextViewUtils import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerModule import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.IOException import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -27,6 +30,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -46,15 +50,24 @@ internal class ReactNativeSessionReplayExtensionSupportTest { @Mock private lateinit var mockLogger: InternalLogger + @Mock + private lateinit var mockAssetManager: AssetManager + private lateinit var testedExtensionSupport: ReactNativeSessionReplayExtensionSupport @BeforeEach fun `set up`() { whenever(mockReactContext.getNativeModule(any>())) .doReturn(mockUiManagerModule) + whenever(mockReactContext.assets).doReturn(mockAssetManager) + whenever(mockAssetManager.open(any())).doThrow(IOException("No assets in test")) + val internalCallback = ReactNativeInternalCallback(mockReactContext) val textViewUtils = TextViewUtils.create(mockReactContext, mockLogger) - testedExtensionSupport = ReactNativeSessionReplayExtensionSupport(textViewUtils) + testedExtensionSupport = ReactNativeSessionReplayExtensionSupport( + textViewUtils, + internalCallback + ) } @Test @@ -63,21 +76,24 @@ internal class ReactNativeSessionReplayExtensionSupportTest { val customViewMappers = testedExtensionSupport.getCustomViewMappers() // Then - assertThat(customViewMappers).hasSize(5) + assertThat(customViewMappers).hasSize(6) assertThat(customViewMappers[0].getUnsafeMapper()) .isInstanceOf(ReactNativeImageViewMapper::class.java) assertThat(customViewMappers[1].getUnsafeMapper()) - .isInstanceOf(ReactViewGroupMapper::class.java) + .isInstanceOf(SvgViewMapper::class.java) assertThat(customViewMappers[2].getUnsafeMapper()) - .isInstanceOf(ReactTextMapper::class.java) + .isInstanceOf(ReactViewGroupMapper::class.java) assertThat(customViewMappers[3].getUnsafeMapper()) - .isInstanceOf(ReactEditTextMapper::class.java) + .isInstanceOf(ReactTextMapper::class.java) assertThat(customViewMappers[4].getUnsafeMapper()) + .isInstanceOf(ReactEditTextMapper::class.java) + + assertThat(customViewMappers[5].getUnsafeMapper()) .isInstanceOf(ReactViewModalMapper::class.java) } } diff --git a/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.h b/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.h new file mode 100644 index 000000000..2806cad49 --- /dev/null +++ b/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.h @@ -0,0 +1,28 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +// DdPrivacyViewFabric.h + +#if RCT_NEW_ARCH_ENABLED +#import +#import +#import +#import +#import +#import + + +#if __has_include("DatadogSDKReactNativeSessionReplay-Swift.h") +#import +#else +#import +#endif + +@interface DdPrivacyViewFabric : RCTViewComponentView +@property (nonatomic, copy) NSString *nativeID; +@property (nonatomic, copy) NSDictionary *attributes; +@end +#endif diff --git a/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.mm b/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.mm index bb65785ca..fb5755b8a 100644 --- a/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.mm +++ b/packages/react-native-session-replay/ios/Sources/DdPrivacyViewFabric.mm @@ -18,14 +18,14 @@ #else #import #endif +#import +#import "DdPrivacyViewFabric.h" using namespace facebook::react; -@interface DdPrivacyViewFabric : RCTViewComponentView -@end - @implementation DdPrivacyViewFabric { UIView * _view; + } + (ComponentDescriptorProvider)componentDescriptorProvider { @@ -40,15 +40,33 @@ - (instancetype)initWithFrame:(CGRect)frame { return self; } + +- (void)setNativeID:(NSString *)nativeID { + objc_setAssociatedObject(self, @selector(nativeID), nativeID, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (NSString *)nativeID { + return objc_getAssociatedObject(self, @selector(nativeID)); +} - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps { const auto &newProps = *std::static_pointer_cast(props); NSString *text = [NSString stringWithUTF8String:newProps.textAndInputPrivacy.c_str()]; NSString *image = [NSString stringWithUTF8String:newProps.imagePrivacy.c_str()]; NSString *touch = [NSString stringWithUTF8String:newProps.touchPrivacy.c_str()]; - + NSString *nativeID = [NSString stringWithUTF8String:newProps.nativeID.c_str()]; + NSMutableDictionary *attributesDict = [NSMutableDictionary new]; + + attributesDict[@"type"] = [NSString stringWithUTF8String:newProps.attributes.type.c_str()]; + attributesDict[@"width"] = [NSString stringWithUTF8String:newProps.attributes.width.c_str()]; + attributesDict[@"height"] = [NSString stringWithUTF8String:newProps.attributes.height.c_str()]; + attributesDict[@"hash"] = [NSString stringWithUTF8String:newProps.attributes.hash.c_str()]; + [DdPrivacyOverrider setOverridesFor:self textPrivacy:text imagePrivacy:image touchPrivacy:touch hide:newProps.hide]; + self.nativeID = nativeID; + self.attributes = attributesDict; + self.accessibilityIdentifier = nativeID; [super updateProps:props oldProps:oldProps]; } diff --git a/packages/react-native-session-replay/ios/Sources/DdPrivacyViewPaper.m b/packages/react-native-session-replay/ios/Sources/DdPrivacyViewPaper.m index 4972c2e86..6c6af0591 100644 --- a/packages/react-native-session-replay/ios/Sources/DdPrivacyViewPaper.m +++ b/packages/react-native-session-replay/ios/Sources/DdPrivacyViewPaper.m @@ -17,6 +17,9 @@ @interface DdPrivacyView : UIView @property (nonatomic, strong) NSString *imagePrivacy; @property (nonatomic, strong) NSString *touchPrivacy; @property (nonatomic, assign) BOOL hide; +@property (nonatomic, copy) NSString *nativeID; + +@property (nonatomic, copy) NSDictionary *attributes; @end @@ -55,6 +58,27 @@ - (UIView *) view { [self setPrivacyOverridesFor:view]; } +RCT_CUSTOM_VIEW_PROPERTY(nativeID, NSString, DdPrivacyView) { + view.nativeID = [RCTConvert NSString:json]; +} + +RCT_CUSTOM_VIEW_PROPERTY(attributes, NSDictionary, DdPrivacyView) { + if (json && [json isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *dict = [NSMutableDictionary new]; + for (id key in json) { + id value = json[key]; + if ([key isKindOfClass:[NSString class]] && [value isKindOfClass:[NSString class]]) { + dict[key] = value; + } else if ([key isKindOfClass:[NSString class]] && value != [NSNull null]) { + dict[key] = [value description]; + } + } + view.attributes = dict; + } else { + view.attributes = nil; + } +} + - (void) setPrivacyOverridesFor:(DdPrivacyView *) view { [DdPrivacyOverrider setOverridesFor:view textPrivacy:view.textPrivacy imagePrivacy:view.imagePrivacy touchPrivacy:view.touchPrivacy hide:view.hide]; } diff --git a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift index e576cb0ae..45cfc2582 100644 --- a/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift +++ b/packages/react-native-session-replay/ios/Sources/DdSessionReplayImplementation.swift @@ -10,6 +10,11 @@ import DatadogInternal import DatadogSDKReactNative import React +internal struct SVGData: Codable { + let offset: Int + let length: Int +} + @objc public class DdSessionReplayImplementation: NSObject { private lazy var sessionReplay: SessionReplayProtocol = sessionReplayProvider() @@ -26,7 +31,7 @@ public class DdSessionReplayImplementation: NSObject { self.uiManager = uiManager self.fabricWrapper = fabricWrapper } - + @objc public convenience init(bridge: RCTBridge) { self.init( @@ -35,7 +40,7 @@ public class DdSessionReplayImplementation: NSObject { fabricWrapper: RCTFabricWrapper() ) } - + @objc public func enable( replaySampleRate: Double, @@ -51,7 +56,7 @@ public class DdSessionReplayImplementation: NSObject { if (customEndpoint != "") { customEndpointURL = URL(string: "\(customEndpoint)/api/v2/replay" as String) } - + var sessionReplayConfiguration = SessionReplay.Configuration( replaySampleRate: Float(replaySampleRate), textAndInputPrivacyLevel: convertTextAndInputPrivacy(textAndInputPrivacyLevel), @@ -60,11 +65,34 @@ public class DdSessionReplayImplementation: NSObject { startRecordingImmediately: startRecordingImmediately, customEndpoint: customEndpointURL ) - + +// let bundle = Bundle(for: DdSessionReplayImplementation.self) + + var svgMap: [String: SVGData] = [:] + + if let bundle = Bundle.ddSessionReplayResources, + let url = bundle.url(forResource: "assets", withExtension: "json") { + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + svgMap = try decoder.decode([String: SVGData].self, from: data) + } catch { + consolePrint("Failed to load or decode assets.json: \(error)", .debug) + } + } + sessionReplayConfiguration.setAdditionalNodeRecorders([ - RCTTextViewRecorder(uiManager: uiManager, fabricWrapper: fabricWrapper) + SvgViewRecorder( + uiManager: uiManager, + fabricWrapper: fabricWrapper, + svgMap: svgMap + ), + RCTTextViewRecorder( + uiManager: uiManager, + fabricWrapper: fabricWrapper + ) ]) - + if let core = DatadogSDKWrapper.shared.getCoreInstance() { sessionReplay.enable( with: sessionReplayConfiguration, diff --git a/packages/react-native-session-replay/ios/Sources/SvgViewRecorder.swift b/packages/react-native-session-replay/ios/Sources/SvgViewRecorder.swift new file mode 100644 index 000000000..0c131c61a --- /dev/null +++ b/packages/react-native-session-replay/ios/Sources/SvgViewRecorder.swift @@ -0,0 +1,223 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import Foundation +@_spi(Internal) +import DatadogSessionReplay +import DatadogInternal +import DatadogSDKReactNative +import UIKit +import React + + +private enum SVGConstants { + static let attributes = "attributes" + static let width = "width" + static let height = "height" + static let hash = "hash" + static let type = "type" + static let svgTypeValue = "svg" +} + +internal struct ReactNativeSVGResource: SessionReplayResource { + let identifier: String + let svgContent: String + let mimeType: String = "image/svg+xml" + + init(id: String, svgContent: String) { + self.identifier = id + self.svgContent = svgContent + } + + func calculateIdentifier() -> String { + return identifier + } + + func calculateData() -> Data { + return svgContent.data(using: .utf8) ?? Data() + } +} + + +internal class SvgViewRecorder: SessionReplayNodeRecorder { + internal var identifier = UUID() + + internal let uiManager: RCTUIManager + internal let fabricWrapper: RCTFabricWrapper + internal let svgMap: [String: SVGData] + + internal init(uiManager: RCTUIManager, fabricWrapper: RCTFabricWrapper, svgMap: [String: SVGData]) { + self.uiManager = uiManager + self.fabricWrapper = fabricWrapper + self.svgMap = svgMap + } + + func semantics( + of view: UIView, + with attributes: SessionReplayViewAttributes, + in context: SessionReplayViewTreeRecordingContext + ) -> SessionReplayNodeSemantics? { + + guard view.accessibilityIdentifier != nil else { + return nil + } + + guard let attrs = view.value(forKey: SVGConstants.attributes) as? [String: String] else { + return nil + } + + guard let type = attrs[SVGConstants.type], type == "svg" else { + return nil + } + + guard let hash = attrs[SVGConstants.hash] else { + return nil + } + + let bundle = Bundle.ddSessionReplayResources + guard let url = bundle?.url(forResource: "assets", withExtension: "bin") else { + return nil + } + + guard let subView = view.subviews.first else { + return nil + } + + let viewId = context.ids.nodeID(view: view, nodeRecorder: self) + let svgId = context.ids.nodeID(view: subView, nodeRecorder: self) + + do { + guard let svgInfo = svgMap[hash] else { + return nil + } + + let fileHandle = try FileHandle(forReadingFrom: url) + defer { try? fileHandle.close() } + + try fileHandle.seek(toOffset: UInt64(svgInfo.offset)) + let svgDataChunk = try fileHandle.read(upToCount: svgInfo.length) + + guard let svgDataChunk = svgDataChunk, + var svgData = String(data: svgDataChunk, encoding: .utf8) else { + return nil + } + + if let updatedSvgData = handleDynamicSvgDimensions(view: subView, svgData: svgData, attrs: attrs) { + svgData = updatedSvgData + } + + let svgResource = ReactNativeSVGResource( + id: hash, + svgContent: svgData + ) + + let contentFrame = CGRect( + origin: attributes.frame.origin, + size: CGSize(width: subView.bounds.width, + height: subView.bounds.height) + ) + + let builder = SvgViewWireframesBuilder( + wireframeID: viewId, + imageWireframeID: svgId, + attributes: attributes, + contentFrame: contentFrame, + svgResource: svgResource, + imagePrivacyLevel: context.recorder.imagePrivacy + ) + + // Children are ignored because they were already processed and converted by the babel plugin + let element = SessionReplaySpecificElement(subtreeStrategy: .ignore, nodes: [ + SessionReplayNode(viewAttributes: attributes, wireframesBuilder: builder) + ]) + + return element + } catch { + return nil + } + } +} + +func handleDynamicSvgDimensions(view: UIView, svgData: String, attrs: [String: String]) -> String? { + let width = attrs[SVGConstants.width] ?? "" + let height = attrs[SVGConstants.height] ?? "" + + var svgAttributes: [String] = [] + + if width.isEmpty { + svgAttributes.append(#"width="\#(Int(view.bounds.width))""#) + } + + if height.isEmpty { + svgAttributes.append(#"height="\#(Int(view.bounds.height))""#) + } + + if !svgAttributes.isEmpty { + // Here we update the svg content but keep the original hash without these values + // The goal is to save some time, as it won't matter since the hash is used as an identifier + var svg = svgData + let pattern = #"]*>"# + + if let match = svg.range(of: pattern, options: .regularExpression) { + let dimensions = " " + svgAttributes.joined(separator: " ") + + if let closingBracketRange = svg.range(of: ">", range: match.lowerBound..") + return svg + } + } + + } + + return nil +} + + +internal struct SvgViewWireframesBuilder: SessionReplayNodeWireframesBuilder { + let wireframeID: WireframeID + + var wireframeRect: CGRect { + attributes.frame + } + + let imageWireframeID: WireframeID + + let attributes: SessionReplayViewAttributes + + let contentFrame: CGRect? + + let svgResource: ReactNativeSVGResource? + + let imagePrivacyLevel: ImagePrivacyLevel + + func buildWireframes(with builder: SessionReplayWireframesBuilder) -> [SRWireframe] { + var wireframes = [ + builder.createShapeWireframe( + id: wireframeID, + frame: attributes.frame, + clip: attributes.clip, + borderColor: attributes.layerBorderColor, + borderWidth: attributes.layerBorderWidth, + backgroundColor: attributes.backgroundColor, + cornerRadius: attributes.layerCornerRadius, + opacity: attributes.alpha + ) + ] + + if let svgResource { + wireframes.append( + builder.createImageWireframe( + id: imageWireframeID, + resource: svgResource, + frame: contentFrame ?? attributes.frame, + clip: attributes.clip + ) + ) + } + + return wireframes + } +} diff --git a/packages/react-native-session-replay/ios/Sources/Utils/Bundle+SessionReplay.swift b/packages/react-native-session-replay/ios/Sources/Utils/Bundle+SessionReplay.swift new file mode 100644 index 000000000..17e6f2d10 --- /dev/null +++ b/packages/react-native-session-replay/ios/Sources/Utils/Bundle+SessionReplay.swift @@ -0,0 +1,21 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import Foundation + +internal class BundleFinder {} + +extension Bundle { + static var ddSessionReplayResources: Bundle? { + let bundle = Bundle(for: BundleFinder.self) + if let resourceURL = bundle.url(forResource: "DDSessionReplay", withExtension: "bundle"), + let resourceBundle = Bundle(url: resourceURL) { + return resourceBundle + } + + return nil + } +} diff --git a/packages/react-native-session-replay/package.json b/packages/react-native-session-replay/package.json index 652bb486b..b471099ac 100644 --- a/packages/react-native-session-replay/package.json +++ b/packages/react-native-session-replay/package.json @@ -19,6 +19,7 @@ "files": [ "src/**", "lib/**", + "assets/**", "scripts/**", "android/build.gradle", "android/detekt.yml", @@ -35,13 +36,27 @@ "react-native": "src/index", "source": "src", "module": "lib/module/index", + "exports": { + ".": { + "import": "./lib/module/index.js", + "require": "./lib/commonjs/index.js", + "types": "./lib/typescript/index.d.ts" + }, + "./metro": { + "import": "./lib/module/metro/index.js", + "require": "./lib/commonjs/metro/index.js", + "types": "./lib/typescript/metro/index.d.ts" + }, + "./package.json": "./package.json" + }, "publishConfig": { "access": "public" }, "scripts": { "test": "jest", "lint": "eslint .", - "prepare": "rm -rf lib && yarn bob build", + "build:assets": "node scripts/build-assets.js", + "prepare": "rm -rf lib && yarn build:assets && yarn bob build", "postinstall": "node scripts/set-ios-rn-version.js" }, "peerDependencies": { @@ -89,5 +104,8 @@ "android": { "javaPackageName": "com.datadog.reactnative.sessionreplay" } + }, + "dependencies": { + "chokidar": "^4.0.3" } } diff --git a/packages/react-native-session-replay/scripts/build-assets.js b/packages/react-native-session-replay/scripts/build-assets.js new file mode 100644 index 000000000..6721879e4 --- /dev/null +++ b/packages/react-native-session-replay/scripts/build-assets.js @@ -0,0 +1,31 @@ +/* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ +const fs = require('fs'); +const path = require('path'); + +const assetsDir = path.resolve(__dirname, '..', 'assets'); +const binPath = path.join(assetsDir, 'assets.bin'); +const jsonPath = path.join(assetsDir, 'assets.json'); + +function ensureAssets() { + try { + if (fs.existsSync(assetsDir)) { + fs.rmSync(assetsDir, { recursive: true, force: true }); + } + + fs.mkdirSync(assetsDir, { recursive: true }); + + fs.writeFileSync(binPath, Buffer.alloc(0)); + + fs.writeFileSync(jsonPath, JSON.stringify({}, null, 2)); + } catch (error) { + console.error('Error creating assets:', error.message); + process.exit(1); + } +} + +ensureAssets(); diff --git a/packages/react-native-session-replay/src/components/SessionReplayView/PrivacyView.tsx b/packages/react-native-session-replay/src/components/SessionReplayView/PrivacyView.tsx index eeccb5df9..a8a7d7945 100644 --- a/packages/react-native-session-replay/src/components/SessionReplayView/PrivacyView.tsx +++ b/packages/react-native-session-replay/src/components/SessionReplayView/PrivacyView.tsx @@ -13,6 +13,7 @@ import type { TouchPrivacyLevel } from '../../SessionReplay'; import View from '../../specs/DdPrivacyView'; +import type { Attributes } from '../../types/DdPrivacyView'; type Props = ViewProps & { /** @@ -31,6 +32,14 @@ type Props = ViewProps & { * When true, completely hides this view and its children from session replays. */ hide?: boolean; + /** + * When set, allows the view to be uniquely identifiable in the native layer. + */ + nativeID?: string; + /** + * When set, allows view attributes to be passed to the native layer. + */ + attributes?: Attributes; }; /** @@ -50,6 +59,7 @@ export function PrivacyView({ textAndInputPrivacy, imagePrivacy, touchPrivacy, + nativeID, hide = false, ...props }: Props) { @@ -60,6 +70,7 @@ export function PrivacyView({ imagePrivacy={imagePrivacy as string} touchPrivacy={touchPrivacy as string} hide={hide || false} + nativeID={nativeID as string} > {children} diff --git a/packages/react-native-session-replay/src/metro/index.ts b/packages/react-native-session-replay/src/metro/index.ts new file mode 100644 index 000000000..5891c72ff --- /dev/null +++ b/packages/react-native-session-replay/src/metro/index.ts @@ -0,0 +1,79 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import chokidar from 'chokidar'; +import fs from 'fs'; +import path from 'path'; + +import { mergeSvgAssets } from './processing'; +import { debounce } from './utils'; + +let watching = false; + +const MERGE_DEBOUNCE_MS = 300; +const WATCH_STABILITY_THRESHOLD_MS = 200; +const WATCH_POLL_INTERVAL_MS = 50; + +export function withSessionReplayAssetBundler(metroConfig: any): any { + const originalReporter = metroConfig.reporter; + + const assetsDir = path.resolve(__dirname, '../../../assets'); + + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + + const debounceMerge = debounce(() => { + try { + mergeSvgAssets(assetsDir); + } catch (error) { + console.warn('[SessionReplayAggregator] merge failed:', error); + } + }, MERGE_DEBOUNCE_MS); + + // Prevent potential multiple watchers if metro loads the config multiple times + if (!watching) { + watching = true; + chokidar + .watch(assetsDir, { + // Skip events for files that already exist + ignoreInitial: true, + depth: 0, + awaitWriteFinish: { + // Wait 200ms after last change + stabilityThreshold: WATCH_STABILITY_THRESHOLD_MS, + // Check every 50ms + pollInterval: WATCH_POLL_INTERVAL_MS + } + }) + .on('add', file => { + if (!file.endsWith('.svg')) { + return; + } + + debounceMerge(); + }); + } + + return { + ...metroConfig, + reporter: { + update(event: any) { + if (originalReporter?.update) { + originalReporter.update(event); + } + + // https://github.com/facebook/metro/blob/main/packages/metro/src/lib/reporting.js + if ( + event.type === 'bundle_build_done' || + event.type === 'transformer_load_done' + ) { + mergeSvgAssets(assetsDir); + } + } + } + }; +} diff --git a/packages/react-native-session-replay/src/metro/processing.ts b/packages/react-native-session-replay/src/metro/processing.ts new file mode 100644 index 000000000..767c268c8 --- /dev/null +++ b/packages/react-native-session-replay/src/metro/processing.ts @@ -0,0 +1,119 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import fs from 'fs'; +import path from 'path'; + +type SvgIndexEntry = { + offset: number; + length: number; +}; + +type SvgIndex = Record; + +/** + * Removes the binary and JSON asset files from the specified directory. + * This is used to clean up corrupted or partial asset files before regenerating them. + * + * @param binPath - Absolute path to the assets.bin file + * @param jsonPath - Absolute path to the assets.json file + */ +function cleanupFiles(binPath: string, jsonPath: string) { + try { + fs.unlinkSync(binPath); + } catch (err) { + console.warn('[cleanupFiles] Failed to cleanup binary assets', err); + } + + try { + fs.unlinkSync(jsonPath); + } catch (err) { + console.warn('[cleanupFiles] Failed to cleanup json assets', err); + } +} + +/** + * Merges all individual SVG files into assets.bin and creates an index in assets.json. + * This function reads all .svg files from the assets directory and packs them into + * a single binary file with an accompanying JSON index for efficient lookup. + * + * @param assetsDir - Absolute path to the assets directory + */ +export function mergeSvgAssets(assetsDir: string) { + try { + const binName = 'assets.bin'; + const jsonName = 'assets.json'; + + const binPath = path.resolve(assetsDir, binName); + const jsonPath = path.resolve(assetsDir, jsonName); + + let index: SvgIndex = {}; + let offset = 0; + + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + + try { + const jsonData = fs.readFileSync(jsonPath, 'utf8'); + const binStats = fs.statSync(binPath); + + index = JSON.parse(jsonData) as SvgIndex; + offset = binStats.size; + } catch (err) { + console.warn( + '[mergeSvgAssets] Assets missing or corrupted, starting fresh' + ); + index = {}; + offset = 0; + cleanupFiles(binPath, jsonPath); + } + + const files = fs + .readdirSync(assetsDir) + .filter(f => f.endsWith('.svg')) + .sort(); + + let added = 0; + + for (const f of files) { + const id = path.basename(f, path.extname(f)); + if (index[id]) { + continue; + } + + try { + const svg = fs.readFileSync(path.join(assetsDir, f), 'utf8'); + const buf = Buffer.from(svg, 'utf8'); + const length = buf.length; + + fs.appendFileSync(binPath, buf); + index[id] = { offset, length }; + offset += length; + added++; + } catch (err) { + console.warn( + `[SessionReplayAssetBundler] Failed to process ${f}:`, + err + ); + } + } + + fs.writeFileSync(jsonPath, JSON.stringify(index, null, 2)); + if (added > 0) { + console.info( + `[SessionReplayAssetBundler] Packed ${added} new Session Replay SVG assets → total: ${ + Object.keys(index).length + }` + ); + } + } catch (err) { + console.error( + '[mergeSvgAssets] Unexpected error during asset merge', + err + ); + } +} diff --git a/packages/react-native-session-replay/src/metro/utils.ts b/packages/react-native-session-replay/src/metro/utils.ts new file mode 100644 index 000000000..637de752a --- /dev/null +++ b/packages/react-native-session-replay/src/metro/utils.ts @@ -0,0 +1,17 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +export function debounce void>( + fn: T, + ms: number +): T { + let timer: NodeJS.Timeout; + + return ((...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +} diff --git a/packages/react-native-session-replay/src/specs/DdPrivacyViewNativeComponent.ts b/packages/react-native-session-replay/src/specs/DdPrivacyViewNativeComponent.ts index 6ce5b03b7..dce238718 100644 --- a/packages/react-native-session-replay/src/specs/DdPrivacyViewNativeComponent.ts +++ b/packages/react-native-session-replay/src/specs/DdPrivacyViewNativeComponent.ts @@ -7,11 +7,20 @@ import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; import type { HostComponent, ViewProps } from 'react-native'; +type Attributes = { + type?: string; + hash?: string; + width?: string; + height?: string; +}; + interface DdPrivacyViewProps extends ViewProps { textAndInputPrivacy: string; imagePrivacy: string; touchPrivacy: string; hide: boolean; + nativeID: string; + attributes: Attributes; } export default codegenNativeComponent('DdPrivacyView', { diff --git a/packages/react-native-session-replay/src/types/DdPrivacyView.ts b/packages/react-native-session-replay/src/types/DdPrivacyView.ts index 4621af0ca..f89525e51 100644 --- a/packages/react-native-session-replay/src/types/DdPrivacyView.ts +++ b/packages/react-native-session-replay/src/types/DdPrivacyView.ts @@ -6,9 +6,18 @@ import type { ViewProps } from 'react-native'; +export type Attributes = { + type?: string; + hash?: string; + width?: string; + height?: string; +}; + export interface DdPrivacyViewProps extends ViewProps { textAndInputPrivacy: string; imagePrivacy: string; touchPrivacy: string; hide: boolean; + nativeID: string; + attributes: Attributes; } diff --git a/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec b/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec index 647126555..3d4668bb0 100644 --- a/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec +++ b/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec @@ -23,8 +23,8 @@ Pod::Spec.new do |s| end # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogWebViewTracking', '2.30.0' - s.dependency 'DatadogInternal', '2.30.0' + s.dependency 'DatadogWebViewTracking', '2.30.2' + s.dependency 'DatadogInternal', '2.30.2' s.dependency 'DatadogSDKReactNative' s.test_spec 'Tests' do |test_spec| diff --git a/packages/react-native-webview/android/build.gradle b/packages/react-native-webview/android/build.gradle index 145cb1528..973b3ec5f 100644 --- a/packages/react-native-webview/android/build.gradle +++ b/packages/react-native-webview/android/build.gradle @@ -190,7 +190,7 @@ dependencies { implementation "com.facebook.react:react-android:$reactNativeVersion" } - implementation "com.datadoghq:dd-sdk-android-webview:2.25.0" + implementation "com.datadoghq:dd-sdk-android-webview:2.26.2" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native') diff --git a/yarn.lock b/yarn.lock index cceb9dfdc..f747a9a64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1751,6 +1751,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.28.3": + version: 7.28.3 + resolution: "@babel/generator@npm:7.28.3" + dependencies: + "@babel/parser": ^7.28.3 + "@babel/types": ^7.28.2 + "@jridgewell/gen-mapping": ^0.3.12 + "@jridgewell/trace-mapping": ^0.3.28 + jsesc: ^3.0.2 + checksum: e2202bf2b9c8a94f7e7a0a049fda0ee037d055c46922e85afa3bbc53309113f859b8193894f991045d7865226028b8f4f06152ed315ab414451932016dba5e42 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" @@ -1999,6 +2012,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/parser@npm:7.28.4" + dependencies: + "@babel/types": ^7.28.4 + bin: + parser: ./bin/babel-parser.js + checksum: d95e283fe1153039b396926ef567ca1ab114afb5c732a23bbcbbd0465ac59971aeb6a63f37593ce7671a52d34ec52b23008c999d68241b42d26928c540464063 + languageName: node + linkType: hard + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.3, @babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.27.1" @@ -3550,6 +3574,21 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/traverse@npm:7.28.4" + dependencies: + "@babel/code-frame": ^7.27.1 + "@babel/generator": ^7.28.3 + "@babel/helper-globals": ^7.28.0 + "@babel/parser": ^7.28.4 + "@babel/template": ^7.27.2 + "@babel/types": ^7.28.4 + debug: ^4.3.1 + checksum: d603b8ce4e55ba4fc7b28d3362cc2b1b20bc887e471c8a59fe87b2578c26803c9ef8fcd118081dd8283ea78e0e9a6df9d88c8520033c6aaf81eec30d2a669151 + languageName: node + linkType: hard + "@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.2, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.27.6 resolution: "@babel/types@npm:7.27.6" @@ -3570,6 +3609,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/types@npm:7.28.4" + dependencies: + "@babel/helper-string-parser": ^7.27.1 + "@babel/helper-validator-identifier": ^7.27.1 + checksum: a369b4fb73415a2ed902a15576b49696ae9777ddee394a7a904c62e6fbb31f43906b0147ae0b8f03ac17f20c248eac093df349e33c65c94617b12e524b759694 + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -3725,21 +3774,29 @@ __metadata: dependencies: "@babel/cli": ^7.27.2 "@babel/core": ^7.27.1 + "@babel/generator": ^7.28.0 "@babel/helper-plugin-utils": ^7.27.1 + "@babel/parser": ^7.28.4 "@babel/preset-env": ^7.27.2 "@babel/preset-react": ^7.27.1 "@babel/preset-typescript": ^7.27.1 + "@babel/traverse": ^7.28.4 "@babel/types": ^7.27.1 "@swc/core": ^1.11.31 "@swc/jest": ^0.2.38 "@types/jest": ^29.5.14 + fast-glob: ^3.3.3 jest: ^29.7.0 react-native-builder-bob: 0.26.0 + svgo: ^4.0.0 tsc-alias: ^1.8.16 typescript: 5.0.4 + uuid: ^13.0.0 peerDependencies: react: ">=16.13.1" react-native: ">=0.63.4 <1.0" + bin: + datadog-generate-sr-assets: ./lib/commonjs/cli/generate-sr-assets.js languageName: unknown linkType: soft @@ -3782,6 +3839,7 @@ __metadata: resolution: "@datadog/mobile-react-native-session-replay@workspace:packages/react-native-session-replay" dependencies: "@testing-library/react-native": 7.0.2 + chokidar: ^4.0.3 react-native-builder-bob: 0.26.0 peerDependencies: react: ">=16.13.1" @@ -9126,6 +9184,13 @@ __metadata: languageName: node linkType: hard +"boolbase@npm:^1.0.0": + version: 1.0.0 + resolution: "boolbase@npm:1.0.0" + checksum: 3e25c80ef626c3a3487c73dbfc70ac322ec830666c9ad915d11b701142fab25ec1e63eff2c450c74347acfd2de854ccde865cd79ef4db1683f7c7b046ea43bb0 + languageName: node + linkType: hard + "boolean@npm:^3.0.1": version: 3.2.0 resolution: "boolean@npm:3.2.0" @@ -9490,6 +9555,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^4.0.3": + version: 4.0.3 + resolution: "chokidar@npm:4.0.3" + dependencies: + readdirp: ^4.0.1 + checksum: a8765e452bbafd04f3f2fad79f04222dd65f43161488bb6014a41099e6ca18d166af613d59a90771908c1c823efa3f46ba36b86ac50b701c20c1b9908c5fe36e + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -9798,6 +9872,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^11.1.0": + version: 11.1.0 + resolution: "commander@npm:11.1.0" + checksum: fd1a8557c6b5b622c89ecdfde703242ab7db3b628ea5d1755784c79b8e7cb0d74d65b4a262289b533359cd58e1bfc0bf50245dfbcd2954682a6f367c828b79ef + languageName: node + linkType: hard + "commander@npm:^12.0.0": version: 12.1.0 resolution: "commander@npm:12.1.0" @@ -10159,6 +10240,46 @@ __metadata: languageName: node linkType: hard +"css-select@npm:^5.1.0": + version: 5.2.2 + resolution: "css-select@npm:5.2.2" + dependencies: + boolbase: ^1.0.0 + css-what: ^6.1.0 + domhandler: ^5.0.2 + domutils: ^3.0.1 + nth-check: ^2.0.1 + checksum: 0ab672620c6bdfe4129dfecf202f6b90f92018b24a1a93cfbb295c24026d0163130ba4b98d7443f87246a2c1d67413798a7a5920cd102b0cfecfbc89896515aa + languageName: node + linkType: hard + +"css-tree@npm:^3.0.1": + version: 3.1.0 + resolution: "css-tree@npm:3.1.0" + dependencies: + mdn-data: 2.12.2 + source-map-js: ^1.0.1 + checksum: 6b8c713c22b7223c0e71179575c3bbf421a13a61641204645d6c3b560bdc4ffed8d676220bbcb83777e07b46a934ec3b1c629aa61d57422c196c8e2e7417ee1a + languageName: node + linkType: hard + +"css-tree@npm:~2.2.0": + version: 2.2.1 + resolution: "css-tree@npm:2.2.1" + dependencies: + mdn-data: 2.0.28 + source-map-js: ^1.0.1 + checksum: b94aa8cc2f09e6f66c91548411fcf74badcbad3e150345074715012d16333ce573596ff5dfca03c2a87edf1924716db765120f94247e919d72753628ba3aba27 + languageName: node + linkType: hard + +"css-what@npm:^6.1.0": + version: 6.2.2 + resolution: "css-what@npm:6.2.2" + checksum: 4d1f07b348a638e1f8b4c72804a1e93881f35e0f541256aec5ac0497c5855df7db7ab02da030de950d4813044f6d029a14ca657e0f92c3987e4b604246235b2b + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -10168,6 +10289,15 @@ __metadata: languageName: node linkType: hard +"csso@npm:^5.0.5": + version: 5.0.5 + resolution: "csso@npm:5.0.5" + dependencies: + css-tree: ~2.2.0 + checksum: 0ad858d36bf5012ed243e9ec69962a867509061986d2ee07cc040a4b26e4d062c00d4c07e5ba8d430706ceb02dd87edd30a52b5937fd45b1b6f2119c4993d59a + languageName: node + linkType: hard + "csstype@npm:^3.0.2": version: 3.1.3 resolution: "csstype@npm:3.1.3" @@ -10733,6 +10863,44 @@ __metadata: languageName: node linkType: hard +"dom-serializer@npm:^2.0.0": + version: 2.0.0 + resolution: "dom-serializer@npm:2.0.0" + dependencies: + domelementtype: ^2.3.0 + domhandler: ^5.0.2 + entities: ^4.2.0 + checksum: cd1810544fd8cdfbd51fa2c0c1128ec3a13ba92f14e61b7650b5de421b88205fd2e3f0cc6ace82f13334114addb90ed1c2f23074a51770a8e9c1273acbc7f3e6 + languageName: node + linkType: hard + +"domelementtype@npm:^2.3.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: ee837a318ff702622f383409d1f5b25dd1024b692ef64d3096ff702e26339f8e345820f29a68bcdcea8cfee3531776b3382651232fbeae95612d6f0a75efb4f6 + languageName: node + linkType: hard + +"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": + version: 5.0.3 + resolution: "domhandler@npm:5.0.3" + dependencies: + domelementtype: ^2.3.0 + checksum: 0f58f4a6af63e6f3a4320aa446d28b5790a009018707bce2859dcb1d21144c7876482b5188395a188dfa974238c019e0a1e610d2fc269a12b2c192ea2b0b131c + languageName: node + linkType: hard + +"domutils@npm:^3.0.1": + version: 3.2.2 + resolution: "domutils@npm:3.2.2" + dependencies: + dom-serializer: ^2.0.0 + domelementtype: ^2.3.0 + domhandler: ^5.0.3 + checksum: ae941d56f03d857077d55dde9297e960a625229fc2b933187cc4123084d7c2d2517f58283a7336567127029f1e008449bac8ac8506d44341e29e3bb18e02f906 + languageName: node + linkType: hard + "dot-prop@npm:^5.1.0": version: 5.3.0 resolution: "dot-prop@npm:5.3.0" @@ -10927,6 +11095,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.2.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 853f8ebd5b425d350bffa97dd6958143179a5938352ccae092c62d1267c4e392a039be1bae7d51b6e4ffad25f51f9617531fedf5237f15df302ccfb452cbf2d7 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -11769,7 +11944,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2": +"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2, fast-glob@npm:^3.3.3": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -15494,6 +15669,20 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.0.28": + version: 2.0.28 + resolution: "mdn-data@npm:2.0.28" + checksum: f51d587a6ebe8e426c3376c74ea6df3e19ec8241ed8e2466c9c8a3904d5d04397199ea4f15b8d34d14524b5de926d8724ae85207984be47e165817c26e49e0aa + languageName: node + linkType: hard + +"mdn-data@npm:2.12.2": + version: 2.12.2 + resolution: "mdn-data@npm:2.12.2" + checksum: 77f38c180292cfbbd41c06641a27940cc293c08f47faa98f80bf64f98bb1b2a804df371e864e31a1ea97bdf181c0b0f85a2d96d1a6261f43c427b32222f33f1f + languageName: node + linkType: hard + "memoize-one@npm:^5.0.0": version: 5.2.1 resolution: "memoize-one@npm:5.2.1" @@ -16765,6 +16954,15 @@ __metadata: languageName: node linkType: hard +"nth-check@npm:^2.0.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" + dependencies: + boolbase: ^1.0.0 + checksum: 5afc3dafcd1573b08877ca8e6148c52abd565f1d06b1eb08caf982e3fa289a82f2cae697ffb55b5021e146d60443f1590a5d6b944844e944714a5b549675bcd3 + languageName: node + linkType: hard + "nullthrows@npm:^1.1.1": version: 1.1.1 resolution: "nullthrows@npm:1.1.1" @@ -18624,6 +18822,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^4.0.1": + version: 4.1.2 + resolution: "readdirp@npm:4.1.2" + checksum: 3242ee125422cb7c0e12d51452e993f507e6ed3d8c490bc8bf3366c5cdd09167562224e429b13e9cb2b98d4b8b2b11dc100d3c73883aa92d657ade5a21ded004 + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -19160,7 +19365,7 @@ __metadata: languageName: node linkType: hard -"sax@npm:>=0.6.0": +"sax@npm:>=0.6.0, sax@npm:^1.4.1": version: 1.4.1 resolution: "sax@npm:1.4.1" checksum: 3ad64df16b743f0f2eb7c38ced9692a6d924f1cd07bbe45c39576c2cf50de8290d9d04e7b2228f924c7d05fecc4ec5cf651423278e0c7b63d260c387ef3af84a @@ -19580,6 +19785,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.0.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 4eb0cd997cdf228bc253bcaff9340afeb706176e64868ecd20efbe6efea931465f43955612346d6b7318789e5265bdc419bc7669c1cebe3db0eb255f57efa76b + languageName: node + linkType: hard + "source-map-support@npm:0.5.13": version: 0.5.13 resolution: "source-map-support@npm:0.5.13" @@ -20157,6 +20369,23 @@ __metadata: languageName: node linkType: hard +"svgo@npm:^4.0.0": + version: 4.0.0 + resolution: "svgo@npm:4.0.0" + dependencies: + commander: ^11.1.0 + css-select: ^5.1.0 + css-tree: ^3.0.1 + css-what: ^6.1.0 + csso: ^5.0.5 + picocolors: ^1.1.1 + sax: ^1.4.1 + bin: + svgo: ./bin/svgo.js + checksum: bddf57bda7b4e8252e5f8c5aa8555ec9810f963369bad17476cf21881e0b5fd3dfffcffddebd7b8b4c80b7e64b508589fad5be055d143d70b28ef0dee2583fcc + languageName: node + linkType: hard + "symbol-observable@npm:^4.0.0": version: 4.0.0 resolution: "symbol-observable@npm:4.0.0" @@ -20924,6 +21153,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^13.0.0": + version: 13.0.0 + resolution: "uuid@npm:13.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 7510ee1ab371be5339ef26ff8cabc2f4a2c60640ff880652968f758072f53bd4f4af1c8b0e671a8c9bb29ef926a24dec3ef0e3861d78183b39291a85743a9f96 + languageName: node + linkType: hard + "uuid@npm:^7.0.3": version: 7.0.3 resolution: "uuid@npm:7.0.3"