Skip to content

Commit 0e87de0

Browse files
fix(iOS): reuse prefetched images (#57971)
Summary: Fixes #28557. iOS image prefetch stores decoded images using their original-size cache key. Later image requests often include concrete dimensions, producing a different cache key and causing the prefetched image to be downloaded again. This change falls back to the original-size cache entry for sized requests and reports prefetched decoded images as available in memory through `Image.queryCache()`. ## Changelog: [IOS] [FIXED] - Reuse prefetched images for subsequent sized image requests Pull Request resolved: #57971 Test Plan: - `git diff --check` — passed. - Tested with RNTester on an iPhone simulator running iOS 26.5. - Opened **Image → Image Loading Events**. - Verified `Image.prefetch()` completed successfully. - Verified `Image.queryCache()` returned `disk/memory`. - Verified the prefetched image loaded and rendered at the requested dimensions without unexpected stretching, cropping, or distortion. Screenshot: <img width="318" height="604" alt="Screenshot 2026-08-16 at 12 09 30 PM" src="https://github.com/user-attachments/assets/c6db4ce4-d130-47d3-ae64-0a62f5e5385e" /> Reviewed By: cipolleschi Differential Revision: D118334375 Pulled By: javache fbshipit-source-id: 3ed64fdde88beb3476056fb0f4ac5acc294ce4ae
1 parent 665beff commit 0e87de0

2 files changed

Lines changed: 142 additions & 20 deletions

File tree

‎packages/react-native/Libraries/Image/RCTImageLoader.mm‎

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -186,19 +186,25 @@ - (float)handlerPriority
186186

187187
- (id<RCTImageCache>)imageCache
188188
{
189-
if (!_imageCache) {
190-
// set up with default cache
191-
_imageCache = [RCTImageCache new];
189+
// Callers reach this from the URL request queue, the JS thread and the main
190+
// queue, so the lazy initialization has to be serialized.
191+
@synchronized(self) {
192+
if (_imageCache == nil) {
193+
// set up with default cache
194+
_imageCache = [RCTImageCache new];
195+
}
196+
return _imageCache;
192197
}
193-
return _imageCache;
194198
}
195199

196200
- (void)setImageCache:(id<RCTImageCache>)cache
197201
{
198-
if (_imageCache) {
199-
RCTLogWarn(@"RCTImageCache was already set and has now been overridden.");
202+
@synchronized(self) {
203+
if (_imageCache != nil) {
204+
RCTLogWarn(@"RCTImageCache was already set and has now been overridden.");
205+
}
206+
_imageCache = cache;
200207
}
201-
_imageCache = cache;
202208
}
203209

204210
- (id<RCTImageURLLoader>)imageURLLoaderForURL:(NSURL *)URL
@@ -311,6 +317,28 @@ - (void)setImageCache:(id<RCTImageCache>)cache
311317
return image;
312318
}
313319

320+
/**
321+
* Looks up a decoded image, falling back to the original-size entry that
322+
* prefetching writes so that a request for concrete dimensions can reuse it.
323+
* The fallback key must stay in sync with the one used by
324+
* prefetchImageWithMetadata:queryRootName:rootTag:resolve:reject:.
325+
*/
326+
static UIImage *RCTCachedImageForURL(
327+
id<RCTImageCache> imageCache,
328+
NSString *urlString,
329+
CGSize size,
330+
CGFloat scale,
331+
BOOL clipped,
332+
RCTResizeMode resizeMode)
333+
{
334+
UIImage *image = [imageCache imageForUrl:urlString size:size scale:scale resizeMode:resizeMode];
335+
if (image != nil) {
336+
return image;
337+
}
338+
image = [imageCache imageForUrl:urlString size:CGSizeZero scale:1 resizeMode:RCTResizeModeStretch];
339+
return clipped ? RCTResizeImageIfNeeded(image, size, scale, resizeMode) : image;
340+
}
341+
314342
/*
315343
* This function abstracts away the migration from loadImageForURL to loadImageURL by handling checking whether
316344
* the interface responds to the new function signature and calling the appropriate function based on the result.
@@ -492,6 +520,7 @@ - (void)dequeueTasks
492520
- (RCTImageURLLoaderRequest *)_loadImageOrDataWithURLRequest:(NSURLRequest *)request
493521
size:(CGSize)size
494522
scale:(CGFloat)scale
523+
clipped:(BOOL)clipped
495524
resizeMode:(RCTResizeMode)resizeMode
496525
priority:(RCTImageLoaderPriority)priority
497526
attribution:(const ImageURLLoaderAttribution &)attribution
@@ -542,10 +571,8 @@ - (RCTImageURLLoaderRequest *)_loadImageOrDataWithURLRequest:(NSURLRequest *)req
542571
}
543572

544573
if (cacheResult && partialLoadHandler) {
545-
UIImage *image = [[self imageCache] imageForUrl:request.URL.absoluteString
546-
size:size
547-
scale:scale
548-
resizeMode:resizeMode];
574+
UIImage *image =
575+
RCTCachedImageForURL([self imageCache], request.URL.absoluteString, size, scale, clipped, resizeMode);
549576
if (image) {
550577
partialLoadHandler(image);
551578
}
@@ -662,10 +689,8 @@ - (RCTImageURLLoaderRequest *)_loadImageOrDataWithURLRequest:(NSURLRequest *)req
662689
} else {
663690
UIImage *image;
664691
if (cacheResult) {
665-
image = [[strongSelf imageCache] imageForUrl:request.URL.absoluteString
666-
size:size
667-
scale:scale
668-
resizeMode:resizeMode];
692+
image = RCTCachedImageForURL(
693+
[strongSelf imageCache], request.URL.absoluteString, size, scale, clipped, resizeMode);
669694
}
670695

671696
if (image) {
@@ -889,6 +914,7 @@ - (RCTImageURLLoaderRequest *)loadImageWithURLRequest:(NSURLRequest *)imageURLRe
889914
RCTImageURLLoaderRequest *loaderRequest = [self _loadImageOrDataWithURLRequest:imageURLRequest
890915
size:size
891916
scale:scale
917+
clipped:clipped
892918
resizeMode:resizeMode
893919
priority:priority
894920
attribution:attribution
@@ -1107,6 +1133,7 @@ - (RCTImageLoaderCancellationBlock)getImageSizeForURLRequest:(NSURLRequest *)ima
11071133
RCTImageURLLoaderRequest *loaderRequest = [self _loadImageOrDataWithURLRequest:imageURLRequest
11081134
size:CGSizeZero
11091135
scale:1
1136+
clipped:NO
11101137
resizeMode:RCTResizeModeStretch
11111138
priority:RCTImageLoaderPriorityImmediate
11121139
attribution:{}
@@ -1121,18 +1148,22 @@ - (NSDictionary *)getImageCacheStatus:(NSArray *)requests
11211148
NSMutableDictionary *results = [NSMutableDictionary dictionary];
11221149
for (id request in requests) {
11231150
NSURLRequest *urlRequest = [RCTConvert NSURLRequest:request];
1124-
if (urlRequest) {
1151+
NSString *urlString = urlRequest.URL.absoluteString;
1152+
if (urlRequest != nil && urlString != nil) {
11251153
NSCachedURLResponse *cachedResponse = [NSURLCache.sharedURLCache cachedResponseForRequest:urlRequest];
11261154
if (cachedResponse) {
11271155
if (cachedResponse.storagePolicy == NSURLCacheStorageAllowedInMemoryOnly) {
1128-
results[urlRequest.URL.absoluteString] = @"memory";
1156+
results[urlString] = @"memory";
11291157
} else if (NSURLCache.sharedURLCache.currentMemoryUsage == 0) {
11301158
// We can't check whether the file is cached on disk or memory.
11311159
// However, if currentMemoryUsage is disabled, it must be read from disk.
1132-
results[urlRequest.URL.absoluteString] = @"disk";
1160+
results[urlString] = @"disk";
11331161
} else {
1134-
results[urlRequest.URL.absoluteString] = @"disk/memory";
1162+
results[urlString] = @"disk/memory";
11351163
}
1164+
} else if (
1165+
[[self imageCache] imageForUrl:urlString size:CGSizeZero scale:1 resizeMode:RCTResizeModeStretch] != nil) {
1166+
results[urlString] = @"memory";
11361167
}
11371168
}
11381169
}

‎packages/rn-tester/RNTesterUnitTests/RCTImageLoaderTests.m‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,98 @@
1919
RCTDefineImageURLLoader(RCTImageLoaderTestsURLLoader1) RCTDefineImageURLLoader(RCTImageLoaderTestsURLLoader2)
2020
RCTDefineImageDecoder(RCTImageLoaderTestsDecoder1) RCTDefineImageDecoder(RCTImageLoaderTestsDecoder2)
2121

22-
@interface RCTImageLoaderTests : XCTestCase
22+
@interface RCTImageLoaderTestsImageCache : NSObject <RCTImageCache>
23+
24+
@property (nonatomic, strong) UIImage *image;
25+
@property (nonatomic, copy) NSString *URLString;
26+
@property (nonatomic, assign) CGSize requestedSize;
27+
@property (nonatomic, assign) BOOL didMissRequestedSize;
28+
@property (nonatomic, assign) BOOL didHitOriginalSize;
29+
30+
@end
31+
32+
@implementation RCTImageLoaderTestsImageCache
33+
34+
- (UIImage *)imageForUrl:(NSString *)url
35+
size:(CGSize)size
36+
scale:(CGFloat)scale
37+
resizeMode:(RCTResizeMode)resizeMode
38+
{
39+
if (![url isEqualToString:self.URLString] || scale != 1 || resizeMode != RCTResizeModeStretch) {
40+
return nil;
41+
}
42+
if (CGSizeEqualToSize(size, self.requestedSize)) {
43+
self.didMissRequestedSize = YES;
44+
return nil;
45+
}
46+
if (CGSizeEqualToSize(size, CGSizeZero)) {
47+
self.didHitOriginalSize = YES;
48+
return self.image;
49+
}
50+
return nil;
51+
}
52+
53+
- (void)addImageToCache:(__unused UIImage *)image
54+
URL:(__unused NSString *)url
55+
size:(__unused CGSize)size
56+
scale:(__unused CGFloat)scale
57+
resizeMode:(__unused RCTResizeMode)resizeMode
58+
response:(__unused NSURLResponse *)response
59+
{
60+
}
61+
62+
@end
63+
64+
@interface RCTImageLoaderCacheTests : XCTestCase
65+
66+
@end
67+
68+
@implementation RCTImageLoaderCacheTests
69+
70+
- (void)testCachedImagePreservesNaturalSizeWhenNotClipped
71+
{
72+
CGSize imageSize = CGSizeMake(200, 100);
73+
UIGraphicsBeginImageContextWithOptions(imageSize, YES, 1);
74+
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
75+
UIGraphicsEndImageContext();
76+
77+
RCTImageLoaderTestsImageCache *imageCache = [RCTImageLoaderTestsImageCache new];
78+
imageCache.image = image;
79+
imageCache.URLString = @"https://reactnative.dev/img/opengraph.png";
80+
imageCache.requestedSize = CGSizeMake(100, 100);
81+
82+
NS_VALID_UNTIL_END_OF_SCOPE RCTImageLoader *imageLoader = [[RCTImageLoader alloc]
83+
initWithRedirectDelegate:nil
84+
loadersProvider:^NSArray<id<RCTImageURLLoader>> *(__unused RCTModuleRegistry *moduleRegistry) {
85+
return @[];
86+
}
87+
decodersProvider:^NSArray<id<RCTImageDataDecoder>> *(__unused RCTModuleRegistry *moduleRegistry) {
88+
return @[];
89+
}];
90+
[imageLoader setImageCache:imageCache];
91+
92+
XCTestExpectation *expectation = [self expectationWithDescription:@"Image loaded from cache"];
93+
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:imageCache.URLString]];
94+
[imageLoader loadImageWithURLRequest:request
95+
size:imageCache.requestedSize
96+
scale:1
97+
clipped:NO
98+
resizeMode:RCTResizeModeStretch
99+
progressBlock:nil
100+
partialLoadBlock:nil
101+
completionBlock:^(NSError *error, UIImage *loadedImage) {
102+
XCTAssertNil(error);
103+
XCTAssertEqualObjects(loadedImage, image);
104+
[expectation fulfill];
105+
}];
106+
[self waitForExpectations:@[ expectation ] timeout:1];
107+
XCTAssertTrue(imageCache.didMissRequestedSize);
108+
XCTAssertTrue(imageCache.didHitOriginalSize);
109+
}
110+
111+
@end
112+
113+
@interface RCTImageLoaderTests : XCTestCase
23114

24115
@end
25116

0 commit comments

Comments
 (0)