feat(session-replay-react-native): add Fabric SRMaskView foundation#1843
feat(session-replay-react-native): add Fabric SRMaskView foundation#1843chungdaniel wants to merge 5 commits into
Conversation
Add dual-arch Fabric infrastructure for layout-transparent masking: SRMaskView codegen component, C++ display:contents ShadowNode, SRMaskingRegistry seam, and Android instrumented canary test. No public API changes. Blocks SDKRN-33 (AmpMask).
size-limit report 📦
|
|
bugbot run |
There was a problem hiding this comment.
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: Mask props skip existing children
- Prop updates now reapply the current masking state to already-mounted children on both Android and iOS, unmasking children when the wrapper is disabled or unmasking.
Or push these changes by commenting:
@cursor push 83e5f8f458
Preview (83e5f8f458)
diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
--- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
+++ b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/fabric/SRMaskView.kt
@@ -14,9 +14,16 @@
private set
fun setMaskingProps(enabled: Boolean, unmask: Boolean, maskLevel: String) {
+ val maskingPropsChanged =
+ this.enabled != enabled || this.unmask != unmask || this.maskLevel != maskLevel
+
this.enabled = enabled
this.unmask = unmask
this.maskLevel = maskLevel
+
+ if (maskingPropsChanged) {
+ applyMaskingToChildren()
+ }
}
override fun addView(child: View, index: Int) {
@@ -29,12 +36,14 @@
super.removeView(view)
}
- private fun applyMaskingToChild(child: View) {
- if (!enabled) {
- return
+ private fun applyMaskingToChildren() {
+ for (i in 0 until childCount) {
+ applyMaskingToChild(getChildAt(i))
}
+ }
- if (unmask) {
+ private fun applyMaskingToChild(child: View) {
+ if (!enabled || unmask) {
SRMaskingRegistry.unmask(child)
return
}
diff --git a/packages/session-replay-react-native/ios/fabric/SRMaskView.mm b/packages/session-replay-react-native/ios/fabric/SRMaskView.mm
--- a/packages/session-replay-react-native/ios/fabric/SRMaskView.mm
+++ b/packages/session-replay-react-native/ios/fabric/SRMaskView.mm
@@ -45,14 +45,21 @@
- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
{
- const auto &oldViewProps = *std::static_pointer_cast<const SRMaskViewProps>(_props);
const auto &newViewProps = *std::static_pointer_cast<const SRMaskViewProps>(props);
+ NSString *newMaskLevel = [NSString stringWithUTF8String:newViewProps.maskLevel.c_str()];
+ BOOL maskingPropsChanged =
+ _enabled != newViewProps.enabled || _unmask != newViewProps.unmask ||
+ ![_maskLevel isEqualToString:newMaskLevel];
_enabled = newViewProps.enabled;
_unmask = newViewProps.unmask;
- _maskLevel = [NSString stringWithUTF8String:newViewProps.maskLevel.c_str()];
+ _maskLevel = newMaskLevel;
[super updateProps:props oldProps:oldProps];
+
+ if (maskingPropsChanged) {
+ [self applyMaskingToChildren];
+ }
}
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
@@ -67,13 +74,16 @@
[super unmountChildComponentView:childComponentView index:index];
}
-- (void)applyMaskingToChild:(UIView *)childView
+- (void)applyMaskingToChildren
{
- if (!_enabled) {
- return;
+ for (UIView *childView in self.subviews) {
+ [self applyMaskingToChild:childView];
}
+}
- if (_unmask) {
+- (void)applyMaskingToChild:(UIView *)childView
+{
+ if (!_enabled || _unmask) {
[SRMaskingRegistry unmaskView:childView];
return;
}You can send follow-ups to the cloud agent here.
… lifecycle - iOS: custom SRMaskViewCustomComponentDescriptor bound to SRMaskViewContentsShadowNode (display:contents); drop duplicate SRMaskViewComponentName definition - Android: remove redundant CMake native lib; keep gradle header patch - Re-apply masking on prop changes; clearForView on unmount; handle removeViewAt on Android - README: native display:contents, no JS workaround required
Move Yoga display:contents enforcement into ComponentDescriptor::adopt() so it survives prop-driven updateYogaProps() on every clone. Register the custom descriptor on Android via jni + header patch; add iOS +load registration for npm consumers.
Use Gradle-patched OBJECT codegen CMake with absolute cpp paths instead of a standalone jni SHARED target.
|
bugbot run |
There was a problem hiding this comment.
✅ 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 bb50600. Configure here.
| } | ||
|
|
||
| @Test | ||
| fun maskChildFiresForEveryDirectChild() { |
There was a problem hiding this comment.
Open question for reviewers: is this test worth keeping as-is?
It verifies the SRMaskingRegistry.mask(child) dispatch (and the iOS SRMaskViewTests equivalent) via a test-double — the seam SDKRN-33's production adapter will depend on. Two caveats worth weighing:
- It does not run in CI (no native/instrumented test job exists today;
ci.ymlruns Jest only,rn-smoke.ymlcoversanalytics-react-native). So it can't actually guard against regressions — it's a local-only canary right now. - It mocks the riskiest part by calling
addView/mountChildComponentViewmanually. It does not prove that Fabric mounts realdisplay:contentschildren underSRMaskViewat runtime — which is the thing that would actually break masking end-to-end.
Options: (a) keep and add a native test job to CI, (b) keep as a documented local smoke test, or (c) drop until SDKRN-33 adds a true end-to-end test (real RN subtree → recorder receives mask signal). Preferences?

Summary
@amplitude/session-replay-react-native: codegenSRMaskView, a C++display: contentsShadowNode, the recorder-agnosticSRMaskingRegistryseam, and instrumented canary tests.SRMaskViewis internal plumbing only; the Paper path (AMPMaskComponentView) is untouched.<AmpMask>public API).Implementation notes
adopt():display:contents+traits_.unset(ForceFlattenView)is enforced inSRMaskViewComponentDescriptor::adopt(), which runs afterupdateYogaProps()on every create/clone, rather than a fragile constructorinitialize(). This survives prop-driven Yoga style resets.+loadregisters the view withRCTComponentViewFactory, and+componentDescriptorProviderbindsSRMaskViewComponentDescriptor;codegenConfig.ios.componentProvidermapsSRMaskView. (Does not rely on auto-gen for npm-installed packages.)patchComponentDescriptorsHeader()replaces the generated descriptor typedef with our custom descriptor, andpatchCodegenCMakeLists()compilescpp/SRMaskViewShadowNode.cppinto the codegen target so the descriptor links intolibappmodules.so.clearForViewfires on unmount (unmountChildComponentViewon iOS,removeView/removeViewAton Android).Differences from the original plan (POC)
The ticket body documents the POC plan; the shipped code diverges in a few places. All are hardening with the same external behavior:
display:contentsenforcementinitialize()(DIV-1)SRMaskViewComponentDescriptor::adopt()updateYogaProps();adopt()runs after every create/clone (react-native-screens pattern) and is authoritativepatchShadowNodesHeader()regex on generatedShadowNodes.h(DIV-3)patchComponentDescriptorsHeader()(swap the descriptor typedef) +patchCodegenCMakeLists()(compilecpp/SRMaskViewShadowNode.cppinto the codegen target)libappmodules.so+load→RCTThirdPartyComponentsProvider+load→RCTComponentViewFactory registerComponentViewClass:++componentDescriptorProvider+codegenConfig.ios.componentProviderregisterComponentDescriptorsFromCodegenpath is effectively dead codeSRMaskViewCustomComponentDescriptorSRMaskViewComponentDescriptor(descriptor) /SRMaskViewContentsShadowNode(node — named distinctly from the codegen typedef to avoid an ODR clash)Test plan
pnpm --filter @amplitude/session-replay-react-native typecheck && build && test(98 tests)SRMaskView(row 5, no JSdisplay:contentsoverride) matches the no-wrapper baseline (row 1) across sections A/B/C, while plain<View>/AmpMaskViewcollapseflex:1and shift absolutely-positioned childrenSRMaskViewTest+ iOSSRMaskViewTestsassertmask(child)fires for every direct child (run locally)Follow-ups
<AmpMask>/<AmpUnmask>built on this foundation. Note: todayunmasksharesAMPMaskComponentViewwithmask, so it has the same layout-boundary problem whenever the child is layout-dependent — this foundation is what lets the new components avoid that.SRMaskingPrimitive(SRMaskingRegistry.setPrimitive(...)) — until then the Fabric masking path is inert (no-op) by design.