Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,6 @@ require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'

# --- RN-floor gate: Fabric SRMaskView (cpp + ios/fabric) needs new-arch + RN >= 0.77 ---
new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1'
rn_version = nil
begin
rn_pkg_path = `node -e "console.log(require.resolve('react-native/package.json'))"`.strip
rn_version = JSON.parse(File.read(rn_pkg_path))["version"] unless rn_pkg_path.empty?
rescue StandardError
rn_version = nil
end
srmaskview_version_ge_077 = lambda do |v|
next false if v.nil?
parts = v.split('.')
major = parts[0].to_i
minor = parts[1].to_i
major > 0 || (major == 0 && minor >= 77)
end
# Only a KNOWN RN below 0.77 disqualifies Fabric. When the version is unresolvable,
# the Fabric sources must still build: codegen (type:"all") references SRMaskViewCls
# unconditionally, so omitting them would break linking. (Android gates the same way.)
rn_known_below_077 = !rn_version.nil? && !srmaskview_version_ge_077.call(rn_version)
if new_arch_enabled && rn_known_below_077
raise "[AmplitudeSessionReplayReactNative] The Fabric SRMaskView component requires React Native >= 0.77 with the New Architecture (found #{rn_version})."
end
fabric_enabled = new_arch_enabled && !rn_known_below_077

Pod::Spec.new do |s|
s.name = "AmplitudeSessionReplayReactNative"
s.version = package["version"].split(/[-+]/).first
Expand All @@ -39,15 +14,7 @@ Pod::Spec.new do |s|
s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => "https://github.com/amplitude/Amplitude-TypeScript.git", :tag => "#{s.version}" }

if fabric_enabled
# Fabric build: include the C++ ShadowNode + the ios/fabric host view.
s.source_files = "ios/**/*.{h,m,mm,swift}", "cpp/**/*.{h,cpp}"
s.public_header_files = "ios/SRMaskingPrimitive.h", "ios/NativeSessionReplay-Bridging-Header.h"
s.private_header_files = "ios/fabric/**/*.h", "cpp/**/*.h"
else
# No Fabric: exclude cpp/** and ios/fabric/** (they pull in renderer headers).
s.source_files = "ios/*.{h,m,mm,swift}"
end
s.source_files = "ios/**/*.{h,m,mm,swift}"

s.dependency 'AmplitudeSessionReplay', '>=0.11.1'
s.dependency 'AmplitudeCore', '>=1.4.2'
Expand All @@ -57,14 +24,6 @@ Pod::Spec.new do |s|
# See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
if respond_to?(:install_modules_dependencies, true)
install_modules_dependencies(s)
if fabric_enabled
# Fabric shadow-node C++ headers break Clang module dependency scanning.
existing_xcconfig = s.attributes_hash["pod_target_xcconfig"] || {}
s.pod_target_xcconfig = existing_xcconfig.merge({
"DEFINES_MODULE" => "NO",
"CLANG_ENABLE_EXPLICIT_MODULES" => "NO",
})
end
else
s.dependency "React-Core"

Expand All @@ -83,4 +42,4 @@ Pod::Spec.new do |s|
s.dependency "ReactCommon/turbomodule/core"
end
end
end
end
112 changes: 0 additions & 112 deletions packages/session-replay-react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,6 @@ the SDK keeps working on older React Native versions on the legacy architecture.
The TurboModule code path is compiled only when the New Architecture is enabled,
which itself requires React Native 0.74 or newer.

### Fabric-based masking

