From 71e3287220a8280d90221758ecb4a0866e812f22 Mon Sep 17 00:00:00 2001 From: Abdullah Ansari Date: Fri, 28 Aug 2026 01:39:44 +0500 Subject: [PATCH] Image: do not restart loading when load callback identities change The image-loading effect listed onError, onLoad, onLoadEnd, and onLoadStart in its dependency array. Passing inline handlers - new function identities on every parent render - made the effect re-run for an unchanged uri: the cleanup aborted the in-flight request, state was reset to LOADING, onLoadStart fired again, and the load restarted. A handler such as onLoadEnd={() => setLoading(false)} triggers a re-render and can loop this indefinitely, with the element never settling even though the browser has the image fully decoded. Read the handlers through a ref that is refreshed on every render, so the effect depends only on the uri while the handlers invoked are always the ones from the most recent render. Native Image is unaffected by handler identity, so this also aligns web behavior with native. --- .../src/exports/Image/__tests__/index-test.js | 68 +++++++++++++++++++ .../src/exports/Image/index.js | 20 +++++- 2 files changed, 87 insertions(+), 1 deletion(-) 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..fe7779c3a5 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 @@ -401,4 +401,72 @@ describe('components/Image', () => { }); }); }); + + describe('load event handler identity', () => { + test('load is not restarted on update if only the handler identities change', () => { + const originalAbort = ImageLoader.abort; + ImageLoader.load = jest.fn(); + ImageLoader.abort = jest.fn(); + const firstStubs = { + onLoad: jest.fn(), + onLoadEnd: jest.fn(), + onLoadStart: jest.fn() + }; + const secondStubs = { + onLoad: jest.fn(), + onLoadEnd: jest.fn(), + onLoadStart: jest.fn() + }; + const { rerender } = render( + + ); + act(() => { + rerender( + + ); + }); + expect(ImageLoader.load.mock.calls.length).toBe(1); + expect(ImageLoader.abort).not.toHaveBeenCalled(); + expect(firstStubs.onLoadStart.mock.calls.length).toBe(1); + expect(secondStubs.onLoadStart).not.toHaveBeenCalled(); + ImageLoader.abort = originalAbort; + ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { + onLoad(); + }); + }); + + test('most recent handlers are called when a load settles after an update', () => { + let resolveLoad; + ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { + resolveLoad = onLoad; + }); + const firstStubs = { + onLoad: jest.fn(), + onLoadEnd: jest.fn(), + onLoadStart: jest.fn() + }; + const secondStubs = { + onLoad: jest.fn(), + onLoadEnd: jest.fn(), + onLoadStart: jest.fn() + }; + const { rerender } = render( + + ); + act(() => { + rerender( + + ); + }); + act(() => { + resolveLoad(); + }); + expect(firstStubs.onLoad).not.toHaveBeenCalled(); + expect(secondStubs.onLoad.mock.calls.length).toBe(1); + expect(secondStubs.onLoadEnd.mock.calls.length).toBe(1); + ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { + onLoad(); + }); + }); + }); }); diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index f99faa7ec3..88f429e385 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -274,12 +274,28 @@ const Image: React.AbstractComponent< } // Image loading + // The load event handlers are read through a ref so that changes to their + // identity (e.g., inline functions re-created by a parent render) do not + // re-run the effect, which would abort the in-flight request, reset the + // state to LOADING, and call 'onLoadStart' again for the same uri. The + // handlers invoked are always the ones from the most recent render. + const loadEventHandlersRef = React.useRef({ + onError, + onLoad, + onLoadEnd, + onLoadStart + }); + React.useEffect(() => { + loadEventHandlersRef.current = { onError, onLoad, onLoadEnd, onLoadStart }; + }); + const uri = resolveAssetUri(source); React.useEffect(() => { abortPendingRequest(); if (uri != null) { updateState(LOADING); + const { onLoadStart } = loadEventHandlersRef.current; if (onLoadStart) { onLoadStart(); } @@ -288,6 +304,7 @@ const Image: React.AbstractComponent< uri, function load(e) { updateState(LOADED); + const { onLoad, onLoadEnd } = loadEventHandlersRef.current; if (onLoad) { onLoad(e); } @@ -297,6 +314,7 @@ const Image: React.AbstractComponent< }, function error() { updateState(ERRORED); + const { onError, onLoadEnd } = loadEventHandlersRef.current; if (onError) { onError({ nativeEvent: { @@ -319,7 +337,7 @@ const Image: React.AbstractComponent< } return abortPendingRequest; - }, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); + }, [uri, requestRef, updateState]); return (