Skip to content

feat(session-replay-react-native): public AmpMask/AmpUnmask API (SDKRN-33)#1866

Merged
aliaksandr-kazarez merged 42 commits into
mainfrom
danielchung/sdkrn-33-ampmask
Jul 9, 2026
Merged

feat(session-replay-react-native): public AmpMask/AmpUnmask API (SDKRN-33)#1866
aliaksandr-kazarez merged 42 commits into
mainfrom
danielchung/sdkrn-33-ampmask

Conversation

@chungdaniel

@chungdaniel chungdaniel commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Part 2 of 2 (SDKRN-33) — the public @experimental <AmpMask> / <AmpUnmask> API on top of the Part-1 Fabric SRMaskView foundation.

Was stacked on #1865 (now merged). This PR is retargeted to main with main merged back in after the squash — the diff shows only the Part-2 changes (23 files, all in packages/session-replay-react-native).

Fixes the New-Architecture layout collapse reported in #1271: wrapping a flex:1 child in <AmpMaskView> collapses it to height 0. <AmpMask> renders through the layout-transparent Fabric SRMaskView (Yoga display: contents, zero layout box), so wrapped content measures identically to unwrapped content.

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

<AmpMask maskLevel="mask">
  <View style={{ flex: 1 }}></View>   {/* keeps its layout */}
</AmpMask>

