Skip to content

Commit

Permalink
Inject __loadBundleAsync global in development (#36808)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: #36808

Changelog: [General][Added] [1/n] Support lazy bundling in development

Implements part of the [lazy bundling RFC](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0605-lazy-bundling.md#__loadbundleasync-in-react-native):

> In development builds, React Native will provide an implementation of `__loadBundleAsync` that fetches a bundle URL from the currently connected Metro server, integrates with Fast Refresh and LogBox, and provides feedback to the developer on the progress of loading a bundle.

NOTE: This implementation of `__loadBundleAsync` is currently unused in OSS, pending a handful of changes to Metro and React Native that have not landed yet. The plan is to land everything in time for React Native 0.73.

Reviewed By: robhogan

Differential Revision: D43600052

fbshipit-source-id: 9342d5894eaf4b90b30598148bd55b0c956a8b08
  • Loading branch information
motiz88 authored and facebook-github-bot committed Apr 12, 2023
1 parent 846ec6d commit 799b0f4
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<void>;

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);
});
124 changes: 124 additions & 0 deletions packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js
Original file line number Diff line number Diff line change
@@ -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<void> {
const requestUrl = buildUrlForBundle(bundlePathAndQuery);

LoadingView.showMessage('Downloading...', 'load');
++pendingRequests;
return asyncRequest(requestUrl)
.then<void>(({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();
}
});
};
4 changes: 4 additions & 0 deletions packages/react-native/Libraries/Core/setUpDeveloperTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,8 @@ if (__DEV__) {
}

require('./setUpReactRefresh');

global[
`${global.__METRO_GLOBAL_PREFIX__ ?? ''}__loadBundleAsync`
] = require('./Devtools/loadBundleFromServer');
}

0 comments on commit 799b0f4

Please sign in to comment.