diff --git a/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js b/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js new file mode 100644 index 00000000000000..42f986e1807b55 --- /dev/null +++ b/packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js @@ -0,0 +1,172 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +jest.mock('react-native/Libraries/Utilities/HMRClient'); + +jest.mock('react-native/Libraries/Core/Devtools/getDevServer', () => + jest.fn(() => ({ + url: 'localhost:8042/', + fullBundleUrl: + 'http://localhost:8042/EntryPoint.bundle?platform=' + + jest.requireActual<$FlowFixMe>('react-native').Platform.OS + + '&dev=true&minify=false&unusedExtraParam=42', + bundleLoadedFromServer: true, + })), +); + +const loadingViewMock = { + showMessage: jest.fn(), + hide: jest.fn(), +}; +jest.mock( + 'react-native/Libraries/Utilities/LoadingView', + () => loadingViewMock, +); + +const sendRequest = jest.fn( + ( + method, + trackingName, + url, + headers, + data, + responseType, + incrementalUpdates, + timeout, + callback, + withCredentials, + ) => { + callback(1); + }, +); + +jest.mock('react-native/Libraries/Network/RCTNetworking', () => ({ + __esModule: true, + default: { + sendRequest, + addListener: jest.fn((name, fn) => { + if (name === 'didReceiveNetworkData') { + setImmediate(() => fn([1, mockDataResponse])); + } else if (name === 'didCompleteNetworkResponse') { + setImmediate(() => fn([1, null])); + } else if (name === 'didReceiveNetworkResponse') { + setImmediate(() => fn([1, null, mockHeaders])); + } + return {remove: () => {}}; + }), + }, +})); + +let loadBundleFromServer: (bundlePathAndQuery: string) => Promise; + +let mockHeaders: {'Content-Type': string}; +let mockDataResponse; + +beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + loadBundleFromServer = require('../loadBundleFromServer'); +}); + +test('loadBundleFromServer will throw for JSON responses', async () => { + mockHeaders = {'Content-Type': 'application/json'}; + mockDataResponse = JSON.stringify({message: 'Error thrown from Metro'}); + + await expect( + loadBundleFromServer('/Fail.bundle?platform=ios'), + ).rejects.toThrow('Error thrown from Metro'); +}); + +test('loadBundleFromServer will request a bundle if import bundles are available', async () => { + mockDataResponse = '"code";'; + mockHeaders = {'Content-Type': 'application/javascript'}; + + await loadBundleFromServer( + '/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + ); + + expect(sendRequest.mock.calls).toEqual([ + [ + 'GET', + expect.anything(), + 'localhost:8042/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ], + ]); + + sendRequest.mockClear(); + await loadBundleFromServer( + '/Tiny/Apple.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + ); + + expect(sendRequest.mock.calls).toEqual([ + [ + 'GET', + expect.anything(), + 'localhost:8042/Tiny/Apple.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ], + ]); +}); + +test('shows and hides the loading view around a request', async () => { + mockDataResponse = '"code";'; + mockHeaders = {'Content-Type': 'application/javascript'}; + + const promise = loadBundleFromServer( + '/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + ); + + expect(loadingViewMock.showMessage).toHaveBeenCalledTimes(1); + expect(loadingViewMock.hide).not.toHaveBeenCalled(); + loadingViewMock.showMessage.mockClear(); + loadingViewMock.hide.mockClear(); + + await promise; + + expect(loadingViewMock.showMessage).not.toHaveBeenCalled(); + expect(loadingViewMock.hide).toHaveBeenCalledTimes(1); +}); + +test('shows and hides the loading view around concurrent requests', async () => { + mockDataResponse = '"code";'; + mockHeaders = {'Content-Type': 'application/javascript'}; + + const promise1 = loadBundleFromServer( + '/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + ); + const promise2 = loadBundleFromServer( + '/Apple.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false', + ); + + expect(loadingViewMock.showMessage).toHaveBeenCalledTimes(2); + expect(loadingViewMock.hide).not.toHaveBeenCalled(); + loadingViewMock.showMessage.mockClear(); + loadingViewMock.hide.mockClear(); + + await promise1; + await promise2; + expect(loadingViewMock.hide).toHaveBeenCalledTimes(1); +}); diff --git a/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js b/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js new file mode 100644 index 00000000000000..443b316fd33d65 --- /dev/null +++ b/packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js @@ -0,0 +1,124 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import Networking from '../../Network/RCTNetworking'; +import HMRClient from '../../Utilities/HMRClient'; +import LoadingView from '../../Utilities/LoadingView'; +import getDevServer from './getDevServer'; + +declare var global: {globalEvalWithSourceUrl?: (string, string) => mixed, ...}; + +let pendingRequests = 0; + +function asyncRequest( + url: string, +): Promise<{body: string, headers: {[string]: string}}> { + let id = null; + let responseText = null; + let headers = null; + let dataListener; + let completeListener; + let responseListener; + return new Promise<{body: string, headers: {[string]: string}}>( + (resolve, reject) => { + dataListener = Networking.addListener( + 'didReceiveNetworkData', + ([requestId, response]) => { + if (requestId === id) { + responseText = response; + } + }, + ); + responseListener = Networking.addListener( + 'didReceiveNetworkResponse', + ([requestId, status, responseHeaders]) => { + if (requestId === id) { + headers = responseHeaders; + } + }, + ); + completeListener = Networking.addListener( + 'didCompleteNetworkResponse', + ([requestId, error]) => { + if (requestId === id) { + if (error) { + reject(error); + } else { + //$FlowFixMe[incompatible-call] + resolve({body: responseText, headers}); + } + } + }, + ); + Networking.sendRequest( + 'GET', + 'asyncRequest', + url, + {}, + '', + 'text', + false, + 0, + requestId => { + id = requestId; + }, + true, + ); + }, + ).finally(() => { + dataListener && dataListener.remove(); + completeListener && completeListener.remove(); + responseListener && responseListener.remove(); + }); +} + +function buildUrlForBundle(bundlePathAndQuery: string) { + const {url: serverUrl} = getDevServer(); + return ( + serverUrl.replace(/\/+$/, '') + '/' + bundlePathAndQuery.replace(/^\/+/, '') + ); +} + +module.exports = function (bundlePathAndQuery: string): Promise { + const requestUrl = buildUrlForBundle(bundlePathAndQuery); + + LoadingView.showMessage('Downloading...', 'load'); + ++pendingRequests; + return asyncRequest(requestUrl) + .then(({body, headers}) => { + if ( + headers['Content-Type'] != null && + headers['Content-Type'].indexOf('application/json') >= 0 + ) { + // Errors are returned as JSON. + throw new Error( + JSON.parse(body).message || + `Unknown error fetching '${bundlePathAndQuery}'`, + ); + } + + HMRClient.registerBundle(requestUrl); + + // Some engines do not support `sourceURL` as a comment. We expose a + // `globalEvalWithSourceUrl` function to handle updates in that case. + if (global.globalEvalWithSourceUrl) { + global.globalEvalWithSourceUrl(body, requestUrl); + } else { + // eslint-disable-next-line no-eval + eval(body); + } + }) + .finally(() => { + if (!--pendingRequests) { + LoadingView.hide(); + } + }); +}; diff --git a/packages/react-native/Libraries/Core/setUpDeveloperTools.js b/packages/react-native/Libraries/Core/setUpDeveloperTools.js index 4247d3bd9474ab..c964af738112f6 100644 --- a/packages/react-native/Libraries/Core/setUpDeveloperTools.js +++ b/packages/react-native/Libraries/Core/setUpDeveloperTools.js @@ -74,4 +74,8 @@ if (__DEV__) { } require('./setUpReactRefresh'); + + global[ + `${global.__METRO_GLOBAL_PREFIX__ ?? ''}__loadBundleAsync` + ] = require('./Devtools/loadBundleFromServer'); }