Release cached images when image component gets recycled on iOS (#51493)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/51493

Changelog: [IOS][FIXED] Don't retain cached images in state after `RCTImageComponentView` gets recycled

Fixes https://github.com/facebook/react-native/issues/51198

Crosspost from the task comment:
From what I've been able to figure out, it seems like the image shadow nodes (keeping the loaded image in state) are being kept in memory by shadow node reference wrappers. It doesn't seem strictly like a memory leak - manually triggering garbage collection causes those nodes to be deallocated, but since Hermes isn't aware of the memory they are retaining, I think, it doesn't trigger it automatically.

This diff releases the image data when the observers are notified and adds a new (`Consumed`) status to signify that.

Reviewed By: sammy-SC

Differential Revision: D75137263

fbshipit-source-id: 97eda7e6d1ef5cd633c4a5a4c37babc5e08968fb
This commit is contained in:
Jakub Piasecki
2025-07-14 01:11:54 -07:00
committed by Facebook GitHub Bot
parent 6747f2b118
commit 1d452b17bf
3 changed files with 21 additions and 1 deletions
@@ -21,6 +21,7 @@ class ImageResponse final {
Completed,
Failed,
Cancelled,
Consumed,
};
ImageResponse(std::shared_ptr<void> image, std::shared_ptr<void> metadata);
@@ -10,6 +10,7 @@
#include <algorithm>
#include <react/debug/react_native_assert.h>
#include <react/featureflags/ReactNativeFeatureFlags.h>
namespace facebook::react {
@@ -31,6 +32,7 @@ void ImageResponseObserverCoordinator::addObserver(
case ImageResponse::Status::Completed: {
auto imageData = imageData_;
auto imageMetadata = imageMetadata_;
consumeResponse();
mutex_.unlock();
observer.didReceiveImage(ImageResponse{imageData, imageMetadata});
break;
@@ -41,7 +43,8 @@ void ImageResponseObserverCoordinator::addObserver(
observer.didReceiveFailure(ImageLoadError{imageErrorData});
break;
}
case ImageResponse::Status::Cancelled: {
case ImageResponse::Status::Cancelled:
case ImageResponse::Status::Consumed: {
observers_.push_back(&observer);
status_ = ImageResponse::Status::Loading;
mutex_.unlock();
@@ -93,6 +96,9 @@ void ImageResponseObserverCoordinator::nativeImageResponseComplete(
status_ == ImageResponse::Status::Cancelled);
status_ = ImageResponse::Status::Completed;
auto observers = observers_;
if (!observers.empty()) {
consumeResponse();
}
mutex_.unlock();
for (auto observer : observers) {
@@ -116,4 +122,12 @@ void ImageResponseObserverCoordinator::nativeImageResponseFailed(
}
}
void ImageResponseObserverCoordinator::consumeResponse() const {
if (ReactNativeFeatureFlags::releaseImageDataWhenConsumed()) {
status_ = ImageResponse::Status::Consumed;
imageData_.reset();
imageMetadata_.reset();
}
}
} // namespace facebook::react
@@ -61,6 +61,11 @@ class ImageResponseObserverCoordinator {
void nativeImageResponseFailed(const ImageLoadError& loadError) const;
private:
/*
* Resets the cached image data pointers. Needs to be protected by mutex_.
*/
void consumeResponse() const;
/*
* List of observers.
* Mutable: protected by mutex_.