diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h index a3c58cb16f57..02c395d4bcea 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h @@ -94,3 +94,19 @@ NS_ASSUME_NONNULL_BEGIN @end NS_ASSUME_NONNULL_END + +/** + * True when `layerTransform` is non-identity while `propsTransform` is identity. + * On recycle, `updateProps` diffs identity vs identity and skips the layer write. + */ +__attribute__((visibility("default"))) BOOL RCTViewComponentViewHasRecycleTransformLeak( + CATransform3D layerTransform, + const facebook::react::Transform &propsTransform); + +/** + * True when `layerOpacity` differs from a default-ish `propsOpacity` (>= 0.999). + * Same recycle skip as transform: leftover opacity is never rewritten. + */ +__attribute__((visibility("default"))) BOOL RCTViewComponentViewHasRecycleOpacityLeak( + float layerOpacity, + facebook::react::Float propsOpacity); diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 9d51f68fd40d..31052da2e9f7 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -10,6 +10,7 @@ #import #import +#import #import #import #import @@ -40,6 +41,19 @@ const CGFloat BACKGROUND_COLOR_ZPOSITION = -1024.0f; +BOOL RCTViewComponentViewHasRecycleTransformLeak( + CATransform3D layerTransform, + const facebook::react::Transform &propsTransform) +{ + return propsTransform == Transform::Identity() && !CATransform3DIsIdentity(layerTransform); +} + +BOOL RCTViewComponentViewHasRecycleOpacityLeak(float layerOpacity, facebook::react::Float propsOpacity) +{ + float expectedOpacity = (float)propsOpacity; + return expectedOpacity >= 0.999f && fabsf(layerOpacity - expectedOpacity) > 0.01f; +} + @implementation RCTViewComponentView { UIColor *_backgroundColor; CALayer *_backgroundColorLayer; @@ -700,6 +714,31 @@ - (void)prepareForRecycle self.layer.opacity = (float)props.opacity; } +#if RCT_DEBUG + // Native code can stamp layer.transform / layer.opacity outside the prop pipeline. + // After recycle, updateProps diffs _props and skips the write, so leftovers poison + // the next consumer. Warn so the call site can reset on unmount (do not sanitize here). + if (RCTViewComponentViewHasRecycleTransformLeak(self.layer.transform, props.transform)) { + RCTLogWarn( + @"[FabricRecycleLeak] %@ tag=%ld size=%.0fx%.0f leftover layer.transform %@ " + @"(props.transform is identity). Reset on unmount or opt out of recycling via +shouldBeRecycled.", + NSStringFromClass(self.class), + (long)self.tag, + self.bounds.size.width, + self.bounds.size.height, + NSStringFromCGAffineTransform(CATransform3DGetAffineTransform(self.layer.transform))); + } + if (RCTViewComponentViewHasRecycleOpacityLeak(self.layer.opacity, props.opacity)) { + RCTLogWarn( + @"[FabricRecycleLeak] %@ tag=%ld leftover layer.opacity=%.3f (props.opacity=%.3f). " + @"Reset on unmount or opt out of recycling.", + NSStringFromClass(self.class), + (long)self.tag, + self.layer.opacity, + (float)props.opacity); + } +#endif + // Clean up box shadow layers to prevent cross-component contamination if (_boxShadowLayers != nullptr) { for (CALayer *boxShadowLayer = nullptr in _boxShadowLayers) { diff --git a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm index 6d5484fe2567..4fb847440a9f 100644 --- a/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm +++ b/packages/react-native/React/Tests/Mounting/RCTViewComponentViewTests.mm @@ -9,6 +9,7 @@ #import #import #import +#import using namespace facebook::react; @@ -183,4 +184,52 @@ - (void)testHitTestAfterScaleTransitionedToZeroReturnsNil XCTAssertNil([view hitTest:CGPointMake(50, 50) withEvent:nil]); } +#pragma mark - recycle-pool leak detection + +- (void)testRecycleTransformLeakWhenLayerIsNonIdentityAndPropsAreIdentity +{ + XCTAssertTrue(RCTViewComponentViewHasRecycleTransformLeak( + CATransform3DMakeTranslation(0, -302, 0), Transform::Identity())); +} + +- (void)testRecycleTransformLeakWhenLayerAndPropsAreIdentity +{ + XCTAssertFalse(RCTViewComponentViewHasRecycleTransformLeak(CATransform3DIdentity, Transform::Identity())); +} + +- (void)testRecycleTransformLeakWhenPropsAreNonIdentity +{ + // Leftover layer vs non-identity props is not the remount-skip case this guard covers. + XCTAssertFalse(RCTViewComponentViewHasRecycleTransformLeak( + CATransform3DMakeTranslation(0, -302, 0), Transform::VerticalInversion())); +} + +- (void)testRecycleOpacityLeakWhenLayerIsTransparentAndPropsAreOpaque +{ + XCTAssertTrue(RCTViewComponentViewHasRecycleOpacityLeak(0.0f, 1.0)); +} + +- (void)testRecycleOpacityLeakWhenLayerMatchesOpaqueProps +{ + XCTAssertFalse(RCTViewComponentViewHasRecycleOpacityLeak(1.0f, 1.0)); +} + +- (void)testRecycleOpacityLeakWhenPropsAreTransparent +{ + XCTAssertFalse(RCTViewComponentViewHasRecycleOpacityLeak(0.0f, 0.0)); +} + +- (void)testPrepareForRecycleDoesNotSanitizeLeftoverTransformOrOpacity +{ + RCTViewComponentView *view = [RCTViewComponentView new]; + CATransform3D leftover = CATransform3DMakeTranslation(0, -302, 0); + view.layer.transform = leftover; + view.layer.opacity = 0.0f; + + [view prepareForRecycle]; + + XCTAssertTrue(CATransform3DEqualToTransform(view.layer.transform, leftover)); + XCTAssertEqualWithAccuracy(view.layer.opacity, 0.0f, 0.001f); +} + @end