From 340bb1b922dfe3d192412140dbc699641de21e18 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Mon, 11 May 2026 03:56:59 -0700 Subject: [PATCH] Properly reject `loadBundleFromServer` on non-2xx HTTP status Summary: Changelog: [General][Fixed] Handle HTTP error responses when loading lazy bundles from Metro Differential Revision: D104423888 --- .../__tests__/loadBundleFromServer-test.js | 26 +++++++++++- .../Core/Devtools/loadBundleFromServer.js | 40 +++++++++++++++---- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js b/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js index 7657099fd2e1..473c876862b5 100644 --- a/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js +++ b/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js @@ -65,7 +65,9 @@ const addListener = jest.fn((name, fn) => { } else if (name === 'didCompleteNetworkResponse') { void Promise.resolve().then(() => fn([REQUEST_ID, mockRequestError])); } else if (name === 'didReceiveNetworkResponse') { - void Promise.resolve().then(() => fn([REQUEST_ID, null, mockHeaders])); + void Promise.resolve().then(() => + fn([REQUEST_ID, mockStatus, mockHeaders]), + ); } return {remove: removeListener}; }); @@ -83,10 +85,12 @@ let loadBundleFromServer: (bundlePathAndQuery: string) => Promise; let mockHeaders: {'Content-Type': string}; let mockDataResponse; let mockRequestError = null; +let mockStatus: number = 200; beforeEach(() => { jest.resetModules(); jest.clearAllMocks(); + mockStatus = 200; loadBundleFromServer = require('../loadBundleFromServer').default; }); @@ -222,6 +226,26 @@ test('shows and hides the loading view around concurrent requests', async () => expect(loadingViewMock.hide).toHaveBeenCalledTimes(1); }); +test.each([404, 500])( + 'loadBundleFromServer will throw for HTTP %d status', + async (status: number) => { + mockHeaders = {'Content-Type': 'application/javascript'}; + mockDataResponse = 'Error response body'; + mockRequestError = null; + mockStatus = status; + + const error: unknown = await loadBundleFromServer( + '/Fail.bundle?platform=ios', + ).catch(e => e); + + invariant( + error instanceof LoadBundleFromServerRequestError, + 'Expected a LoadBundleFromServerRequestError', + ); + expect(error.message).toMatch(new RegExp(`HTTP ${status}`)); + }, +); + test('loadBundleFromServer does not cache errors', async () => { mockHeaders = {'Content-Type': 'application/json'}; mockDataResponse = JSON.stringify({message: 'Error thrown from Metro'}); diff --git a/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js b/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js index eb5555d60f7a..c45a783883d3 100644 --- a/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js +++ b/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js @@ -60,7 +60,10 @@ function asyncRequest( ): Promise<{body: string, headers: {[string]: string}}> { let id = null; let responseText = null; - let headers = null; + let head: ?{ + status: number, + headers: ?{[string]: string}, + } = null; let dataListener; let completeListener; let responseListener; @@ -91,7 +94,7 @@ function asyncRequest( 'didReceiveNetworkResponse', ([requestId, status, responseHeaders]) => { if (requestId === id) { - headers = responseHeaders; + head = {status, headers: responseHeaders}; } }, ); @@ -99,20 +102,43 @@ function asyncRequest( 'didCompleteNetworkResponse', ([requestId, errorMessage, isTimeout]) => { if (requestId === id) { - if (errorMessage) { + if ( + errorMessage != null || + isTimeout || + responseText == null || + head == null || + head.status < 200 || + head.status >= 300 + ) { + let summary = null, + cause: ?string = null; + if (errorMessage != null || isTimeout) { + summary = isTimeout + ? 'Request timed out' + : 'Could not load bundle'; + cause = errorMessage; + } else if (responseText == null || head == null) { + summary = 'Request completed with missing response data'; + } + if (head && (head.status < 200 || head.status >= 300)) { + const {status} = head; + summary = [`HTTP ${status} when loading bundle ${url}`, summary] + .filter(Boolean) + .join('; '); + cause = cause ?? responseText; + } reject( new LoadBundleFromServerRequestError( - 'Could not load bundle', + summary ?? `Unknown error when loading bundle ${url}`, url, isTimeout, { - cause: errorMessage, + cause, }, ), ); } else { - //$FlowFixMe[incompatible-type] - resolve({body: responseText, headers}); + resolve({body: responseText, headers: head.headers ?? {}}); } } },