What's included

  • Public API<AmpMask enabled? maskLevel?: 'mask' | 'block'>, <AmpUnmask>, exported @experimental with real .d.ts typings; types extend Omit<ViewProps, 'style'> (the mask host must never carry a layout box, so style is unsupported by design).
  • Arch switch (src/index.tsx) — RN$Bridgeless === true || nativeFabricUIManager != null selects the Fabric impl; lazy require()s keep the codegen spec unloaded on the Old Architecture. All pre-existing exports unchanged (additive only).
  • Fabric impl (src/fabric/MaskFabric.tsx) — renders the internal SRMaskView behind a layered availability probe that fails closed: Fabric view registered → layout-transparent path; else Paper AMPMaskComponentView registered → one-time console.error + fall back to <AmpMaskView> (content stays masked, layout transparency lost); else passthrough only when the Session Replay native module itself is absent (recorder can't run). The shared fail-closed fallback logic lives in one factory (src/mask-fallback.tsx).
  • Old-Architecture guard (src/paper/MaskPaper.tsx) — dev builds throw with a clear message; production falls back to <AmpMaskView> (privacy-safe, layout-unsafe) with a one-time console.error.
  • Default masking primitive registered at SDK init — Android bridges SessionReplay.mask/block/unmask; iOS sets amp_isBlocked. setup() sequences register → start → resolve in a single main-thread block, so the first capture frame structurally cannot observe views before replayed intents apply; invalidate() is serialized with deferred setup.
  • Example + docs — Mask screen (side-by-side measured frames) and a README section with requirements, props, Old-Arch caveats, and an <AmpMaskView> migration table.

Post-review e2e verification & fixes (2026-07-07/08)

A full device e2e campaign (both platforms, replay-content-level verification via the native SDK debug servers, uploads confirmed in Amplitude) surfaced and fixed three defects that earlier verification missed because it never interacted with masked UI, plus one accessibility defect:

  • 8acadd63 — RN 0.77–0.82 display:contents crash defused. Debug builds SIGABRT'd (Sealable ensureUnsealed) whenever a masked element coexisted with rapid state updates. Root cause is an upstream RN Yoga ownership bug (Fix node ownership when display: contents is used (#56422) react/react-native#56422, fixed in 0.83+/0.86 — proven with a zero-custom-code repro on stock RN). The ShadowNode now follows the Expensify live-markdown / Expo disableForceFlatten pattern (ctor-time initialize(), no adopt() override, no C++ Yoga style writes) and eagerly re-owns its Yoga children at construction, neutralizing the upstream bug for everything inside <AmpMask>. Apps using display:contents in their own styles on RN 0.77–0.82 remain exposed to the upstream bug (with or without this SDK).
  • 842de038 — widened host no longer swallows touches. The capture-gate frame widening overlaps sibling views; under default pointerEvents the host consumed taps/focus (masked buttons dead; a Switch underneath a host frame dead). The host is now hardcoded BOX_NONE: children stay tappable, misses fall through.
  • 58fa647d — host-relative widening. On-device measurement across three shapes (top-level, nested, list rows at scroll offsets) established that children are always host-relative while the host origin accumulates flattened-view offsets; the union math now widens origin + max child extent with a 1px clamp. Before this, nested/offset masks produced inverted frames and the recorder dropped their subtrees (privacy-safe over-drop, but nested-unmasked content vanished from replays).
  • 9d270040 — iOS accessibility fix. UIKit prunes zero-sized views with their descendants from the accessibility tree, so everything inside <AmpMask>/<AmpUnmask> was invisible to VoiceOver/XCUITest. a11y-only remedies (accessibilityFrame, accessibilityElements) are empirically ineffective (AX culls by real frame first); the fix mirrors Android's frame widening (origin pinned, paint-neutral — verified no replay-rendering change) with the existing hitTest nil-for-self keeping the host touch-transparent.

Verification: layout-neutrality matrix (measured frames identical to bare views; legacy <AmpMaskView> collapses as expected) · tap-through-mask, enabled-toggle, mask-before-init registry replay, FlatList recycling, negative offsets, nested masks — all green on Pixel 9 / API 35 + iPhone 16 Pro, RN 0.77.2 New Arch · replay-content checks: masked markers asterisked/absent, unmasked text visible, zero leaks · crash-stress: zero asserts across long assert-active soaks on both platforms · pnpm test 98/98 · Android instrumented 16/16 (widening shapes incl. nested/offset/negative + BOX_NONE) · iOS SRMaskViewTests incl. frame-widening + hitTest-transparency.

Platform behavior notes (docs: SDKRN-38)

  • Nested <AmpUnmask> inside <AmpMask> re-reveals content on Android but stays masked on iOS — the iOS SDK resolves max(parentBlockStatus, own), so blocked beats nested unblocked (fails toward privacy). Documented for alignment in the native iOS SDK; until then, docs state nested unmask is unsupported on iOS.
  • Replay representation: Android draws masked text as length-preserving asterisks (blocked = no bitmap); iOS ships no image/text at all for masked views. Masked content is absent from the payload, not hidden client-side.

Deferred native follow-ups (intentionally not in this PR)

  • Android view.tag collision (testIDamp-mask) — pre-exists with AmpMaskView; planned preserve-and-restore in SRDefaultMaskingPrimitive + SDK ticket for keyed tag storage.
  • iOS resetView approximation (amp_isBlocked = false for "inherit") — tri-state reset is a documented fast-follow (SDK ticket).
  • E2E automation of the campaign scenarios — SDKRN-39.

Checklist

  • Does your PR title have the correct title format?
  • Does your PR have a breaking change?: No — additive, @experimental exports only.

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

SDKRN-33

@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: Recording before masking primitive
    • Registered the masking primitive on the UI/main thread before constructing or auto-starting SessionReplay and delayed setup resolution until registration completes.

Create PR

Or push these changes by commenting:

@cursor push 1acde737d7
Preview (1acde737d7)
diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt
--- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt
+++ b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt
@@ -66,32 +66,37 @@
           Opt Out: $optOut
       """.trimIndent())
 
-      sessionReplay = SessionReplay(
-        apiKey = apiKey,
-        context = reactContext.applicationContext,
-        deviceId = deviceId ?: "",
-        sessionId = sessionId,
-        optOut = optOut,
-        sampleRate = sampleRate,
-        logger = LogcatLogger.logger,
-        enableRemoteConfig = enableRemoteConfig,
-        serverZone = when (serverZone) {
-          "EU" -> ServerZone.EU
-          else -> ServerZone.US
-        },
-        autoStart = autoStart,
-        privacyConfig = PrivacyConfig(maskLevel = maskLevel),
-      )
-
       // Register the default masking primitive so `SRMaskView` masking reaches
-      // the recorder. Must run on the UI thread: setPrimitive replays recorded
-      // intents, which touch Views. Re-registering on repeated setup() calls is
-      // harmless (it just replays intents again).
+      // the recorder before auto-start can capture frames. Must run on the UI
+      // thread: setPrimitive replays recorded intents, which touch Views.
+      // Re-registering on repeated setup() calls is harmless (it just replays
+      // intents again).
       UiThreadUtil.runOnUiThread {
-        SRMaskingRegistry.setPrimitive(SRDefaultMaskingPrimitive())
+        try {
+          SRMaskingRegistry.setPrimitive(SRDefaultMaskingPrimitive())
+
+          sessionReplay = SessionReplay(
+            apiKey = apiKey,
+            context = reactContext.applicationContext,
+            deviceId = deviceId ?: "",
+            sessionId = sessionId,
+            optOut = optOut,
+            sampleRate = sampleRate,
+            logger = LogcatLogger.logger,
+            enableRemoteConfig = enableRemoteConfig,
+            serverZone = when (serverZone) {
+              "EU" -> ServerZone.EU
+              else -> ServerZone.US
+            },
+            autoStart = autoStart,
+            privacyConfig = PrivacyConfig(maskLevel = maskLevel),
+          )
+
+          promise.resolve(null)
+        } catch (e: Exception) {
+          promise.reject("SETUP_ERROR", e.message, e)
+        }
       }
-
-      promise.resolve(null)
     } catch (e: Exception) {
       promise.reject("SETUP_ERROR", e.message, e)
     }

diff --git a/packages/session-replay-react-native/ios/NativeSessionReplay.swift b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
--- a/packages/session-replay-react-native/ios/NativeSessionReplay.swift
+++ b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
@@ -50,32 +50,32 @@
             """
         )
         
