diff --git a/packages/session-replay-react-native/README.md b/packages/session-replay-react-native/README.md index 831187f7e..1e0cd9ec3 100644 --- a/packages/session-replay-react-native/README.md +++ b/packages/session-replay-react-native/README.md @@ -21,12 +21,11 @@ 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 foundation (internal) +### Fabric-based masking -This release includes internal groundwork for an upcoming layout-transparent -masking component built on Fabric. The component is currently inert: it is not -part of the public API, exposes nothing new to call, and does not change any -masking behavior. +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). @@ -116,6 +115,105 @@ import { AmpMaskView } from '@amplitude/session-replay-react-native'; ; ``` +## 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 `` 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 ``/`` — 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 — ``/`` log a + one-time `console.error` and fall back to `` — 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), ``/`` + 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. + + + {accountNumber} + + + +// Block: fully block the subtree from the replay. + + + + +// Unmask: opt content back in to the replay. + + Public banner + +``` + +`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` 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 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 index 5ebdbcf71..1390b67ac 100644 --- 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 @@ -4,14 +4,18 @@ import android.content.Context import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.amplitude.android.sessionreplay.SessionReplay import com.amplitude.sessionreplayreactnative.fabric.SRMaskView import com.amplitude.sessionreplayreactnative.fabric.SRMaskViewManager import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import com.facebook.react.uimanager.PointerEvents import org.junit.runner.RunWith /** @@ -210,11 +214,15 @@ class SRMaskViewTest { assertEquals("childless host height should stay 0", 0, host.height) } - // 4c. Regression: Fabric lays the degenerate display:contents host out at a - // NON-ZERO parent offset. The host must widen to the children's size WITHOUT - // moving — children are host-relative, so moving the host shifts them in - // absolute space (breaks layout neutrality) and mixing the host's parent-space - // frame with the children's host-space frames also miscomputes the size. + // 4c. Regression (measured on-device, RN 0.77.2 Fabric): Fabric lays the + // degenerate display:contents host out at a NON-ZERO parent offset + // (flattened intermediate views accumulate into the host origin — e.g. + // an AmpUnmask nested in an AmpMask through a flattened , or a + // mask inside a list row) and the children frames are HOST-relative + // (a child renders at host.left + child.left). The host must widen to + // origin + children extent WITHOUT moving; treating the child extents + // as absolute (a previous revision) produced inverted frames + // (bottom < top) that the capture gate dropped. @Test fun degenerateHostFrame_atNonZeroOffset_widensWithoutMoving() { lateinit var host: SRMaskView @@ -224,19 +232,80 @@ class SRMaskViewTest { val b = View(context) host.addView(a) host.addView(b) - // Children laid out in the host's OWN (host-relative) coordinate space. + // Children laid out HOST-relative — as measured from Fabric. a.layout(0, 0, 100, 50) b.layout(120, 60, 200, 140) // Fabric places the 0x0 display:contents host at a non-zero parent offset. host.layout(300, 400, 300, 400) } - // Size == children union (200x140), independent of the host's parent offset. + // Extent == origin + children union max right/bottom → (300,400,500,540). assertEquals("union width at offset", 200, host.width) assertEquals("union height at offset", 140, host.height) // Position preserved — the host was NOT moved (else children shift absolutely). assertEquals("host left preserved", 300, host.left) assertEquals("host top preserved", 400, host.top) + // Never inverted: bottom/right beyond origin. + assertTrue("frame not inverted", host.bottom > host.top && host.right > host.left) + } + + // 4d. A child at fully negative host-relative coordinates (e.g. the + // negative-offset badge shape) cannot be enclosed without moving the + // origin (forbidden). The frame must still never be degenerate or + // inverted — clamp to a 1x1 minimum so shouldCapture() keeps the + // subtree. + @Test + fun degenerateHostFrame_withFullyNegativeChild_clampsToOnePixel() { + lateinit var host: SRMaskView + onMain { + host = SRMaskView(context) + val a = View(context) + host.addView(a) + a.layout(-50, -60, -10, -20) // entirely above/left of the host origin + host.layout(74, 3018, 74, 3018) + } + + assertEquals("clamped width", 1, host.width) + assertEquals("clamped height", 1, host.height) + assertEquals("host left preserved", 74, host.left) + assertEquals("host top preserved", 3018, host.top) + } + + // 4e. Nested-mask shape (AmpUnmask host inside an AmpMask host): the inner + // host sits at a non-zero offset inside the outer host with its own + // host-relative children; the outer host's union must enclose the + // inner host's WIDENED frame. Mirrors the measured Mask-screen nesting + // (outer at (0,0,0,0); inner fabric frame (32,2016,32,2016), inner + // child (0,124,1017,175)). + @Test + fun nestedHosts_outerEnclosesInnerWidenedFrame() { + lateinit var outer: SRMaskView + lateinit var inner: SRMaskView + onMain { + outer = SRMaskView(context) + inner = SRMaskView(context) + val innerChild = View(context) + val outerText = View(context) + outer.addView(outerText) + outer.addView(inner) + inner.addView(innerChild) + outerText.layout(32, 2047, 1049, 2098) // host-relative to outer + innerChild.layout(0, 124, 1017, 175) // host-relative to inner + inner.layout(32, 2016, 32, 2016) // Fabric's degenerate offset frame + outer.layout(0, 0, 0, 0) + } + + // Inner: origin + child extent = (32,2016,1049,2191), never inverted. + assertEquals("inner left", 32, inner.left) + assertEquals("inner top", 2016, inner.top) + assertEquals("inner width", 1017, inner.width) + assertEquals("inner height", 175, inner.height) + // Outer: encloses max(outerText.right, inner.right)=1049 and + // max(outerText.bottom, inner.bottom)=2191 from its (0,0) origin. + assertEquals("outer width", 1049, outer.width) + assertEquals("outer height", 2191, outer.height) + assertTrue("outer capture gate", outer.width > 0 && outer.height > 0) + assertTrue("inner capture gate", inner.width > 0 && inner.height > 0) } // 5. Dropping the host detaches each child's layout listener, so a child that is @@ -307,4 +376,84 @@ class SRMaskViewTest { masksForSomeView.single().level, ) } + + // 7. Default primitive mapping (Task 2.6): [SRDefaultMaskingPrimitive] bridges + // the seam to the Session Replay SDK's tag-based hooks. Each level is + // compared against a control view driven through the SDK static directly, + // so the tests don't depend on the SDK's internal tag constants. + @Test + fun defaultPrimitive_maskLevelMask_matchesSdkMask() { + val primitive = SRDefaultMaskingPrimitive() + lateinit var view: View + lateinit var control: View + onMain { + view = View(context) + control = View(context) + primitive.mask(view, "mask") + SessionReplay.mask(control) + } + + assertNotNull("SDK mask should set a tag on the control view", control.tag) + assertEquals("mask(\"mask\") must apply the SDK's mask tag", control.tag, view.tag) + } + + @Test + fun defaultPrimitive_maskLevelBlock_matchesSdkBlock() { + val primitive = SRDefaultMaskingPrimitive() + lateinit var view: View + lateinit var control: View + onMain { + view = View(context) + control = View(context) + primitive.mask(view, "block") + SessionReplay.block(control) + } + + assertNotNull("SDK block should set a tag on the control view", control.tag) + assertEquals("mask(\"block\") must apply the SDK's block tag", control.tag, view.tag) + } + + @Test + fun defaultPrimitive_unmask_matchesSdkUnmask() { + val primitive = SRDefaultMaskingPrimitive() + lateinit var view: View + lateinit var control: View + onMain { + view = View(context) + control = View(context) + primitive.unmask(view) + SessionReplay.unmask(control) + } + + assertNotNull("SDK unmask should set a tag on the control view", control.tag) + assertEquals("unmask must apply the SDK's unmask tag", control.tag, view.tag) + } + + @Test + fun defaultPrimitive_reset_clearsTag() { + val primitive = SRDefaultMaskingPrimitive() + lateinit var view: View + onMain { + view = View(context) + primitive.mask(view, "mask") + } + assertNotNull("precondition: mask should set a tag", view.tag) + + onMain { primitive.reset(view) } + assertNull("reset must clear the SDK tag (return to inherit)", view.tag) + } + + // 7. Touch transparency: the widened host frame necessarily overlaps + // unrelated siblings, so the host itself must never be a touch target — + // RN touch targeting must skip it (BOX_NONE) and only consider children. + @Test + fun host_pointerEvents_isBoxNone() { + lateinit var host: SRMaskView + onMain { host = SRMaskView(context) } + assertEquals( + "SRMaskView host must be BOX_NONE so its widened frame can't swallow input", + PointerEvents.BOX_NONE, + host.pointerEvents, + ) + } } diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRDefaultMaskingPrimitive.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRDefaultMaskingPrimitive.kt new file mode 100644 index 000000000..05883dcfb --- /dev/null +++ b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SRDefaultMaskingPrimitive.kt @@ -0,0 +1,36 @@ +package com.amplitude.sessionreplayreactnative + +import android.view.View +import com.amplitude.android.sessionreplay.SessionReplay + +/** + * Default [SRMaskingPrimitive] bridging the masking seam to the Amplitude + * Session Replay Android SDK's existing tag-based hooks: + * + * - `mask(view, "mask")` -> [SessionReplay.mask] + * - `mask(view, "block")` -> [SessionReplay.block] + * - `unmask(view)` -> [SessionReplay.unmask] + * - `reset(view)` -> `view.tag = null` (the SDK hooks are tag-based; + * clearing the tag returns the view to "inherit") + * + * Registered on the UI thread at SDK init + * ([SessionReplayReactNativeModule.setup]); registration replays intents + * recorded before init, so mount-before-init masking still applies. + */ +class SRDefaultMaskingPrimitive : SRMaskingPrimitive { + override fun mask(view: View, level: String) { + when (level) { + "block" -> SessionReplay.block(view) + // Default mask level is "mask"; unknown levels fail safe to masking. + else -> SessionReplay.mask(view) + } + } + + override fun unmask(view: View) { + SessionReplay.unmask(view) + } + + override fun reset(view: View) { + view.tag = null + } +} 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 index 932b65c1d..77c691440 100644 --- 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 @@ -9,6 +9,7 @@ import com.amplitude.core.ServerZone import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.ReadableMap @@ -18,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap class SessionReplayReactNativeModule(private val reactContext: ReactApplicationContext) : SessionReplayReactNativeSpec(reactContext) { private lateinit var sessionReplay: SessionReplay + @Volatile private var invalidated = false override fun getName(): String { return NAME @@ -78,11 +80,32 @@ class SessionReplayReactNativeModule(private val reactContext: ReactApplicationC "EU" -> ServerZone.EU else -> ServerZone.US }, - autoStart = autoStart, + // Deferred to the UI-thread block below so primitive registration can + // be ordered before start(); behaviorally identical to SDK autoStart. + autoStart = false, privacyConfig = PrivacyConfig(maskLevel = maskLevel), ) - - promise.resolve(null) + + // Register the primitive, start capture, and resolve in ONE UI-thread + // block: the registry is UI-thread-only, and registration must precede + // start() on the capture (UI) thread so an early capture frame can't + // snapshot SRMaskView children before their masking intents apply. + // Re-registering on repeated setup() calls is harmless. + UiThreadUtil.runOnUiThread { + if (invalidated) { + promise.reject("SETUP_ERROR", "Session Replay module was invalidated before setup completed", null) + return@runOnUiThread + } + try { + SRMaskingRegistry.setPrimitive(SRDefaultMaskingPrimitive()) + if (autoStart) { + sessionReplay.start() + } + promise.resolve(null) + } catch (e: Exception) { + promise.reject("SETUP_ERROR", e.message, e) + } + } } catch (e: Exception) { promise.reject("SETUP_ERROR", e.message, e) } @@ -177,8 +200,15 @@ class SessionReplayReactNativeModule(private val reactContext: ReactApplicationC } override fun invalidate() { - if (::sessionReplay.isInitialized) { - sessionReplay.shutdown() + invalidated = true + // Serialize teardown with the deferred setup() block: both run on the UI + // queue, so a mid-setup invalidate can no longer interleave — shutdown + // always runs either before the block (flag rejects it) or after start() + // (normal stop). + UiThreadUtil.runOnUiThread { + if (::sessionReplay.isInitialized) { + sessionReplay.shutdown() + } } } 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 index c876f7083..9016d37fb 100644 --- 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 @@ -3,6 +3,7 @@ package com.amplitude.sessionreplayreactnative.fabric import android.content.Context import android.view.View import com.amplitude.sessionreplayreactnative.SRMaskingRegistry +import com.facebook.react.uimanager.PointerEvents import com.facebook.react.views.view.ReactViewGroup /** @@ -28,8 +29,22 @@ class SRMaskView(context: Context) : ReactViewGroup(context) { init { clipChildren = false clipToPadding = false + // The widened frame (see expandBoundsToChildrenUnion) necessarily spans + // from the host's Fabric-assigned origin to the children's far corner, so + // it overlaps sibling views that have nothing to do with this mask. The + // host must therefore never participate in touch targeting itself: + // BOX_NONE makes RN's TouchTargetHelper skip the host (only its children + // can be targets) and lets misses fall through to views underneath. + // Children are unaffected. This view is never created from a JS + // pointerEvents prop, so nothing else writes this field. + setPointerEvents(PointerEvents.BOX_NONE) } + // Belt-and-braces with the init{} setter: TouchTargetHelper consults this + // accessor, and it must stay BOX_NONE even if some future code path (e.g. + // view recycling's resetPointerEvents) rewrites the backing field. + override fun getPointerEvents(): PointerEvents = PointerEvents.BOX_NONE + // Children can be laid out after the host's own layout pass; re-widen then. private val childLayoutChangeListener = OnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom -> @@ -74,13 +89,25 @@ class SRMaskView(context: Context) : ReactViewGroup(context) { expandBoundsToChildrenUnion() } - // Widen this host's native frame to enclose its children WITHOUT moving it: - // the origin (left, top) must stay Fabric-assigned, because children are - // positioned relative to the host — moving it would shift them on screen and - // break layout neutrality. Only right/bottom grow. Child coordinates are in - // host-space, so the parent-space extent is host origin + max child extent. - // (Children at negative host-space coords can't be enclosed without moving - // the host; normal RN layout never produces those.) + // Widen this host's native frame to enclose its children WITHOUT moving it. + // + // Measured coordinate model (SDKRN-33, RN 0.77.2 Fabric, on-device): + // children are ALWAYS host-relative — a child renders at + // (host.left + child.left, host.top + child.top), standard Android. The + // host's Fabric-assigned degenerate frame is (X,Y,X,Y) where (X,Y) is the + // accumulated origin of any flattened views between the host and its + // mounted parent: (0,0) for top-level masks (where host-relative and + // parent-space coincide numerically), non-zero for e.g. an AmpUnmask nested + // inside an AmpMask through a flattened , a mask inside a list row, + // or a mask whose child uses negative offsets. Therefore the enclosing + // extent in parent space is origin + max child extent. The origin must + // never move (children would shift on screen), so children at negative + // host-relative coordinates cannot be enclosed; the extents are clamped so + // the frame is never degenerate or inverted while children exist — the + // session-replay capture gate (width>0 && height>0) is the whole point of + // the widening. The widened frame can overlap unrelated siblings, which is + // why the host is pointer-events BOX_NONE (see init) and must never be a + // touch target itself. private fun expandBoundsToChildrenUnion() { if (expanding) return if (childCount == 0) return @@ -93,8 +120,11 @@ class SRMaskView(context: Context) : ReactViewGroup(context) { if (c.bottom > maxChildBottom) maxChildBottom = c.bottom } - val newRight = left + maxChildRight - val newBottom = top + maxChildBottom + // Children are host-relative, so parent-space extent = origin + extent. + // Clamp to a 1px minimum so a child at fully negative coordinates can + // never produce a zero/inverted frame that the capture gate would drop. + val newRight = left + maxOf(maxChildRight, 1) + val newBottom = top + maxOf(maxChildBottom, 1) if (newRight != right || newBottom != bottom) { expanding = true diff --git a/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h b/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h index f9711eaa9..09d712b46 100644 --- a/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h +++ b/packages/session-replay-react-native/cpp/SRMaskViewComponentDescriptor.h @@ -2,27 +2,19 @@ #include "SRMaskViewShadowNode.h" -#include -#include #include namespace facebook::react { // Replaces the codegen typedef of the same name (patched on Android; iOS binds -// via +componentDescriptorProvider). adopt() runs after updateYogaProps() on -// every create/clone so display:contents is authoritative. +// via +componentDescriptorProvider). Exists only to bind the custom +// SRMaskViewContentsShadowNode class — no adopt() override; the ShadowNode's +// own constructors handle the ForceFlattenView unset (see +// SRMaskViewShadowNode.h). class SRMaskViewComponentDescriptor final : public ConcreteComponentDescriptor { public: using ConcreteComponentDescriptor::ConcreteComponentDescriptor; - - void adopt(ShadowNode& shadowNode) const override { - react_native_assert( - dynamic_cast(&shadowNode)); - auto& contentsNode = static_cast(shadowNode); - contentsNode.applyContentsDisplay(); - ConcreteComponentDescriptor::adopt(shadowNode); - } }; } // namespace facebook::react diff --git a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp b/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp index 72ae3c7ab..0f860f502 100644 --- a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp +++ b/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.cpp @@ -1,16 +1,54 @@ #include "SRMaskViewShadowNode.h" +#include #include -#include +#include namespace facebook::react { -void SRMaskViewContentsShadowNode::applyContentsDisplay() { +void SRMaskViewContentsShadowNode::initialize() { + // display:contents comes from the JS style prop and is already in + // yogaNode_'s style (parsed by RN's own updateYogaProps(), which also set + // ForceFlattenView). We never write the Yoga style in C++; the only trait + // customization is keeping the host view mounted (the session-replay SDK + // needs a real view to tag for masking) while Yoga still lays the children + // out as if the host didn't exist. + if (YGNodeStyleGetDisplay(&yogaNode_) != YGDisplayContents) { + return; + } + traits_.unset(ShadowNodeTraits::Trait::ForceFlattenView); - auto style = yogaNode_.style(); - style.setDisplay(yoga::Display::Contents); - yogaNode_.setStyle(style); + // Workaround for an upstream Yoga display:contents bug present in + // RN 0.77-0.82 (fixed by facebook/react-native#56422, shipped in 0.86 and + // backports): because contents nodes are skipped by Yoga's layout + // traversal, their children are cloned through side channels + // (cleanupContentsNodesRecursively / layoutAbsoluteDescendants) that call + // cloneChildrenIfNeeded() on a contents node even when it is clean and + // belongs to an already-committed (sealed) tree. If this node was cloned + // without re-adopting its children, that fires the ShadowNode clone + // callback on the sealed node and SIGABRTs debug builds + // (Sealable::ensureUnsealed). Defuse the precondition: eagerly take + // ownership of every Yoga child at construction time (we are unsealed + // here), so cloneChildrenIfNeeded() on this node is always a no-op later. + // This mirrors what YogaLayoutableShadowNode::cloneChildInPlace() does + // lazily during layout, just moved to a legal (unsealed) point in time. + auto yogaChildren = yogaNode_.getChildren(); // copy: replaceChild mutates + for (yoga::Node* childYogaNode : yogaChildren) { + if (childYogaNode->getOwner() == &yogaNode_) { + continue; + } + auto& childShadowNode = *static_cast( + childYogaNode->getContext()); + auto clonedChildShadowNode = childShadowNode.clone( + {ShadowNodeFragment::propsPlaceholder(), + ShadowNodeFragment::childrenPlaceholder(), + childShadowNode.getState()}); + // Public replaceChild() performs the same Yoga bookkeeping as RN's + // private cloneChildInPlace(): swaps the ShadowNode child and re-owns + // the fresh clone's Yoga node under yogaNode_. + replaceChild(childShadowNode, clonedChildShadowNode); + } } } // namespace facebook::react diff --git a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h b/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h index 22f9a7cba..650bdc4fc 100644 --- a/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h +++ b/packages/session-replay-react-native/cpp/SRMaskViewShadowNode.h @@ -10,6 +10,19 @@ namespace facebook::react { // clashes with generated ShadowNodes.h while reusing the same component name. extern const char SRMaskViewComponentName[]; +// The JS component passes style={display:'contents'}; RN's own +// updateYogaProps() (run inside the base constructors) parses it into the +// Yoga style and sets ShadowNodeTraits::ForceFlattenView. We never mutate the +// Yoga style in C++ — the only customization is unsetting ForceFlattenView +// after each construction so the Android/iOS host view stays mounted (the +// session-replay SDK needs a real view to tag for masking) while Yoga still +// lays the children out as if the host didn't exist. +// +// This mirrors the pattern shipped by Expensify react-native-live-markdown +// (MarkdownTextInputDecoratorShadowNode) and Expo's expo-modules-core +// (ExpoViewShadowNode's disableForceFlatten): a post-constructor hook invoked +// from BOTH constructors (create + clone), no ComponentDescriptor::adopt() +// override, no Yoga style writes. class SRMaskViewContentsShadowNode final : public ConcreteViewShadowNode< SRMaskViewComponentName, @@ -20,16 +33,24 @@ class SRMaskViewContentsShadowNode final const ShadowNodeFragment& fragment, const ShadowNodeFamily::Shared& family, ShadowNodeTraits traits) - : ConcreteViewShadowNode(fragment, family, traits) {} + : ConcreteViewShadowNode(fragment, family, traits) { + initialize(); + } SRMaskViewContentsShadowNode( const ShadowNode& sourceShadowNode, const ShadowNodeFragment& fragment) - : ConcreteViewShadowNode(sourceShadowNode, fragment) {} + : ConcreteViewShadowNode(sourceShadowNode, fragment) { + initialize(); + } - // Applied from ComponentDescriptor::adopt() after updateYogaProps() on every - // create/clone so display:contents survives prop-driven Yoga style resets. - void applyContentsDisplay(); + private: + // Runs after the base constructors (and therefore after updateYogaProps(), + // which re-sets ForceFlattenView on every clone that carries new props). + // Must be called from every constructor. Besides the trait unset it also + // eagerly re-owns this node's Yoga children — a workaround for an upstream + // RN 0.77-0.82 display:contents crash; see the .cpp for details. + void initialize(); }; } // namespace facebook::react diff --git a/packages/session-replay-react-native/example/App.tsx b/packages/session-replay-react-native/example/App.tsx index 1b321198e..7c3ae55d7 100644 --- a/packages/session-replay-react-native/example/App.tsx +++ b/packages/session-replay-react-native/example/App.tsx @@ -24,6 +24,7 @@ import { Switch, Text, TextInput, + UIManager, View, } from 'react-native'; import { WebView } from 'react-native-webview'; @@ -42,6 +43,9 @@ import { getSessionReplayProperties, setSessionId, setDeviceId, + AmpMask, + AmpUnmask, + AmpMaskView, } from '@amplitude/session-replay-react-native'; const g = global as unknown as { @@ -51,6 +55,9 @@ const g = global as unknown as { }; const isTurboModule = g.__turboModuleProxy != null || g.RN$Bridgeless === true; const isFabric = g.nativeFabricUIManager != null; +// Same New Architecture check the library uses to select the AmpMask +// implementation (src/index.tsx) — keep the two in sync. +const isNewArch = g.RN$Bridgeless === true || g.nativeFabricUIManager != null; const rnv = Platform.constants?.reactNativeVersion; const rnVersion = rnv ? `${rnv.major}.${rnv.minor}.${rnv.patch}` : 'unknown'; @@ -68,6 +75,7 @@ type RootStackParamList = { Form: undefined; Gallery: undefined; Web: undefined; + Mask: undefined; }; type HomeProps = NativeStackScreenProps; @@ -172,6 +180,7 @@ function HomeScreen({ navigation }: HomeProps): React.JSX.Element {