Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
7471ae7
feat(sr-rn): add SRMaskView codegen component spec (SDKRN-32)
chungdaniel Jun 30, 2026
6a4be07
build(sr-rn): codegen type:all, RN 0.77 floor, lint tsx (SDKRN-32)
chungdaniel Jun 30, 2026
0c6ab4e
feat(sr-rn): add C++ display:contents ShadowNode + adopt() descriptor…
chungdaniel Jun 30, 2026
46f0a10
feat(sr-rn): recorder-agnostic SRMaskingPrimitive + registry with rea…
chungdaniel Jun 30, 2026
e1a30b2
feat(sr-rn): add iOS Fabric SRMaskView host view + podspec wiring (SD…
chungdaniel Jun 30, 2026
3b12ae1
feat(sr-rn): add Android Fabric SRMaskView + manager + gradle codegen…
chungdaniel Jun 30, 2026
db55eea
build(sr-rn): pin example react-native-screens to 4.4.0 for RN 0.77 c…
chungdaniel Jun 30, 2026
6a28197
test(sr-rn): native SRMaskView masking canaries (SDKRN-32)
chungdaniel Jun 30, 2026
24c0799
docs(sr-rn): document Fabric foundation requirements (SDKRN-32)
chungdaniel Jun 30, 2026
65c1e5e
build(sr-rn): keep RN devDep at 0.73, defer 0.77 bump to Part 2 (SDKR…
chungdaniel Jul 1, 2026
4bfa5b6
fix(sr-rn): keep SRMaskView host origin fixed when widening capture b…
chungdaniel Jul 1, 2026
69c5338
fix(sr-rn): detach child layout listeners when SRMaskView host is dro…
chungdaniel Jul 1, 2026
b4e6129
fix(sr-rn): build Fabric iOS sources when RN version is unresolved un…
chungdaniel Jul 1, 2026
b1a8115
fix(sr-rn): render masked children on Android via clipChildren=false …
chungdaniel Jul 1, 2026
dbc8ee5
feat(sr-rn): add AmpMask shared types (SDKRN-33)
chungdaniel Jul 3, 2026
e38ac6d
feat(sr-rn): add Fabric AmpMask/AmpUnmask with native probe (SDKRN-33)
chungdaniel Jul 3, 2026
99a1b86
feat(sr-rn): add Paper AmpMask guard (Policy B) (SDKRN-33)
chungdaniel Jul 3, 2026
c6dc52f
feat(sr-rn): arch switch + export AmpMask/AmpUnmask (SDKRN-33)
chungdaniel Jul 3, 2026
4a95feb
feat(sr-rn): register default masking primitive at init (SDKRN-33)
chungdaniel Jul 3, 2026
5c424de
feat(sr-rn): example Mask screen + AmpMask docs (SDKRN-33)
chungdaniel Jul 3, 2026
f574e5d
fix(sr-rn): harden AmpMask public surface per final review (SDKRN-33)
chungdaniel Jul 3, 2026
dfbf16d
fix(sr-rn): fail closed when SRMaskView is unregistered on New Arch (…
chungdaniel Jul 3, 2026
3de52da
docs(sr-rn): note tier-3 passthrough still logs a one-time error (SDK…
chungdaniel Jul 3, 2026
2b72abb
fix(sr-rn): register masking primitive before capture starts (SDKRN-33)
chungdaniel Jul 3, 2026
402a523
fix(sr-rn): settle setup promise when module is invalidated before de…
chungdaniel Jul 3, 2026
04a62a3
fix(sr-rn): serialize module teardown with deferred setup on the main…
chungdaniel Jul 4, 2026
9d7a244
fix(sr-rn): harden fallback mask props and gate passthrough on record…
chungdaniel Jul 6, 2026
c6a37d5
docs(sr-rn): separate Old-Arch and bridge-mode fallback behavior in R…
chungdaniel Jul 6, 2026
d58b516
fix(sr-rn): resolve recorder probe via TurboModuleRegistry like the S…
chungdaniel Jul 6, 2026
dc4e4f2
refactor(sr-rn): keep AmpSessionReplaySpec as the codegen library nam…
aliaksandr-kazarez Jul 7, 2026
02eb85c
docs(sr-rn): tighten internal comments, drop review-artifact codename…
aliaksandr-kazarez Jul 7, 2026
985bb1e
Merge updated sdkrn-32 base (codegen name revert + comment pass) into…
aliaksandr-kazarez Jul 7, 2026
255d63d
refactor(sr-rn): dedupe AmpMask fallback components into one factory …
aliaksandr-kazarez Jul 7, 2026
14174b2
docs(sr-rn): document the public AmpMask surface, tighten internal co…
aliaksandr-kazarez Jul 7, 2026
d86395f
build(sr-rn): sync example lockfiles with the react-native-screens 4.…
aliaksandr-kazarez Jul 7, 2026
6e351d4
Merge sdkrn-32 lockfile sync into sdkrn-33
aliaksandr-kazarez Jul 7, 2026
8acadd6
fix(sr-rn): defuse RN 0.77-0.82 display:contents crash via eager chil…
aliaksandr-kazarez Jul 8, 2026
842de03
fix(sr-rn): stop widened SRMaskView host from swallowing touches (SDK…
aliaksandr-kazarez Jul 8, 2026
58fa647
fix(sr-rn): widen SRMaskView in host-relative space so offset hosts s…
aliaksandr-kazarez Jul 8, 2026
9d27004
fix(sr-rn): widen iOS SRMaskView frame so a11y doesn't prune masked c…
aliaksandr-kazarez Jul 9, 2026
5946f48
test(sr-rn): pin BOX_NONE and iOS frame-widening/touch-transparency (…
aliaksandr-kazarez Jul 9, 2026
9c8c85d
Merge main after #1865 squash-merge (retarget #1866 base to main)
aliaksandr-kazarez Jul 9, 2026
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
108 changes: 103 additions & 5 deletions packages/session-replay-react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -116,6 +115,105 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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 <View>, 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
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading