Skip to content

feat(session-replay-react-native): Fabric SRMaskView foundation (SDKRN-32)#1865

Open
chungdaniel wants to merge 14 commits into
mainfrom
danielchung/sdkrn-32-fabric-foundation
Open

feat(session-replay-react-native): Fabric SRMaskView foundation (SDKRN-32)#1865
chungdaniel wants to merge 14 commits into
mainfrom
danielchung/sdkrn-32-fabric-foundation

Conversation

@chungdaniel

@chungdaniel chungdaniel commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Part 1 of 2 (SDKRN-32) — the internal, inert foundation for a Fabric-only, layout-transparent masking component for @amplitude/session-replay-react-native. This PR adds all the plumbing but no public API and no behavior change; the public <AmpMask>/<AmpUnmask> API and the default masking primitive land in Part 2 (SDKRN-33).

The component is a custom Fabric SRMaskView whose C++ ComponentDescriptor::adopt() forces Yoga display: contents (zero layout box, no Yoga node) while keeping a real host view, fronted by a recorder-agnostic SRMaskingPrimitive / SRMaskingRegistry seam.

What's included

  • JS codegen specsrc/specs/SRMaskViewNativeComponent.ts (enabled / unmask / maskLevel props, default maskLevel: 'mask').
  • codegenConfig → type: "all" (name: "SRMaskViewSpec"), RN devDep floor bumped to 0.77.2, lint globs widened to .tsx.
  • C++ ShadowNodecpp/SRMaskViewShadowNode.{h,cpp} + cpp/SRMaskViewComponentDescriptor.h: adopt() re-applies display:contents after every create/clone so prop-driven Yoga resets can't override it.
  • Masking seam (both platforms)SRMaskingPrimitive + SRMaskingRegistry with reset semantics and a weak view→intent map that replays on setPrimitive (so views masked before a primitive registers are still applied — no JS re-render).
  • iOS Fabric host viewios/fabric/SRMaskView.{h,mm} (per-child masking; reset on unmount + prepareForRecycle) + podspec wiring with an RN-floor gate (Fabric/C++ sources only on new-arch + RN ≥ 0.77).
  • Android Fabric host viewandroid/src/newarch/.../fabric/SRMaskView.kt (per-child masking + the O3 onLayout capture-bounds fix: widens the host's native frame to the union of its children so the 0×0 display:contents host isn't dropped by the recorder, without moving children), a codegen-delegate SRMaskViewManager, package registration, and build.gradle codegen patches + RN-floor gate. The existing src/{newarch,oldarch} split is preserved so old-arch keeps compiling.
  • Native canariesandroid/src/androidTest/.../SRMaskViewTest.kt (6 tests) + example/ios/exampleTests/SRMaskViewTests.mm (6 tests) locking: per-child mask, reset on removal/recycle (R5), the Android O3 host-bounds union, and intent reapply (R8).
  • Docs — README note on the Fabric requirements (new-arch + RN ≥ 0.77, internal/inert, RN-floor behavior).

Verification

  • ✅ New-arch example builds and runs on both platforms — iOS xcodebuild** BUILD SUCCEEDED **; Android assembleDebugBUILD SUCCESSFUL, app installs + launches on the emulator (native libs incl. react_codegen_SRMaskViewSpec + cpp load clean, no crash).
  • ✅ Native canaries pass on-device — 6 Android (connectedDebugAndroidTest, Pixel 8 / API 35) + 6 iOS (xcodebuild test** TEST SUCCEEDED **).
  • ✅ Existing Paper Jest tests pass unchanged (50/50).
  • No public API changeSRMaskView is internal (not exported); no masking primitive is registered, so the seam is inert.
  • ✅ Old-arch still compiles (compileDebugKotlin -PnewArchEnabled=false).