The layout-transparent masking components (`AmpMask` / `AmpUnmask`, see
[Layout-transparent masking](#layout-transparent-masking-with-ampmask--ampunmask-experimental))
are built on Fabric.

The Fabric/C++ sources compile only on the New Architecture with React Native
0.77 or newer (they rely on capabilities that exist only in those versions).
On the legacy architecture, or on the New Architecture with React Native older
than 0.77, the Fabric sources are excluded. Enabling the New Architecture on
React Native older than 0.77 fails the build fast with a clear error (an
"RN-floor gate" enforced in both `android/build.gradle` and the iOS podspec).

## Usage

### Session Replay React Native Standalone SDK
Expand Down Expand Up @@ -115,105 +102,6 @@ import { AmpMaskView } from '@amplitude/session-replay-react-native';
</AmpMaskView>;
```

## Layout-transparent masking with `AmpMask` / `AmpUnmask` (Experimental)

> **@experimental** — this API is new and may change in a future release.

`AmpMaskView` wraps its children in an extra native view, which introduces a
layout boundary: children that depend on their parent for sizing (`flex: 1`,
percentage heights, `position: 'absolute'`) can shift or collapse to zero.
`AmpMask` and `AmpUnmask` are layout-transparent replacements: they mark their
children as masked/unmasked in the replay without affecting layout at all —
wrapping content in `<AmpMask>` renders pixel-identical to not wrapping it.

### Requirements

`AmpMask`/`AmpUnmask` require React Native **0.77 or newer** with the
**New Architecture** enabled (Fabric) **with bridgeless enabled** (the RN
0.77 default). On Fabric without bridgeless (bridge mode) — as well as on
the Old Architecture — they are not supported as a layout-transparent path:

- On the Old Architecture, in development they throw with a clear error;
in production they fall back to `AmpMaskView` and log a one-time
`console.error`.
- On Fabric without bridgeless (bridge mode), they never throw — they
always fall back to `AmpMaskView` and log a one-time `console.error`,
in both development and production (bridge-mode Fabric cannot detect
`SRMaskView` on iOS).
- Both fallbacks **ignore `enabled`** — wrapped content stays masked
regardless (it fails toward privacy). Neither is layout-transparent —
the `AmpMaskView` layout caveats above apply. Use `AmpMaskView`
directly outside the bridgeless-Fabric path.

### Caveats

- `style` is not supported on `<AmpMask>`/`<AmpUnmask>` — they never occupy
layout, so there is no box to style. Style your children directly instead.
- `enabled` is only honored on the layout-transparent Fabric path — all
fallback paths (Old Architecture, Fabric bridge-mode, and the
build-misconfiguration cases below) ignore it and keep content masked
regardless (they fail toward privacy).
- If the New Architecture is active but the native `SRMaskView` component is
missing — including on Fabric without bridgeless, which cannot detect
`SRMaskView` on iOS and always falls back — `<AmpMask>`/`<AmpUnmask>` log a
one-time `console.error` and fall back to `<AmpMaskView>` — content stays
**masked**, but layout-transparency is lost. Treat that log as a build
error to fix, not a warning to ignore.
- On the **New Architecture**, if the package's native code is absent
entirely (so Session Replay cannot record at all), `<AmpMask>`/`<AmpUnmask>`
log a one-time `console.error` and render children directly. If instead the
native module is present but neither masking component is registered (an
unexpected build error), they throw in development and log a distinct
one-time `console.error` in production instead of silently passing content
through. On the **Old Architecture** with the native code absent, rendering
fails at `requireNativeComponent` like any other native component — there
is no silent passthrough.

### Usage

```tsx
import { AmpMask, AmpUnmask } from '@amplitude/session-replay-react-native';

// Mask: children are masked in the replay, layout is unchanged.
<AmpMask>
<View style={{ flex: 1 }}>
<Text>{accountNumber}</Text>
</View>
</AmpMask>

// Block: fully block the subtree from the replay.
<AmpMask maskLevel="block">
<CreditCardForm />
</AmpMask>

// Unmask: opt content back in to the replay.
<AmpUnmask>
<Text>Public banner</Text>
</AmpUnmask>
```

`AmpMask` props:

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | When `false`, children render without masking. |
| `maskLevel` | `'mask' \| 'block'` | `'mask'` | Masking level applied to the children. On iOS, `mask` and `block` currently behave identically (both fully block). |

`AmpUnmask` takes no masking props — it always unmasks its children.

### Migrating from `AmpMaskView`

| Before | After |
| --- | --- |
| `<AmpMaskView mask="amp-mask">` | `<AmpMask>` |
| `<AmpMaskView mask="amp-block">` | `<AmpMask maskLevel="block">` |
| `<AmpMaskView mask="amp-unmask">` | `<AmpUnmask>` |

`AmpMaskView` remains supported on both architectures. Prefer
`AmpMask`/`AmpUnmask` on the New Architecture, especially around children that
are sized by their parent (`flex: 1`, percentage heights, absolute
positioning).

## Tracking Web Views (Beta)

Web views are blocked by default and will not be tracked. If you'd like webviews to be tracked, you can manually unmask
Expand Down
136 changes: 9 additions & 127 deletions packages/session-replay-react-native/android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import groovy.json.JsonSlurper

def reactNativeArchitectures() {
def value = rootProject.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
Expand Down Expand Up @@ -28,103 +26,9 @@ def supportsNamespace() {
def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')
def major = parsed[0].toInteger()
def minor = parsed[1].toInteger()
return (major == 7 && minor >= 3) || major >= 8
}

// --- RN-floor gate: the Fabric SRMaskView codegen/C++ needs RN >= 0.77 under new-arch.
// Defensive: if RN can't be resolved, do NOT fail (don't break the happy path). ---
def resolveReactNativeVersion() {
def candidates = [
file("${rootProject.projectDir}/../node_modules/react-native/package.json"),
file("${rootProject.projectDir}/node_modules/react-native/package.json"),
file("${projectDir}/../../../react-native/package.json"),
]
for (c in candidates) {
if (c.exists()) {
try { return new JsonSlurper().parse(c).version } catch (ignored) {}
}
}
return null
}

if (isNewArchitectureEnabled()) {
def rnVersion = resolveReactNativeVersion()
if (rnVersion != null) {
def p = rnVersion.tokenize('.')
def major = p[0].toInteger()
def minor = p[1].toInteger()
def ge077 = major > 0 || (major == 0 && minor >= 77)
if (!ge077) {
throw new GradleException(
"[SessionReplayReactNative] The Fabric SRMaskView component requires React Native >= 0.77 " +
"with the New Architecture (found ${rnVersion})."
)
}
}
}

def patchComponentDescriptorsHeader() {
def componentsDir = file("$buildDir/generated/source/codegen/jni/react/renderer/components/AmpSessionReplaySpec")
def header = new File(componentsDir, "ComponentDescriptors.h")

if (!header.exists()) {
throw new GradleException(
"[SessionReplayReactNative] Expected codegen ComponentDescriptors.h at ${header.absolutePath}. " +
"Ensure newArchEnabled=true and generateCodegenArtifactsFromSchema ran successfully."
)
}

def original = header.getText("UTF-8")
if (original.contains("#include <SRMaskViewComponentDescriptor.h>")) {
return
}

def typedefPattern = /using SRMaskViewComponentDescriptor = ConcreteComponentDescriptor<SRMaskViewShadowNode>;/
if (!(original =~ typedefPattern)) {
throw new GradleException(
"[SessionReplayReactNative] patchComponentDescriptorsHeader regex did not match ComponentDescriptors.h. " +
"React Native codegen output may have changed; update the patch task before shipping."
)
}

def patched = original.replace(
"#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>",
"""#include <react/renderer/componentregistry/ComponentDescriptorProviderRegistry.h>
#include <SRMaskViewComponentDescriptor.h>"""
).replaceFirst(typedefPattern, "")
header.write(patched, "UTF-8")
}

def patchCodegenCMakeLists() {
def cmakeFile = file("$buildDir/generated/source/codegen/jni/CMakeLists.txt")

if (!cmakeFile.exists()) {
throw new GradleException(
"[SessionReplayReactNative] Expected codegen CMakeLists.txt at ${cmakeFile.absolutePath}. " +
"Ensure newArchEnabled=true and generateCodegenArtifactsFromSchema ran successfully."
)
}

def original = cmakeFile.getText("UTF-8")
if (original.contains("SRMaskViewShadowNode.cpp")) {
return
}

def cppDir = file("../cpp").absolutePath.replace("\\", "/")
def insertion = """target_sources(react_codegen_AmpSessionReplaySpec PRIVATE
"${cppDir}/SRMaskViewShadowNode.cpp"
)
target_include_directories(react_codegen_AmpSessionReplaySpec PUBLIC "${cppDir}")
"""

if (!original.contains("target_link_libraries(")) {
throw new GradleException(
"[SessionReplayReactNative] patchCodegenCMakeLists could not find target_link_libraries in CMakeLists.txt."
)
}

def patched = original.replace("target_link_libraries(", insertion + "target_link_libraries(")
cmakeFile.write(patched, "UTF-8")
// Namespace support was added in 7.3.0
return (major == 7 && minor >= 3) || major >= 8
}

android {
Expand All @@ -138,8 +42,8 @@ android {
}
}

// New Arch compiles src/newarch (codegen spec + BaseReactPackage + Fabric SRMaskView);
// Old Arch compiles src/oldarch (hand-written spec + plain ReactPackage).
// New Arch compiles src/newarch (codegen spec + BaseReactPackage); Old Arch
// compiles src/oldarch (hand-written spec + plain ReactPackage).
sourceSets {
main {
if (isNewArchitectureEnabled()) {
Expand All @@ -155,12 +59,7 @@ android {
defaultConfig {
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildFeatures {
buildConfig true
}

buildTypes {
Expand All @@ -169,10 +68,6 @@ android {
}
}

packagingOptions {
pickFirst "lib/**/*.so"
}

lintOptions {
disable "GradleCompatible"
}
Expand All @@ -193,33 +88,20 @@ def kotlin_version = getExtOrDefault("kotlinVersion")
dependencies {
implementation("com.amplitude:session-replay-android:[0.24.0,0.25.0)")
implementation("com.amplitude:analytics-android:[1.25.0,1.26.0)")

// For < 0.71, this will be from the local maven repo
// For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin
//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"

androidTestImplementation "androidx.test.ext:junit:1.2.1"
androidTestImplementation "androidx.test:runner:1.6.2"
}

// Codegen for the TurboModule spec (New Architecture only). Generates
// NativeAmpSessionReplaySpec from src/specs/NativeAmpSessionReplay.ts.
if (isNewArchitectureEnabled()) {
react {
jsRootDir = file("../src/specs/")
libraryName = "AmpSessionReplaySpec"
codegenJavaPackageName = "com.amplitude.sessionreplayreactnative"
}

afterEvaluate {
tasks.named("generateCodegenArtifactsFromSchema").configure {
doLast {
patchComponentDescriptorsHeader()
patchCodegenCMakeLists()
}
}

tasks.configureEach { task ->
if (task.name.contains("configureCMake")) {
task.dependsOn("generateCodegenArtifactsFromSchema")
}
}
}
}
Loading
Loading