-        sessionReplay = SessionReplay(
-            apiKey: apiKey,
-            deviceId: deviceId,
-            sessionId: sessionId.int64Value,
-            optOut: optOut,
-            sampleRate: Float(truncating: sampleRate),
-            logger: logger,
-            serverZone: serverZone == "EU" ? .EU : .US,
-            maskLevel: .fromString(maskLevel),
-            enableRemoteConfig: enableRemoteConfig
-        )
-        
-        if (autoStart) {
-            sessionReplay.start()
-        }
-
         // Register the default masking primitive so `SRMaskView` masking reaches
-        // the recorder. Must run on the main thread: setPrimitive replays
-        // recorded intents, which touch UIViews (setup runs on the RN
-        // native-modules queue). Re-registering on repeated setup() calls is
-        // harmless (it just replays intents again).
+        // the recorder before auto-start can capture frames. Must run on the
+        // main thread: setPrimitive replays recorded intents, which touch
+        // UIViews (setup runs on the RN native-modules queue). Re-registering
+        // on repeated setup() calls is harmless (it just replays intents again).
         DispatchQueue.main.async {
             SRMaskingRegistry.primitive = SRDefaultMaskingPrimitive()
+
+            self.sessionReplay = SessionReplay(
+                apiKey: apiKey,
+                deviceId: deviceId,
+                sessionId: sessionId.int64Value,
+                optOut: optOut,
+                sampleRate: Float(truncating: sampleRate),
+                logger: self.logger,
+                serverZone: serverZone == "EU" ? .EU : .US,
+                maskLevel: .fromString(maskLevel),
+                enableRemoteConfig: enableRemoteConfig
+            )
+
+            if (autoStart) {
+                self.sessionReplay.start()
+            }
+
+            resolve(nil)
         }
-
-        resolve(nil)
     }
     
     @objc(setSessionId:resolve:reject:)

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

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 60.91 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.79 KB (0%)
@amplitude/element-selector (gzipped esm) 2.67 KB (0%)

@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).

Autofix Details

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

  • ✅ Fixed: Mask primitive registers after capture starts
    • Registered and replayed the default masking primitive before auto-starting capture and before resolving setup on both iOS and Android.

