From e2f51a57abf1f7379c8c8a57364dc6d36fef0389 Mon Sep 17 00:00:00 2001 From: lkinasiewicz Date: Tue, 17 Feb 2026 12:14:07 +0100 Subject: [PATCH 1/6] [ImageLoader] Use Map instead of object Mainly not to use delete operator for performance reasons. --- .../src/exports/Image/__tests__/index-test.js | 2 +- .../src/modules/ImageLoader/index.js | 38 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/react-native-web/src/exports/Image/__tests__/index-test.js b/packages/react-native-web/src/exports/Image/__tests__/index-test.js index 6bdd81337b..91d5324e1d 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Image/__tests__/index-test.js @@ -18,7 +18,7 @@ const originalImage = window.Image; describe('components/Image', () => { beforeEach(() => { - ImageUriCache._entries = {}; + ImageUriCache._entries = new Map(); window.Image = jest.fn(() => ({})); }); diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 892db99292..ce74fd3811 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -11,32 +11,34 @@ const dataUriPattern = /^data:/; export class ImageUriCache { static _maximumEntries: number = 256; - static _entries = {}; + static _entries = new Map(); static has(uri: string): boolean { const entries = ImageUriCache._entries; const isDataUri = dataUriPattern.test(uri); - return isDataUri || Boolean(entries[uri]); + return isDataUri || entries.has(uri); } static add(uri: string) { const entries = ImageUriCache._entries; const lastUsedTimestamp = Date.now(); - if (entries[uri]) { - entries[uri].lastUsedTimestamp = lastUsedTimestamp; - entries[uri].refCount += 1; + const entry = entries.get(uri); + if (entry) { + entry.lastUsedTimestamp = lastUsedTimestamp; + entry.refCount += 1; } else { - entries[uri] = { + entries.set(uri, { lastUsedTimestamp, refCount: 1 - }; + }); } } static remove(uri: string) { const entries = ImageUriCache._entries; - if (entries[uri]) { - entries[uri].refCount -= 1; + const entry = entries.get(uri); + if (entry) { + entry.refCount -= 1; } // Free up entries when the cache is "full" ImageUriCache._cleanUpIfNeeded(); @@ -44,14 +46,12 @@ export class ImageUriCache { static _cleanUpIfNeeded() { const entries = ImageUriCache._entries; - const imageUris = Object.keys(entries); - if (imageUris.length + 1 > ImageUriCache._maximumEntries) { + if (entries.size + 1 > ImageUriCache._maximumEntries) { let leastRecentlyUsedKey; let leastRecentlyUsedEntry; - imageUris.forEach((uri) => { - const entry = entries[uri]; + entries.forEach((entry, uri) => { if ( (!leastRecentlyUsedEntry || entry.lastUsedTimestamp < @@ -64,23 +64,23 @@ export class ImageUriCache { }); if (leastRecentlyUsedKey) { - delete entries[leastRecentlyUsedKey]; + entries.delete(leastRecentlyUsedKey); } } } } let id = 0; -const requests = {}; +const requests = new Map(); const ImageLoader = { abort(requestId: number) { - let image = requests[`${requestId}`]; + let image = requests.get(`${requestId}`); if (image) { image.onerror = null; image.onload = null; image = null; - delete requests[`${requestId}`]; + requests.delete(`${requestId}`); } }, getSize( @@ -93,7 +93,7 @@ const ImageLoader = { const requestId = ImageLoader.load(uri, callback, errorCallback); function callback() { - const image = requests[`${requestId}`]; + const image = requests.get(`${requestId}`); if (image) { const { naturalHeight, naturalWidth } = image; if (naturalHeight && naturalWidth) { @@ -135,7 +135,7 @@ const ImageLoader = { } }; image.src = uri; - requests[`${id}`] = image; + requests.set(`${id}`, image); return id; }, prefetch(uri: string): Promise { From a56b924600dfb23ce67a52f914202fa3de2f89ad Mon Sep 17 00:00:00 2001 From: lkinasiewicz Date: Tue, 17 Feb 2026 12:36:12 +0100 Subject: [PATCH 2/6] [ImageLoader] Remove unnecessary code --- packages/react-native-web/src/modules/ImageLoader/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index ce74fd3811..89cc6060b0 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -75,11 +75,10 @@ const requests = new Map(); const ImageLoader = { abort(requestId: number) { - let image = requests.get(`${requestId}`); + const image = requests.get(`${requestId}`); if (image) { image.onerror = null; image.onload = null; - image = null; requests.delete(`${requestId}`); } }, From fdf2fb9190c5f388a3ecb0977c09168fae25618c Mon Sep 17 00:00:00 2001 From: lkinasiewicz Date: Tue, 17 Feb 2026 13:06:12 +0100 Subject: [PATCH 3/6] [ImageLoader] Try to clean up cache only when a cache entry is removed --- packages/react-native-web/src/modules/ImageLoader/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 89cc6060b0..ea86071a3b 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -37,11 +37,11 @@ export class ImageUriCache { static remove(uri: string) { const entries = ImageUriCache._entries; const entry = entries.get(uri); - if (entry) { + if (entry?.refCount > 0) { entry.refCount -= 1; + // Free up entries when the cache is "full" + ImageUriCache._cleanUpIfNeeded(); } - // Free up entries when the cache is "full" - ImageUriCache._cleanUpIfNeeded(); } static _cleanUpIfNeeded() { From 0779f7806ed48fe788d27adfc01961c8ab54b236 Mon Sep 17 00:00:00 2001 From: lkinasiewicz Date: Tue, 17 Feb 2026 13:13:40 +0100 Subject: [PATCH 4/6] [ImageLoader] Use only numbers as request IDs --- .../react-native-web/src/modules/ImageLoader/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index ea86071a3b..6e98750eab 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -75,11 +75,11 @@ const requests = new Map(); const ImageLoader = { abort(requestId: number) { - const image = requests.get(`${requestId}`); + const image = requests.get(requestId); if (image) { image.onerror = null; image.onload = null; - requests.delete(`${requestId}`); + requests.delete(requestId); } }, getSize( @@ -92,7 +92,7 @@ const ImageLoader = { const requestId = ImageLoader.load(uri, callback, errorCallback); function callback() { - const image = requests.get(`${requestId}`); + const image = requests.get(requestId); if (image) { const { naturalHeight, naturalWidth } = image; if (naturalHeight && naturalWidth) { @@ -134,7 +134,7 @@ const ImageLoader = { } }; image.src = uri; - requests.set(`${id}`, image); + requests.set(id, image); return id; }, prefetch(uri: string): Promise { From 636103cca4e6078c539ec5f485b84f926687ddd8 Mon Sep 17 00:00:00 2001 From: lkinasiewicz Date: Tue, 14 Jan 2025 10:13:32 +0100 Subject: [PATCH 5/6] [ImageLoader] Simplify getSize implementation Fixes possible infinite intervals --- .../react-native-web/src/modules/ImageLoader/index.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 6e98750eab..6ea7c8adac 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -87,31 +87,21 @@ const ImageLoader = { success: (width: number, height: number) => void, failure: () => void ) { - let complete = false; - const interval = setInterval(callback, 16); const requestId = ImageLoader.load(uri, callback, errorCallback); - function callback() { const image = requests.get(requestId); if (image) { const { naturalHeight, naturalWidth } = image; if (naturalHeight && naturalWidth) { success(naturalWidth, naturalHeight); - complete = true; } } - if (complete) { - ImageLoader.abort(requestId); - clearInterval(interval); - } } - function errorCallback() { if (typeof failure === 'function') { failure(); } ImageLoader.abort(requestId); - clearInterval(interval); } }, has(uri: string): boolean { From ebe25766c4006a7052adcf87b2f04d3143e1882e Mon Sep 17 00:00:00 2001 From: lkinasiewicz Date: Tue, 17 Feb 2026 13:30:47 +0100 Subject: [PATCH 6/6] [ImageLoader] Fix memory leak in "load" function Fixes #2804 --- .../src/modules/ImageLoader/index.js | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 6ea7c8adac..6ce9705881 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -87,7 +87,7 @@ const ImageLoader = { success: (width: number, height: number) => void, failure: () => void ) { - const requestId = ImageLoader.load(uri, callback, errorCallback); + const requestId = ImageLoader.load(uri, callback, failure); function callback() { const image = requests.get(requestId); if (image) { @@ -97,23 +97,24 @@ const ImageLoader = { } } } - function errorCallback() { - if (typeof failure === 'function') { - failure(); - } - ImageLoader.abort(requestId); - } }, has(uri: string): boolean { return ImageUriCache.has(uri); }, load(uri: string, onLoad: Function, onError: Function): number { id += 1; + const requestId = id; const image = new window.Image(); - image.onerror = onError; + image.onerror = () => { + onError(); + ImageLoader.abort(requestId); + }; image.onload = (e) => { // avoid blocking the main thread - const onDecode = () => onLoad({ nativeEvent: e }); + const onDecode = () => { + onLoad({ nativeEvent: e }); + ImageLoader.abort(requestId); + }; if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -124,8 +125,8 @@ const ImageLoader = { } }; image.src = uri; - requests.set(id, image); - return id; + requests.set(requestId, image); + return requestId; }, prefetch(uri: string): Promise { return new Promise((resolve, reject) => {