Notes

  • Layout zero-shift is guaranteed by the display:contents mechanism (C++ identical to the validated Phase-0 spike) and locked by the Android O3 canary (host widens to the children's union without repositioning children). A literal visual repro requires the Part 2 <AmpMask> public API to wrap content.
  • The CHANGELOG is lerna/conventional-commits auto-generated, so it's intentionally not hand-edited — the feat/build/test/docs commits here populate it at release time.

🤖 Generated with Claude Code


Note

Medium Risk
Large native/build surface (codegen rename, Gradle pod patches, custom C++ descriptor) affects New Architecture consumers on RN ≥ 0.77, but legacy arch is unchanged and masking stays inert without a registered primitive.

Overview
Adds internal, inert Fabric groundwork for a future layout-transparent masking host (SRMaskView). Nothing is exported from the JS package yet and no masking primitive is registered, so runtime masking behavior is unchanged until a follow-up wires the public API.

Codegen & build: codegenConfig moves from module-only AmpSessionReplaySpec to type: "all" / SRMaskViewSpec, emitting both the existing TurboModule and the new Fabric component. iOS/Android RN ≥ 0.77 + New Architecture gates exclude Fabric/C++ sources on older setups (fail-fast when new-arch is on but RN is too old). Android post-codegen patches hook in the custom C++ ComponentDescriptor and shadow node; the podspec conditionally compiles cpp/** and ios/fabric/**.

Native stack: C++ adopt() forces Yoga display: contents on every clone. SRMaskingPrimitive / SRMaskingRegistry record per-view mask/unmask/reset intent and replay when a primitive registers later. Fabric hosts on iOS and Android mask each direct child, reset on unmount/recycle/drop, and on Android widen a degenerate 0×0 host frame to children’s bounds so session replay capture still traverses the subtree. SRMaskViewManager is registered only on new-arch Android.

Tests & docs: Instrumented Android and XCTest iOS canaries lock registry replay, per-child mask, reset, and capture-bounds behavior; a small Jest test asserts the codegen component name. README documents the internal Fabric floor.

Reviewed by Cursor Bugbot for commit b1a8115. Bugbot is set up for automated code reviews on this repo. Configure here.

@linear-code

linear-code Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

SDKRN-32

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: iOS fabric gate rejects unknown RN
    • Adjusted the iOS podspec Fabric gate to include Fabric sources when New Architecture is enabled and the React Native version cannot be resolved, while still rejecting known unsupported versions.

Create PR

Or push these changes by commenting:

@cursor push 2bd0ef4535
Preview (2bd0ef4535)
diff --git a/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec b/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec
--- a/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec
+++ b/packages/session-replay-react-native/AmplitudeSessionReplayReactNative.podspec
@@ -19,7 +19,7 @@
   minor = parts[1].to_i
   major > 0 || (major == 0 && minor >= 77)
 end
-fabric_enabled = new_arch_enabled && srmaskview_version_ge_077.call(rn_version)
+fabric_enabled = new_arch_enabled && (rn_version.nil? || srmaskview_version_ge_077.call(rn_version))
 if new_arch_enabled && !rn_version.nil? && !srmaskview_version_ge_077.call(rn_version)
   raise "[AmplitudeSessionReplayReactNative] The Fabric SRMaskView component requires React Native >= 0.77 with the New Architecture (found #{rn_version})."
 end

You can send follow-ups to the cloud agent here.

@github-actions

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 60.73 KB (0%)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 134.34 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 214.61 KB (0%)
@amplitude/element-selector (gzipped esm) 2.67 KB (0%)

@chungdaniel

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Host bounds union mixes coordinates
    • Updated SRMaskView bounds expansion to preserve the host's parent-space origin while unioning child extents in host-local coordinates.

Create PR

Or push these changes by commenting:

@cursor push 22dad913df
Preview (22dad913df)
diff --git a/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt b/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt
--- a/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt
+++ b/packages/session-replay-react-native/android/src/androidTest/java/com/amplitude/sessionreplayreactnative/SRMaskViewTest.kt
@@ -180,9 +180,9 @@
       // Give children non-zero, disjoint frames.
       a.layout(0, 0, 100, 50)
       b.layout(120, 60, 200, 140)
-      // Simulate Fabric's display:contents 0x0 host frame. View.layout is final
-      // and invokes the overridden onLayout -> expandBoundsToChildrenUnion.
-      host.layout(0, 0, 0, 0)
+      // Simulate Fabric's display:contents 0x0 host frame at a non-zero parent
+      // offset. View.layout is final and invokes onLayout -> expand.
+      host.layout(50, 60, 50, 60)
     }
 
     assertTrue(
@@ -194,6 +194,8 @@
       host.height > 0,
     )
     // Union of (0,0,100,50) and (120,60,200,140) is (0,0,200,140).
+    assertEquals("host left should preserve parent offset", 50, host.left)
+    assertEquals("host top should preserve parent offset", 60, host.top)
     assertEquals("union width", 200, host.width)
     assertEquals("union height", 140, host.height)
   }

diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
--- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
+++ b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
@@ -77,28 +77,27 @@
     expandBoundsToChildrenUnion()
   }
 
-  // Widen this host's native frame to the union of its own frame and all
-  // children's frames so that width>0 && height>0 (shouldCapture passes).
+  // Widen this host's native frame from its parent-space origin to the union of
+  // its own local frame and all children's local frames so that width>0 &&
+  // height>0 (shouldCapture passes).
   private fun expandBoundsToChildrenUnion() {
     if (expanding) return
     if (childCount == 0) return
 
-    var minLeft = left
-    var minTop = top
-    var maxRight = right
-    var maxBottom = bottom
+    var maxRight = width
+    var maxBottom = height
     for (i in 0 until childCount) {
       val c = getChildAt(i) ?: continue
-      if (c.left < minLeft) minLeft = c.left
-      if (c.top < minTop) minTop = c.top
       if (c.right > maxRight) maxRight = c.right
       if (c.bottom > maxBottom) maxBottom = c.bottom
     }
 
-    if (minLeft != left || minTop != top || maxRight != right || maxBottom != bottom) {
+    val expandedRight = left + maxRight
+    val expandedBottom = top + maxBottom
+    if (expandedRight != right || expandedBottom != bottom) {
       expanding = true
       try {
-        setLeftTopRightBottom(minLeft, minTop, maxRight, maxBottom)
+        setLeftTopRightBottom(left, top, expandedRight, expandedBottom)
       } finally {
         expanding = false
       }

You can send follow-ups to the cloud agent here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Drop host leaves child listeners
    • Added drop-time child cleanup that removes the host layout listener from each child before resetting masking.

Create PR

Or push these changes by commenting:

@cursor push beb61fe258
Preview (beb61fe258)
diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
--- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
+++ b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
@@ -68,6 +68,14 @@
     expandBoundsToChildrenUnion()
   }
 
+  fun resetChildrenOnDrop() {
+    for (i in 0 until childCount) {
+      val child = getChildAt(i) ?: continue
+      child.removeOnLayoutChangeListener(childLayoutChangeListener)
+      SRMaskingRegistry.reset(child)
+    }
+  }
+
   // Fabric drives layout by calling the (final) View.layout(l,t,r,b) from
   // SurfaceMountingManager.updateLayout() (RN 0.77.2). For a display:contents
   // host that frame is degenerate (r==l, b==t -> 0x0). onLayout runs right

diff --git a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt
--- a/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt
+++ b/packages/session-replay-react-native/android/src/newarch/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskViewManager.kt
@@ -1,6 +1,5 @@
 package com.amplitude.sessionreplayreactnative.fabric
 
-import com.amplitude.sessionreplayreactnative.SRMaskingRegistry
 import com.facebook.react.module.annotations.ReactModule
 import com.facebook.react.uimanager.ThemedReactContext
 import com.facebook.react.uimanager.ViewGroupManager
@@ -37,9 +36,7 @@
   override fun onDropViewInstance(view: SRMaskView) {
     super.onDropViewInstance(view)
     // R5: reset all children when the host view is dropped.
-    for (i in 0 until view.childCount) {
-      SRMaskingRegistry.reset(view.getChildAt(i))
-    }
+    view.resetChildrenOnDrop()
   }
 
   companion object {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 65c1e5e. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant