Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

#import <CoreGraphics/CoreGraphics.h>
#import <QuartzCore/QuartzCore.h>
#import <math.h>
#import <objc/runtime.h>
#import <optional>
#import <ranges>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#import <XCTest/XCTest.h>
#import <react/renderer/components/view/ViewProps.h>
#import <react/renderer/components/view/ViewShadowNode.h>
#import <react/renderer/graphics/Transform.h>

using namespace facebook::react;

Expand Down Expand Up @@ -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