Skip to content

Commit 87a26cf

Browse files
authoredMar 12, 2023
feat: catch runtime error (#4605)
1 parent b0f15ac commit 87a26cf

12 files changed

+1634
-1237
lines changed
 

‎client-src/index.js

+22-13
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import webpackHotLog from "webpack/hot/log.js";
44
import stripAnsi from "./utils/stripAnsi.js";
55
import parseURL from "./utils/parseURL.js";
66
import socket from "./socket.js";
7-
import { formatProblem, show, hide } from "./overlay.js";
7+
import { formatProblem, createOverlay } from "./overlay.js";
88
import { log, logEnabledFeatures, setLogLevel } from "./utils/log.js";
99
import sendMessage from "./utils/sendMessage.js";
1010
import reloadApp from "./utils/reloadApp.js";
@@ -115,6 +115,13 @@ self.addEventListener("beforeunload", () => {
115115
status.isUnloading = true;
116116
});
117117

118+
const trustedTypesPolicyName =
119+
typeof options.overlay === "object" && options.overlay.trustedTypesPolicyName;
120+
121+
const overlay = createOverlay({
122+
trustedTypesPolicyName,
123+
});
124+
118125
const onSocketMessage = {
119126
hot() {
120127
if (parsedResourceQuery.hot === "false") {
@@ -135,7 +142,7 @@ const onSocketMessage = {
135142

136143
// Fixes #1042. overlay doesn't clear if errors are fixed but warnings remain.
137144
if (options.overlay) {
138-
hide();
145+
overlay.send({ type: "DISMISS" });
139146
}
140147

141148
sendMessage("Invalid");
@@ -192,7 +199,7 @@ const onSocketMessage = {
192199
log.info("Nothing changed.");
193200

194201
if (options.overlay) {
195-
hide();
202+
overlay.send({ type: "DISMISS" });
196203
}
197204

198205
sendMessage("StillOk");
@@ -201,7 +208,7 @@ const onSocketMessage = {
201208
sendMessage("Ok");
202209

203210
if (options.overlay) {
204-
hide();
211+
overlay.send({ type: "DISMISS" });
205212
}
206213

207214
reloadApp(options, status);
@@ -256,10 +263,11 @@ const onSocketMessage = {
256263
: options.overlay && options.overlay.warnings;
257264

258265
if (needShowOverlayForWarnings) {
259-
const trustedTypesPolicyName =
260-
typeof options.overlay === "object" &&
261-
options.overlay.trustedTypesPolicyName;
262-
show("warning", warnings, trustedTypesPolicyName || null);
266+
overlay.send({
267+
type: "BUILD_ERROR",
268+
level: "warning",
269+
messages: warnings,
270+
});
263271
}
264272

265273
if (params && params.preventReloading) {
@@ -292,10 +300,11 @@ const onSocketMessage = {
292300
: options.overlay && options.overlay.errors;
293301

294302
if (needShowOverlayForErrors) {
295-
const trustedTypesPolicyName =
296-
typeof options.overlay === "object" &&
297-
options.overlay.trustedTypesPolicyName;
298-
show("error", errors, trustedTypesPolicyName || null);
303+
overlay.send({
304+
type: "BUILD_ERROR",
305+
level: "error",
306+
messages: errors,
307+
});
299308
}
300309
},
301310
/**
@@ -308,7 +317,7 @@ const onSocketMessage = {
308317
log.info("Disconnected!");
309318

310319
if (options.overlay) {
311-
hide();
320+
overlay.send({ type: "DISMISS" });
312321
}
313322

314323
sendMessage("Close");

‎client-src/overlay.js

+221-159
Large diffs are not rendered by default.

‎client-src/overlay/fsm.js

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @typedef {Object} StateDefinitions
3+
* @property {{[event: string]: { target: string; actions?: Array<string> }}} [on]
4+
*/
5+
6+
/**
7+
* @typedef {Object} Options
8+
* @property {{[state: string]: StateDefinitions}} states
9+
* @property {object} context;
10+
* @property {string} initial
11+
*/
12+
13+
/**
14+
* @typedef {Object} Implementation
15+
* @property {{[actionName: string]: (ctx: object, event: any) => object}} actions
16+
*/
17+
18+
/**
19+
* A simplified `createMachine` from `@xstate/fsm` with the following differences:
20+
*
21+
* - the returned machine is technically a "service". No `interpret(machine).start()` is needed.
22+
* - the state definition only support `on` and target must be declared with { target: 'nextState', actions: [] } explicitly.
23+
* - event passed to `send` must be an object with `type` property.
24+
* - actions implementation will be [assign action](https://xstate.js.org/docs/guides/context.html#assign-action) if you return any value.
25+
* Do not return anything if you just want to invoke side effect.
26+
*
27+
* The goal of this custom function is to avoid installing the entire `'xstate/fsm'` package, while enabling modeling using
28+
* state machine. You can copy the first parameter into the editor at https://stately.ai/viz to visualize the state machine.
29+
*
30+
* @param {Options} options
31+
* @param {Implementation} implementation
32+
*/
33+
function createMachine({ states, context, initial }, { actions }) {
34+
let currentState = initial;
35+
let currentContext = context;
36+
37+
return {
38+
send: (event) => {
39+
const currentStateOn = states[currentState].on;
40+
const transitionConfig = currentStateOn && currentStateOn[event.type];
41+
42+
if (transitionConfig) {
43+
currentState = transitionConfig.target;
44+
if (transitionConfig.actions) {
45+
transitionConfig.actions.forEach((actName) => {
46+
const actionImpl = actions[actName];
47+
48+
const nextContextValue =
49+
actionImpl && actionImpl(currentContext, event);
50+
51+
if (nextContextValue) {
52+
currentContext = {
53+
...currentContext,
54+
...nextContextValue,
55+
};
56+
}
57+
});
58+
}
59+
}
60+
},
61+
};
62+
}
63+
64+
export default createMachine;

‎client-src/overlay/runtime-error.js

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
*
3+
* @param {Error} error
4+
*/
5+
function parseErrorToStacks(error) {
6+
if (!error || !(error instanceof Error)) {
7+
throw new Error(`parseErrorToStacks expects Error object`);
8+
}
9+
if (typeof error.stack === "string") {
10+
return error.stack
11+
.split("\n")
12+
.filter((stack) => stack !== `Error: ${error.message}`);
13+
}
14+
}
15+
16+
/**
17+
* @callback ErrorCallback
18+
* @param {ErrorEvent} error
19+
* @returns {void}
20+
*/
21+
22+
/**
23+
* @param {ErrorCallback} callback
24+
*/
25+
function listenToRuntimeError(callback) {
26+
window.addEventListener("error", callback);
27+
28+
return function cleanup() {
29+
window.removeEventListener("error", callback);
30+
};
31+
}
32+
33+
export { listenToRuntimeError, parseErrorToStacks };

‎client-src/overlay/state-machine.js

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import createMachine from "./fsm.js";
2+
3+
/**
4+
* @typedef {Object} ShowOverlayData
5+
* @property {'warning' | 'error'} level
6+
* @property {Array<string | { moduleIdentifier?: string, moduleName?: string, loc?: string, message?: string }>} messages
7+
*/
8+
9+
/**
10+
* @typedef {Object} CreateOverlayMachineOptions
11+
* @property {(data: ShowOverlayData) => void} showOverlay
12+
* @property {() => void} hideOverlay
13+
*/
14+
15+
/**
16+
* @param {CreateOverlayMachineOptions} options
17+
*/
18+
const createOverlayMachine = (options) => {
19+
const { hideOverlay, showOverlay } = options;
20+
const overlayMachine = createMachine(
21+
{
22+
initial: "hidden",
23+
context: {
24+
level: "error",
25+
messages: [],
26+
},
27+
states: {
28+
hidden: {
29+
on: {
30+
BUILD_ERROR: {
31+
target: "displayBuildError",
32+
actions: ["setMessages", "showOverlay"],
33+
},
34+
RUNTIME_ERROR: {
35+
target: "displayRuntimeError",
36+
actions: ["setMessages", "showOverlay"],
37+
},
38+
},
39+
},
40+
displayBuildError: {
41+
on: {
42+
DISMISS: {
43+
target: "hidden",
44+
actions: ["dismissMessages", "hideOverlay"],
45+
},
46+
BUILD_ERROR: {
47+
target: "displayBuildError",
48+
actions: ["appendMessages", "showOverlay"],
49+
},
50+
},
51+
},
52+
displayRuntimeError: {
53+
on: {
54+
DISMISS: {
55+
target: "hidden",
56+
actions: ["dismissMessages", "hideOverlay"],
57+
},
58+
RUNTIME_ERROR: {
59+
target: "displayRuntimeError",
60+
actions: ["appendMessages", "showOverlay"],
61+
},
62+
BUILD_ERROR: {
63+
target: "displayBuildError",
64+
actions: ["setMessages", "showOverlay"],
65+
},
66+
},
67+
},
68+
},
69+
},
70+
{
71+
actions: {
72+
dismissMessages: () => {
73+
return {
74+
messages: [],
75+
level: "error",
76+
};
77+
},
78+
appendMessages: (context, event) => {
79+
return {
80+
messages: context.messages.concat(event.messages),
81+
level: event.level || context.level,
82+
};
83+
},
84+
setMessages: (context, event) => {
85+
return {
86+
messages: event.messages,
87+
level: event.level || context.level,
88+
};
89+
},
90+
hideOverlay,
91+
showOverlay,
92+
},
93+
}
94+
);
95+
96+
return overlayMachine;
97+
};
98+
99+
export default createOverlayMachine;

‎examples/client/overlay/app.js

+5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"use strict";
22

3+
// eslint-disable-next-line import/order
4+
const createErrorBtn = require("./error-button");
5+
36
const target = document.querySelector("#target");
47

8+
target.insertAdjacentElement("afterend", createErrorBtn());
9+
510
// eslint-disable-next-line import/no-unresolved, import/extensions
611
const invalid = require("./invalid.js");
712

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use strict";
2+
3+
function unsafeOperation() {
4+
throw new Error("Error message thrown from JS");
5+
}
6+
7+
function handleButtonClick() {
8+
unsafeOperation();
9+
}
10+
11+
module.exports = function createErrorButton() {
12+
const errorBtn = document.createElement("button");
13+
14+
errorBtn.addEventListener("click", handleButtonClick);
15+
errorBtn.innerHTML = "Click to throw error";
16+
17+
return errorBtn;
18+
};

‎examples/client/overlay/webpack.config.js

+2
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ module.exports = setup({
1313
overlay: true,
1414
},
1515
},
16+
// uncomment to test for IE
17+
// target: ["web", "es5"],
1618
});

‎test/client/index.test.js

+47-16
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,21 @@ describe("index", () => {
3535
jest.setMock("../../client-src/socket.js", jest.fn());
3636
socket = require("../../client-src/socket");
3737

38+
const send = jest.fn();
39+
3840
// overlay
3941
jest.setMock("../../client-src/overlay.js", {
40-
hide: jest.fn(),
41-
show: jest.fn(),
42+
createOverlay: () => {
43+
return {
44+
send,
45+
};
46+
},
4247
formatProblem: (item) => {
4348
return { header: "HEADER warning", body: `BODY: ${item}` };
4449
},
4550
});
46-
overlay = require("../../client-src/overlay");
51+
const { createOverlay } = require("../../client-src/overlay");
52+
overlay = createOverlay();
4753

4854
// reloadApp
4955
jest.setMock("../../client-src/utils/reloadApp.js", jest.fn());
@@ -89,13 +95,13 @@ describe("index", () => {
8995

9096
expect(log.log.info.mock.calls[0][0]).toMatchSnapshot();
9197
expect(sendMessage.mock.calls[0][0]).toMatchSnapshot();
92-
expect(overlay.hide).not.toBeCalled();
98+
expect(overlay.send).not.toBeCalledWith({ type: "DISMISS" });
9399

94100
// change flags
95101
onSocketMessage.overlay(true);
96102
onSocketMessage["still-ok"]();
97103

98-
expect(overlay.hide).toBeCalled();
104+
expect(overlay.send).toHaveBeenCalledWith({ type: "DISMISS" });
99105
});
100106

101107
test("should run onSocketMessage.progress and onSocketMessage['progress-update']", () => {
@@ -191,9 +197,14 @@ describe("index", () => {
191197

192198
// change flags
193199
onSocketMessage.overlay({ warnings: true });
194-
onSocketMessage.warnings([]);
200+
onSocketMessage.warnings(["warning message"]);
195201

196-
expect(overlay.show).toBeCalled();
202+
expect(overlay.send).toHaveBeenCalledTimes(1);
203+
expect(overlay.send).toHaveBeenCalledWith({
204+
type: "BUILD_ERROR",
205+
level: "warning",
206+
messages: ["warning message"],
207+
});
197208
});
198209

199210
test("should parse overlay options from resource query", () => {
@@ -202,51 +213,71 @@ describe("index", () => {
202213
global.__resourceQuery = `?overlay=${encodeURIComponent(
203214
`{"warnings": false}`
204215
)}`;
205-
overlay.show.mockReset();
216+
overlay.send.mockReset();
206217
socket.mockReset();
207218
jest.unmock("../../client-src/utils/parseURL.js");
208219
require("../../client-src");
209220
onSocketMessage = socket.mock.calls[0][1];
210221

211222
onSocketMessage.warnings(["warn1"]);
212-
expect(overlay.show).not.toBeCalled();
223+
expect(overlay.send).not.toBeCalled();
213224

214225
onSocketMessage.errors(["error1"]);
215-
expect(overlay.show).toBeCalledTimes(1);
226+
expect(overlay.send).toBeCalledTimes(1);
227+
expect(overlay.send).toHaveBeenCalledWith({
228+
type: "BUILD_ERROR",
229+
level: "error",
230+
messages: ["error1"],
231+
});
216232
});
217233

218234
jest.isolateModules(() => {
219235
// Pass JSON config with errors disabled
220236
global.__resourceQuery = `?overlay=${encodeURIComponent(
221237
`{"errors": false}`
222238
)}`;
223-
overlay.show.mockReset();
239+
overlay.send.mockReset();
224240
socket.mockReset();
225241
jest.unmock("../../client-src/utils/parseURL.js");
226242
require("../../client-src");
227243
onSocketMessage = socket.mock.calls[0][1];
228244

229245
onSocketMessage.errors(["error1"]);
230-
expect(overlay.show).not.toBeCalled();
246+
expect(overlay.send).not.toBeCalled();
231247

232248
onSocketMessage.warnings(["warn1"]);
233-
expect(overlay.show).toBeCalledTimes(1);
249+
expect(overlay.send).toBeCalledTimes(1);
250+
expect(overlay.send).toHaveBeenCalledWith({
251+
type: "BUILD_ERROR",
252+
level: "warning",
253+
messages: ["warn1"],
254+
});
234255
});
235256

236257
jest.isolateModules(() => {
237258
// Use simple boolean
238259
global.__resourceQuery = "?overlay=true";
239260
jest.unmock("../../client-src/utils/parseURL.js");
240261
socket.mockReset();
241-
overlay.show.mockReset();
262+
overlay.send.mockReset();
242263
require("../../client-src");
243264
onSocketMessage = socket.mock.calls[0][1];
244265

245266
onSocketMessage.warnings(["warn2"]);
246-
expect(overlay.show).toBeCalledTimes(1);
267+
expect(overlay.send).toBeCalledTimes(1);
268+
expect(overlay.send).toHaveBeenLastCalledWith({
269+
type: "BUILD_ERROR",
270+
level: "warning",
271+
messages: ["warn2"],
272+
});
247273

248274
onSocketMessage.errors(["error2"]);
249-
expect(overlay.show).toBeCalledTimes(2);
275+
expect(overlay.send).toBeCalledTimes(2);
276+
expect(overlay.send).toHaveBeenLastCalledWith({
277+
type: "BUILD_ERROR",
278+
level: "error",
279+
messages: ["error2"],
280+
});
250281
});
251282
});
252283

‎test/e2e/__snapshots__/multi-compiler.test.js.snap.webpack5

+6-6
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,13 @@ Array [
5151
"[HMR] Cannot apply update. Need to do a full reload!",
5252
"[HMR] Error: Aborted because ./browser.js is not accepted
5353
Update propagation: ./browser.js
54-
at applyHandler (http://127.0.0.1:8103/browser.js:1044:31)
55-
at http://127.0.0.1:8103/browser.js:743:21
54+
at applyHandler (http://127.0.0.1:8103/browser.js:1077:31)
55+
at http://127.0.0.1:8103/browser.js:776:21
5656
at Array.map (<anonymous>)
57-
at internalApply (http://127.0.0.1:8103/browser.js:742:54)
58-
at http://127.0.0.1:8103/browser.js:712:26
59-
at waitForBlockingPromises (http://127.0.0.1:8103/browser.js:666:48)
60-
at http://127.0.0.1:8103/browser.js:710:24",
57+
at internalApply (http://127.0.0.1:8103/browser.js:775:54)
58+
at http://127.0.0.1:8103/browser.js:745:26
59+
at waitForBlockingPromises (http://127.0.0.1:8103/browser.js:699:48)
60+
at http://127.0.0.1:8103/browser.js:743:24",
6161
"[webpack-dev-server] Server started: Hot Module Replacement enabled, Live Reloading disabled, Progress disabled, Overlay enabled.",
6262
"[HMR] Waiting for update signal from WDS...",
6363
"Hello from the browser",

‎test/e2e/__snapshots__/overlay.test.js.snap.webpack4

+543-507
Large diffs are not rendered by default.

‎test/e2e/__snapshots__/overlay.test.js.snap.webpack5

+574-536
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.