Skip to content

Commit 0750b5f

Browse files
vzaidmanfacebook-github-bot
authored andcommitted
Improve the error thrown when a bundle fails to load (#51070)
Summary: Pull Request resolved: #51070 Changelog: [General][Internal][Breaking] When a bundle is failing to load throw an error of special type. ### Breaking `didCompleteNetworkResponse` now throws an Error instead of throwing a string. In dev, we use [lazy bundling](https://github.com/react-native-community/discussions-and-proposals/blob/main/proposals/0605-lazy-bundling.md#__loadbundleasync-in-metro) which allows us to re-build only the parts of the app that are changed. However the function that loads these lazy bundles in `loadBundleFromServer.js` for requests that reach `didCompleteNetworkResponse` with an error **throws a string representing the error message** which makes debugging very inconvenient because it lacks stack trace and error type. Throwing strings is not a standard practice. We better throw an error in these cases so it can be handled better. Why a special type of error? * As you can see in the "after", in the test plan below, even with a stack trace, it might be hard to know where this error originates from * Also so extra information can be added on it. Currently adding "url" * Also to support error handling based on the type of error Reviewed By: hoxyq Differential Revision: D73925027 fbshipit-source-id: a0f98d283ec8842696f1b87864fb63cb30c0c028
1 parent 54499d8 commit 0750b5f

File tree

3 files changed

+104
-11
lines changed

3 files changed

+104
-11
lines changed

packages/react-native/Libraries/Core/Devtools/__tests__/loadBundleFromServer-test.js

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ jest.mock('../../../Network/RCTNetworking', () => ({
6262
if (name === 'didReceiveNetworkData') {
6363
setImmediate(() => fn([1, mockDataResponse]));
6464
} else if (name === 'didCompleteNetworkResponse') {
65-
setImmediate(() => fn([1, null]));
65+
setImmediate(() => fn([1, mockRequestError]));
6666
} else if (name === 'didReceiveNetworkResponse') {
6767
setImmediate(() => fn([1, null, mockHeaders]));
6868
}
@@ -72,9 +72,14 @@ jest.mock('../../../Network/RCTNetworking', () => ({
7272
}));
7373

7474
let loadBundleFromServer: (bundlePathAndQuery: string) => Promise<void>;
75+
const {
76+
LoadBundleFromServerError,
77+
LoadBundleFromServerRequestError,
78+
} = require('../loadBundleFromServer');
7579

7680
let mockHeaders: {'Content-Type': string};
7781
let mockDataResponse;
82+
let mockRequestError = null;
7883

7984
beforeEach(() => {
8085
jest.resetModules();
@@ -85,15 +90,35 @@ beforeEach(() => {
8590
test('loadBundleFromServer will throw for JSON responses', async () => {
8691
mockHeaders = {'Content-Type': 'application/json'};
8792
mockDataResponse = JSON.stringify({message: 'Error thrown from Metro'});
93+
mockRequestError = null;
94+
95+
try {
96+
await loadBundleFromServer('/Fail.bundle?platform=ios');
97+
} catch (e) {
98+
expect(e).toBeInstanceOf(LoadBundleFromServerError);
99+
expect(e.message).toBe('Could not load bundle');
100+
expect(e.cause).toBe('Error thrown from Metro');
101+
}
102+
});
88103

89-
await expect(
90-
loadBundleFromServer('/Fail.bundle?platform=ios'),
91-
).rejects.toThrow('Error thrown from Metro');
104+
test('loadBundleFromServer will throw LoadBundleFromServerError for request errors', async () => {
105+
mockHeaders = {'Content-Type': 'application/json'};
106+
mockDataResponse = '';
107+
mockRequestError = 'Some error';
108+
109+
try {
110+
await loadBundleFromServer('/Fail.bundle?platform=ios');
111+
} catch (e) {
112+
expect(e).toBeInstanceOf(LoadBundleFromServerRequestError);
113+
expect(e.message).toBe('Could not load bundle');
114+
expect(e.cause).toBe('Some error');
115+
}
92116
});
93117

94118
test('loadBundleFromServer will request a bundle if import bundles are available', async () => {
95119
mockDataResponse = '"code";';
96120
mockHeaders = {'Content-Type': 'application/javascript'};
121+
mockRequestError = null;
97122

98123
await loadBundleFromServer(
99124
'/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false',
@@ -138,6 +163,7 @@ test('loadBundleFromServer will request a bundle if import bundles are available
138163
test('shows and hides the loading view around a request', async () => {
139164
mockDataResponse = '"code";';
140165
mockHeaders = {'Content-Type': 'application/javascript'};
166+
mockRequestError = null;
141167

142168
const promise = loadBundleFromServer(
143169
'/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false',
@@ -157,6 +183,7 @@ test('shows and hides the loading view around a request', async () => {
157183
test('shows and hides the loading view around concurrent requests', async () => {
158184
mockDataResponse = '"code";';
159185
mockHeaders = {'Content-Type': 'application/javascript'};
186+
mockRequestError = null;
160187

161188
const promise1 = loadBundleFromServer(
162189
'/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false',
@@ -178,13 +205,15 @@ test('shows and hides the loading view around concurrent requests', async () =>
178205
test('loadBundleFromServer does not cache errors', async () => {
179206
mockHeaders = {'Content-Type': 'application/json'};
180207
mockDataResponse = JSON.stringify({message: 'Error thrown from Metro'});
208+
mockRequestError = null;
181209

182210
await expect(
183211
loadBundleFromServer('/Fail.bundle?platform=ios'),
184212
).rejects.toThrow();
185213

186214
mockDataResponse = '"code";';
187215
mockHeaders = {'Content-Type': 'application/javascript'};
216+
mockRequestError = null;
188217

189218
await expect(
190219
loadBundleFromServer('/Fail.bundle?platform=ios'),
@@ -194,6 +223,7 @@ test('loadBundleFromServer does not cache errors', async () => {
194223
test('loadBundleFromServer caches successful fetches', async () => {
195224
mockDataResponse = '"code";';
196225
mockHeaders = {'Content-Type': 'application/javascript'};
226+
mockRequestError = null;
197227

198228
const promise1 = loadBundleFromServer(
199229
'/Banana.bundle?platform=ios&dev=true&minify=false&unusedExtraParam=42&modulesOnly=true&runModule=false',

packages/react-native/Libraries/Core/Devtools/loadBundleFromServer.js

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,34 @@ let pendingRequests = 0;
2020

2121
const cachedPromisesByUrl = new Map<string, Promise<void>>();
2222

23+
export class LoadBundleFromServerError extends Error {
24+
url: string;
25+
isTimeout: boolean;
26+
constructor(
27+
message: string,
28+
url: string,
29+
isTimeout: boolean,
30+
options?: {cause: mixed, ...},
31+
): void {
32+
super(message, options);
33+
this.url = url;
34+
this.isTimeout = isTimeout;
35+
this.name = 'LoadBundleFromServerError';
36+
}
37+
}
38+
39+
export class LoadBundleFromServerRequestError extends LoadBundleFromServerError {
40+
constructor(
41+
message: string,
42+
url: string,
43+
isTimeout: boolean,
44+
options?: {cause: mixed, ...},
45+
): void {
46+
super(message, url, isTimeout, options);
47+
this.name = 'LoadBundleFromServerRequestError';
48+
}
49+
}
50+
2351
function asyncRequest(
2452
url: string,
2553
): Promise<{body: string, headers: {[string]: string}}> {
@@ -62,10 +90,19 @@ function asyncRequest(
6290
);
6391
completeListener = Networking.addListener(
6492
'didCompleteNetworkResponse',
65-
([requestId, error]) => {
93+
([requestId, errorMessage, isTimeout]) => {
6694
if (requestId === id) {
67-
if (error) {
68-
reject(error);
95+
if (errorMessage) {
96+
reject(
97+
new LoadBundleFromServerRequestError(
98+
'Could not load bundle',
99+
url,
100+
isTimeout,
101+
{
102+
cause: errorMessage,
103+
},
104+
),
105+
);
69106
} else {
70107
//$FlowFixMe[incompatible-call]
71108
resolve({body: responseText, headers});
@@ -122,9 +159,15 @@ export default function loadBundleFromServer(
122159
headers['Content-Type'].indexOf('application/json') >= 0
123160
) {
124161
// Errors are returned as JSON.
125-
throw new Error(
126-
JSON.parse(body).message ||
127-
`Unknown error fetching '${bundlePathAndQuery}'`,
162+
throw new LoadBundleFromServerError(
163+
'Could not load bundle',
164+
bundlePathAndQuery,
165+
false, // isTimeout
166+
{
167+
cause:
168+
JSON.parse(body).message ||
169+
`Unknown error fetching '${bundlePathAndQuery}'`,
170+
},
128171
);
129172
}
130173

packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3843,7 +3843,27 @@ declare export default function getDevServer(): DevServerInfo;
38433843
`;
38443844

38453845
exports[`public API should not change unintentionally Libraries/Core/Devtools/loadBundleFromServer.js 1`] = `
3846-
"declare export default function loadBundleFromServer(
3846+
"declare export class LoadBundleFromServerError extends Error {
3847+
url: string;
3848+
isTimeout: boolean;
3849+
constructor(
3850+
message: string,
3851+
url: string,
3852+
isTimeout: boolean,
3853+
options?: { cause: mixed, ... }
3854+
): void;
3855+
}
3856+
declare export class LoadBundleFromServerRequestError
3857+
extends LoadBundleFromServerError
3858+
{
3859+
constructor(
3860+
message: string,
3861+
url: string,
3862+
isTimeout: boolean,
3863+
options?: { cause: mixed, ... }
3864+
): void;
3865+
}
3866+
declare export default function loadBundleFromServer(
38473867
bundlePathAndQuery: string
38483868
): Promise<void>;
38493869
"

0 commit comments

Comments
 (0)