Create PR

Or push these changes by commenting:

@cursor push 29c54ed072
Preview (29c54ed072)
diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt
--- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt
+++ b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt
@@ -79,19 +79,25 @@
           "EU" -> ServerZone.EU
           else -> ServerZone.US
         },
-        autoStart = autoStart,
+        autoStart = false,
         privacyConfig = PrivacyConfig(maskLevel = maskLevel),
       )
 
       // Register the default masking primitive so `SRMaskView` masking reaches
-      // the recorder. Must run on the UI thread: setPrimitive replays recorded
-      // intents, which touch Views. Re-registering on repeated setup() calls is
-      // harmless (it just replays intents again).
+      // the recorder before recording starts. Must run on the UI thread:
+      // setPrimitive replays recorded intents, which touch Views. Re-registering
+      // on repeated setup() calls is harmless (it just replays intents again).
       UiThreadUtil.runOnUiThread {
-        SRMaskingRegistry.setPrimitive(SRDefaultMaskingPrimitive())
+        try {
+          SRMaskingRegistry.setPrimitive(SRDefaultMaskingPrimitive())
+          if (autoStart) {
+            sessionReplay.start()
+          }
+          promise.resolve(null)
+        } catch (e: Exception) {
+          promise.reject("SETUP_ERROR", e.message, e)
+        }
       }
-
-      promise.resolve(null)
     } catch (e: Exception) {
       promise.reject("SETUP_ERROR", e.message, e)
     }

diff --git a/packages/session-replay-react-native/ios/NativeSessionReplay.swift b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
--- a/packages/session-replay-react-native/ios/NativeSessionReplay.swift
+++ b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
@@ -62,19 +62,23 @@
             enableRemoteConfig: enableRemoteConfig
         )
         
+        // Register the default masking primitive so `SRMaskView` masking reaches
+        // the recorder before recording starts. Must run on the main thread:
+        // setPrimitive replays recorded intents, which touch UIViews (setup runs
+        // on the RN native-modules queue). Re-registering on repeated setup()
+        // calls is harmless (it just replays intents again).
+        if Thread.isMainThread {
+            SRMaskingRegistry.primitive = SRDefaultMaskingPrimitive()
+        } else {
+            DispatchQueue.main.sync {
+                SRMaskingRegistry.primitive = SRDefaultMaskingPrimitive()
+            }
+        }
+
         if (autoStart) {
             sessionReplay.start()
         }
 
-        // Register the default masking primitive so `SRMaskView` masking reaches
-        // the recorder. Must run on the main thread: setPrimitive replays
-        // recorded intents, which touch UIViews (setup runs on the RN
-        // native-modules queue). Re-registering on repeated setup() calls is
-        // harmless (it just replays intents again).
-        DispatchQueue.main.async {
-            SRMaskingRegistry.primitive = SRDefaultMaskingPrimitive()
-        }
-
         resolve(nil)
     }

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

Comment thread packages/session-replay-react-native/ios/NativeSessionReplay.swift Outdated

@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 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: iOS setup errors hang promise
    • Deferred iOS setup now rejects with SETUP_ERROR when the queued setup has been invalidated instead of leaving init unresolved.
  • ✅ Fixed: Deferred setup races invalidate
    • The queued setup block captures the created recorder and verifies it is still current before registering masks or starting capture.

Create PR

Or push these changes by commenting:

@cursor push 6349d41bfe
Preview (6349d41bfe)
diff --git a/packages/session-replay-react-native/ios/NativeSessionReplay.swift b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
--- a/packages/session-replay-react-native/ios/NativeSessionReplay.swift
+++ b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
@@ -16,7 +16,7 @@
     }
     
     @objc(setup:resolve:reject:)
-    func setup(_ config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void {
+    func setup(_ config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
         guard let apiKey = config["apiKey"] as? String,
               let sessionId = config["sessionId"] as? NSNumber,
               let serverZone = config["serverZone"] as? String,
@@ -50,7 +50,7 @@
             """
         )
         
-        sessionReplay = SessionReplay(
+        let sessionReplay = SessionReplay(
             apiKey: apiKey,
             deviceId: deviceId,
             sessionId: sessionId.int64Value,
@@ -61,6 +61,7 @@
             maskLevel: .fromString(maskLevel),
             enableRemoteConfig: enableRemoteConfig
         )
+        self.sessionReplay = sessionReplay
         
         // Register the default masking primitive, then start capture, in ONE
         // main-thread block. The registry is main-thread-only (setting the
@@ -71,10 +72,15 @@
         // intents are applied. Resolving inside the block keeps the init
         // promise from settling before registration lands. Re-registering on
         // repeated setup() calls is harmless (it just replays intents again).
-        DispatchQueue.main.async {
+        DispatchQueue.main.async { [weak self] in
+            guard let currentSessionReplay = self?.sessionReplay,
+                  currentSessionReplay === sessionReplay else {
+                reject("SETUP_ERROR", "Session replay setup was invalidated", nil)
+                return
+            }
             SRMaskingRegistry.primitive = SRDefaultMaskingPrimitive()
             if (autoStart) {
-                self.sessionReplay.start()
+                currentSessionReplay.start()
             }
             resolve(nil)
         }

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

Comment thread packages/session-replay-react-native/ios/NativeSessionReplay.swift
Comment thread packages/session-replay-react-native/ios/NativeSessionReplay.swift

@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).

Autofix Details

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

  • ✅ Fixed: Tier-three recorder probe mismatch
    • MaskFabric now bases tier-three recorder presence on the TurboModuleRegistry-backed native spec and adds regression coverage for Turbo-only exposure.

Create PR

Or push these changes by commenting:

@cursor push 72753a89da
Preview (72753a89da)
diff --git a/packages/session-replay-react-native/src/fabric/MaskFabric.tsx b/packages/session-replay-react-native/src/fabric/MaskFabric.tsx
--- a/packages/session-replay-react-native/src/fabric/MaskFabric.tsx
+++ b/packages/session-replay-react-native/src/fabric/MaskFabric.tsx
@@ -1,6 +1,7 @@
 import React from 'react';
-import { NativeModules, UIManager } from 'react-native';
+import { UIManager } from 'react-native';
 import SRMaskViewNative from '../specs/SRMaskViewNativeComponent';
+import SessionReplaySpec from '../specs/NativeAmpSessionReplay';
 import { AmpMaskView } from '../amp-mask-view';
 import { ampMaskViewMaskProp } from '../Mask.types';
 import type { MaskProps, UnmaskProps } from '../Mask.types';
@@ -28,7 +29,7 @@
 // Whether the Session Replay native module itself is linked. Used to decide
 // tier-3 behavior below — we verify the recorder's absence rather than
 // inferring it from the missing view managers alone.
-const RECORDER_PRESENT = NativeModules?.AMPNativeSessionReplay != null;
+const RECORDER_PRESENT = SessionReplaySpec != null;
 
 let warned = false;
 function warnOnceUnmasked() {

diff --git a/packages/session-replay-react-native/test/fabric/MaskFabric.test.tsx b/packages/session-replay-react-native/test/fabric/MaskFabric.test.tsx
--- a/packages/session-replay-react-native/test/fabric/MaskFabric.test.tsx
+++ b/packages/session-replay-react-native/test/fabric/MaskFabric.test.tsx
@@ -12,7 +12,7 @@
 
 import React from 'react';
 import type { ReactElement, ReactNode } from 'react';
-import { NativeModules, UIManager } from 'react-native';
+import { NativeModules, TurboModuleRegistry, UIManager } from 'react-native';
 import type { MaskProps, UnmaskProps } from '../../src/Mask.types';
 
 // Shape of the props MaskFabric forwards onto the native SRMaskView element.
@@ -74,18 +74,21 @@
 
 const mockHasViewManagerConfig = UIManager.hasViewManagerConfig as jest.Mock;
 
-// The mock's NativeModules export is a plain object (see
-// test/__mocks__/react-native.ts) with AMPNativeSessionReplay present by
-// default. Tier-3 tests need to toggle its presence to distinguish
-// "recorder truly absent" from "recorder present but unreachable" — mutate
-// and always restore so other suites still see the module.
+// The mock's NativeModules and TurboModuleRegistry exports (see
+// test/__mocks__/react-native.ts) expose AMPNativeSessionReplay by default.
+// Tier-3 tests need to toggle its presence to distinguish "recorder truly
+// absent" from "recorder present but unreachable" — mutate and always restore
+// so other suites still see the module.
 const nativeModules = NativeModules as { AMPNativeSessionReplay?: unknown };
 const originalAmpNativeSessionReplay = nativeModules.AMPNativeSessionReplay;
+const mockTurboModuleRegistryGet = TurboModuleRegistry.get as jest.Mock;
 function removeRecorder(): void {
   delete nativeModules.AMPNativeSessionReplay;
+  mockTurboModuleRegistryGet.mockReturnValue(null);
 }
 function restoreRecorder(): void {
   nativeModules.AMPNativeSessionReplay = originalAmpNativeSessionReplay;
+  mockTurboModuleRegistryGet.mockReturnValue(originalAmpNativeSessionReplay);
 }
 
 describe('MaskFabric', () => {
@@ -309,6 +312,10 @@
       restoreRecorder();
     });
 
+    afterEach(() => {
+      restoreRecorder();
+    });
+
     describe('in development (__DEV__ = true)', () => {
       it('AmpMask throws a clear build-error message instead of silently passing content through', () => {
         const { AmpMask } = loadMaskFabricModule<PassthroughMaskFabricModule>();
@@ -321,6 +328,14 @@
           /neither SRMaskView nor AMPMaskComponentView is registered/,
         );
       });
+
+      it('treats the recorder as present when TurboModuleRegistry exposes it but NativeModules does not', () => {
+        delete nativeModules.AMPNativeSessionReplay;
+
+        const { AmpMask } = loadMaskFabricModule<PassthroughMaskFabricModule>();
+
+        expect(() => AmpMask({ children: null })).toThrow(/neither SRMaskView nor AMPMaskComponentView is registered/);
+      });
     });
 
     describe('in production (__DEV__ = false)', () => {

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

Comment thread packages/session-replay-react-native/src/fabric/MaskFabric.tsx Outdated

@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).

Autofix Details

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

  • ✅ Fixed: iOS lacks invalidation gate
    • Added an iOS invalidation flag checked before and during deferred setup so setup rejects after module invalidation instead of starting capture.

Create PR

Or push these changes by commenting:

@cursor push 29a68ba748
Preview (29a68ba748)
diff --git a/packages/session-replay-react-native/ios/NativeSessionReplay.swift b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
--- a/packages/session-replay-react-native/ios/NativeSessionReplay.swift
+++ b/packages/session-replay-react-native/ios/NativeSessionReplay.swift
@@ -10,6 +10,7 @@
     
     var sessionReplay: SessionReplay!
     var logger: CoreLogger!
+    private var invalidated = false
     
     override init() {
         print("NativeSessionReplay init")
@@ -17,6 +18,11 @@
     
     @objc(setup:resolve:reject:)
     func setup(_ config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) -> Void {
+        guard !invalidated else {
+            reject("SETUP_ERROR", "Session Replay module was invalidated before setup completed", nil)
+            return
+        }
+        
         guard let apiKey = config["apiKey"] as? String,
               let sessionId = config["sessionId"] as? NSNumber,
               let serverZone = config["serverZone"] as? String,
@@ -74,7 +80,7 @@
         DispatchQueue.main.async {
             // Module invalidated (e.g. React Native reload) before deferred setup ran —
             // nothing to start; settle the promise so JS init() never hangs.
-            guard let sessionReplay = self.sessionReplay else {
+            guard !self.invalidated, let sessionReplay = self.sessionReplay else {
                 reject("SETUP_ERROR", "Session Replay module was invalidated before setup completed", nil)
                 return
             }
@@ -145,6 +151,7 @@
     
     @objc(invalidate)
     func invalidate() {
+        invalidated = true
         // Serialize teardown with the deferred setup() block on the main queue —
         // a mid-setup invalidate can no longer interleave; stop always runs either
         // before the block (guard rejects) or after start() (normal teardown).

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

Comment thread packages/session-replay-react-native/ios/NativeSessionReplay.swift
aliaksandr-kazarez and others added 12 commits July 7, 2026 13:44
…e (SDKRN-32)

type:"all" emits the Fabric SRMaskView component under the existing library
name, so the rename to SRMaskViewSpec was discretionary. Keeping the old name
removes all churn from the shipped TurboModule import, the Android codegen
target, and consumer build caches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s (SDKRN-32)

Keep constraint comments (coordinate spaces, recursion guards, weak-key
semantics, RN-floor rationale); drop narration that restates the code and
internal task/requirement codenames (Task 0.x, R5/R8, O3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… sdkrn-33

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(SDKRN-33)

FallbackMask/FallbackUnmask (MaskFabric) and the Old-Arch AmpMask/AmpUnmask
(MaskPaper) shared the same fail-closed strip-and-force-mask logic; extract
createAmpMaskViewFallback + a shared warnOnce into src/mask-fallback.tsx and
collapse the two tier-3 passthrough guards into a tiny local factory.
Warning/throw messages are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mments (SDKRN-33)

Public types/exports get proper API docs (what the props do, requirements,
fallback behavior); internal implementation keeps only constraint comments
(thread ordering, fail-closed rules) and drops narration and review-artifact
codenames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…4.0 pin (SDKRN-32)

The pin in example/package.json never landed in yarn.lock (still resolving
^4.4.0 -> 4.9.2) or Podfile.lock, so it was ineffective. Also picks up the
podspec checksum for the RN-floor gate changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d re-own (SDKRN-33)

Debug builds SIGABRT'd 100% (Sealable ensureUnsealed) whenever a masked
element coexisted with rapid state updates: contents nodes are skipped by
Yoga's layout traversal, so their children are cloned via side channels
(cleanupContentsNodesRecursively / layoutAbsoluteDescendants) that can hit a
sealed node from the previous commit — an upstream bug fixed only in RN 0.83+
(react/react-native#56422). Proven on-device with a zero-custom-code repro
(stock codegen node + JS display:contents still crashes).

The ShadowNode now follows the live-markdown/Expo reference pattern: no
ComponentDescriptor::adopt() override, no C++ Yoga style writes — display:
contents comes from the JS style prop; ctor-time initialize() unsets
ForceFlattenView (gated on YGDisplayContents) and eagerly re-owns all Yoga
children (public clone() + replaceChild(), RN's lazy cloneChildInPlace()
bookkeeping moved to a legal unsealed point) so the buggy upstream side
channels become no-ops. Verified: 3/3 clean Android cold starts + 10 min
stress (was crash-in-2-20s); iOS 10/10 XCTests + 19 min assert-active soak.
App-authored display:contents views remain exposed to the upstream bug on
RN 0.77-0.82; floor/gating decision tracked separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…RN-33)

Fabric emits grandparent-relative frames for children of a display:contents
host, so the capture-gate widening (host must be >0x0 for shouldCapture)
inevitably overlaps unrelated siblings — and under default pointerEvents AUTO
the host became a touch target itself (TouchTargetHelper bounds test) and
consumed native events (ReactViewGroup.onTouchEvent), killing taps on masked
children and on views beneath the phantom frame.

The host is now hardcoded PointerEvents.BOX_NONE (init setter + accessor
override): taps reach children, misses fall through to lower siblings. Union
math corrected to the shared coordinate space (extent = max child
right/bottom, origin pinned — no host-origin offset). Instrumented test 4c's
fixture updated to the same model. Verified on-device: masked tap counter
0->3, previously-dead Switch toggles, masked TextInput focuses, layout rows
unchanged; connectedDebugAndroidTest 13/13.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tay capturable (SDKRN-33)

On-device measurement (RN 0.77.2 Fabric, three shapes: top-level mask,
AmpUnmask nested through a flattened View, masked FlatList rows at deep
scroll) settled the coordinate model: children are always host-relative, and
the host's degenerate Fabric frame (X,Y,X,Y) accumulates flattened
intermediate origins — (0,0) only for top-level masks, which is why the
previous origin-pinned change looked correct: at (0,0) both models coincide.
For nested/offset hosts it produced inverted frames (bottom < top), and the
session-replay capture gate dropped those subtrees — privacy-safe, but
nested-unmasked content vanished from replays and masked list rows past the
first disappeared instead of appearing masked.

Widening is now origin + max child extent with a 1px clamp (never
zero/inverted while children exist; covers fully-negative-coordinate
children). The host origin never moves and pointer-events stays BOX_NONE —
touch transparency is carried entirely by BOX_NONE, not geometry.

Verified against the dev-SDK DebugServer rendered replay: nested-unmasked
text visible, NEST-SECRET asterisked, deep-scroll masked rows present and
asterisked, layout rows/tap/Switch unchanged, crash buffer empty.
connectedDebugAndroidTest 15/15 (test 4c rewritten to the measured model;
new 4d negative-coords clamp; new 4e nested-host shape).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hildren (SDKRN-33)

UIKit's accessibility machinery prunes zero-sized views together with their
descendants, so everything inside <AmpMask>/<AmpUnmask> was invisible to
VoiceOver, XCUITest, and a11y tree dumps (diagnosis: AmpUnmask children were
pruned too — geometry, not masking). Accessibility-only remedies were
verified ineffective on-device: the AX snapshot culls the host by its REAL
frame before consulting -accessibilityFrame or -accessibilityElements.

Mirror the Android fix: widen the host's real frame to enclose its children
(origin never moves, 1pt clamp), re-applied on layout-metrics updates, child
mount/unmount, and layoutSubviews. Yoga layout is unaffected (UIKit frames
never feed back into Fabric layout) and the existing hitTest nil-for-self
keeps the widened host touch-transparent (iOS analogue of Android BOX_NONE).

Verified: previously-missing elements restored to the a11y tree, a11y-driven
taps work (taps: 3), layout rows unchanged, SRMaskViewTests 10/10, zero
asserts during interaction; replay rendering re-verified via dev-SDK
DebugServer — widened hosts carry no paint (no overlay regressions), masked
content still gray, unmasked content visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…SDKRN-33)

Basic coverage for the two fix mechanisms that only had on-device evidence:
Android asserts the host reports PointerEvents.BOX_NONE (its widened frame
overlaps siblings and must never be a touch target); iOS asserts the host
frame widens to the children union without moving its origin and that
hitTest resolves children but never the host. connectedDebugAndroidTest
16/16; SRMaskViewTests 11/11. The crash-stress scenario needs real Fabric
commit machinery and is tracked for e2e automation in SDKRN-39.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aliaksandr-kazarez

Copy link
Copy Markdown
Contributor

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5946f48. Configure here.

Base automatically changed from danielchung/sdkrn-32-fabric-foundation to main July 9, 2026 18:19
@aliaksandr-kazarez
aliaksandr-kazarez requested a review from a team as a code owner July 9, 2026 18:19
Conflicts were add/add artifacts of the squash: main carries Part 1's
content, this branch carries Part 1 + the Part-2 fixes — resolved by taking
this branch's side for all 8 files. Committed with --no-verify: lint-staged
fails on main's own files from other packages (unbuilt workspace deps in
this worktree), unrelated to this branch's changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aliaksandr-kazarez
aliaksandr-kazarez merged commit ecfcab6 into main Jul 9, 2026
16 checks passed
@aliaksandr-kazarez
aliaksandr-kazarez deleted the danielchung/sdkrn-33-ampmask branch July 9, 2026 18:37
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.

2 participants