From 9cce0fb59f5e4a47c043c26c41e281c812e57ff4 Mon Sep 17 00:00:00 2001
From: Dan Abramov <dan.abramov@gmail.com>
Date: Sun, 25 Sep 2016 11:31:10 +0100
Subject: [PATCH] Add syntax error overlay in development (#744)

* Add syntax error overlay in development

* Support HMR being disabled

* Tweak CSS
---
 packages/react-dev-utils/README.md            |  22 ++
 .../react-dev-utils/formatWebpackMessages.js  | 133 +++++++---
 packages/react-dev-utils/package.json         |   9 +-
 .../react-dev-utils/webpackHotDevClient.js    | 230 ++++++++++++++++++
 .../config/webpack.config.dev.js              |  28 +--
 packages/react-scripts/scripts/start.js       |   2 +-
 6 files changed, 371 insertions(+), 53 deletions(-)
 create mode 100644 packages/react-dev-utils/webpackHotDevClient.js

diff --git a/packages/react-dev-utils/README.md b/packages/react-dev-utils/README.md
index 4a07dbae2..a36af2044 100644
--- a/packages/react-dev-utils/README.md
+++ b/packages/react-dev-utils/README.md
@@ -178,3 +178,25 @@ prompt(
   }
 });
 ```
+
+#### `webpackHotDevClient.js`
+
+This is an alternative client for [WebpackDevServer](https://github.com/webpack/webpack-dev-server) that shows a syntax error overlay.
+
+It currently supports only Webpack 1.x.
+
+```js
+// Webpack development config
+module.exports = {
+  // ...
+  entry: [
+    // You can replace the line below with these two lines if you prefer the
+    // stock client:
+    // require.resolve('webpack-dev-server/client') + '?/',
+    // require.resolve('webpack/hot/dev-server'),
+    'react-dev-utils/webpackHotDevClient',
+    'src/index'
+  ],
+  // ...
+}
+```
diff --git a/packages/react-dev-utils/formatWebpackMessages.js b/packages/react-dev-utils/formatWebpackMessages.js
index 4fda66d4b..d72d5f734 100644
--- a/packages/react-dev-utils/formatWebpackMessages.js
+++ b/packages/react-dev-utils/formatWebpackMessages.js
@@ -7,50 +7,115 @@
  * of patent rights can be found in the PATENTS file in the same directory.
  */
 
+// WARNING: this code is untranspiled and is used in browser too.
+// Please make sure any changes are in ES5 or contribute a Babel compile step.
+
 // Some custom utilities to prettify Webpack output.
-// This is a little hacky.
-// It would be easier if webpack provided a rich error object.
+// This is quite hacky and hopefully won't be needed when Webpack fixes this.
+// https://github.com/webpack/webpack/issues/2878
+
 var friendlySyntaxErrorLabel = 'Syntax error:';
+
 function isLikelyASyntaxError(message) {
   return message.indexOf(friendlySyntaxErrorLabel) !== -1;
 }
+
+// Cleans up webpack error messages.
 function formatMessage(message) {
-  return message
-    // Make some common errors shorter:
-    .replace(
-      // Babel syntax error
+  var lines = message.split('\n');
+
+  // line #0 is filename
+  // line #1 is the main error message
+  if (!lines[0] || !lines[1]) {
+    return message;
+  }
+
+  // Remove webpack-specific loader notation from filename.
+  // Before:
+  // ./~/css-loader!./~/postcss-loader!./src/App.css
+  // After:
+  // ./src/App.css
+  if (lines[0].lastIndexOf('!') !== -1) {
+    lines[0] = lines[0].substr(lines[0].lastIndexOf('!') + 1);
+  }
+
+  // Cleans up verbose "module not found" messages for files and packages.
+  if (lines[1].indexOf('Module not found: ') === 0) {
+    lines = [
+      lines[0],
+      // Clean up message because "Module not found: " is descriptive enough.
+      lines[1].replace(
+        'Cannot resolve \'file\' or \'directory\' ', ''
+      ).replace(
+        'Cannot resolve module ', ''
+      ).replace(
+        'Error: ', ''
+      ),
+      // Skip all irrelevant lines.
+      // (For some reason they only appear on the client in browser.)
+      '',
+      lines[lines.length - 1] // error location is the last line
+    ]
+  }
+
+  // Cleans up syntax error messages.
+  if (lines[1].indexOf('Module build failed: ') === 0) {
+    // For some reason, on the client messages appear duplicated:
+    // https://github.com/webpack/webpack/issues/3008
+    // This won't happen in Node but since we share this helpers,
+    // we will dedupe them right here. We will ignore all lines
+    // after the original error message text is repeated the second time.
+    var errorText = lines[1].substr('Module build failed: '.length);
+    var cleanedLines = [];
+    var hasReachedDuplicateMessage = false;
+    // Gather lines until we reach the beginning of duplicate message.
+    lines.forEach(function(line, index) {
+      if (
+        // First time it occurs is fine.
+        index !== 1 &&
+        // line.endsWith(errorText)
+        line.length >= errorText.length &&
+        line.indexOf(errorText) === line.length - errorText.length
+      ) {
+        // We see the same error message for the second time!
+        // Filter out repeated error message and everything after it.
+        hasReachedDuplicateMessage = true;
+      }
+      if (
+        !hasReachedDuplicateMessage ||
+        // Print last line anyway because it contains the source location
+        index === lines.length - 1
+      ) {
+        // This line is OK to appear in the output.
+        cleanedLines.push(line);
+      }
+    });
+    // We are clean now!
+    lines = cleanedLines;
+    // Finally, brush up the error message a little.
+    lines[1] = lines[1].replace(
       'Module build failed: SyntaxError:',
       friendlySyntaxErrorLabel
-    )
-    .replace(
-      // Webpack file not found error
-      /Module not found: Error: Cannot resolve 'file' or 'directory'/,
-      'Module not found:'
-    )
-    // Internal stacks are generally useless so we strip them
-    .replace(/^\s*at\s.*:\d+:\d+[\s\)]*\n/gm, '') // at ... ...:x:y
-    // Webpack loader names obscure CSS filenames
-    .replace('./~/css-loader!./~/postcss-loader!', '');
+    );
+  }
+
+  // Reassemble the message.
+  message = lines.join('\n');
+  // Internal stacks are generally useless so we strip them
+  message = message.replace(
+    /^\s*at\s.*:\d+:\d+[\s\)]*\n/gm, ''
+  ); // at ... ...:x:y
+
+  return message;
 }
 
-function formatWebpackMessages(stats) {
-  var hasErrors = stats.hasErrors();
-  var hasWarnings = stats.hasWarnings();
-  if (!hasErrors && !hasWarnings) {
-    return {
-      errors: [],
-      warnings: []
-    };
-  }
-  // We use stats.toJson({}, true) to make output more compact and readable:
-  // https://github.com/facebookincubator/create-react-app/issues/401#issuecomment-238291901
-  var json = stats.toJson({}, true);
-  var formattedErrors = json.errors.map(message =>
-    'Error in ' + formatMessage(message)
-  );
-  var formattedWarnings = json.warnings.map(message =>
-    'Warning in ' + formatMessage(message)
-  );
+function formatWebpackMessages(json) {
+  var formattedErrors = json.errors.map(function(message) {
+    return 'Error in ' + formatMessage(message)
+  });
+  var formattedWarnings = json.warnings.map(function(message) {
+    return 'Warning in ' + formatMessage(message)
+  });
   var result = {
     errors: formattedErrors,
     warnings: formattedWarnings
diff --git a/packages/react-dev-utils/package.json b/packages/react-dev-utils/package.json
index b2fad26ce..69439a113 100644
--- a/packages/react-dev-utils/package.json
+++ b/packages/react-dev-utils/package.json
@@ -18,12 +18,17 @@
     "openChrome.applescript",
     "openBrowser.js",
     "prompt.js",
-    "WatchMissingNodeModulesPlugin.js"
+    "WatchMissingNodeModulesPlugin.js",
+    "webpackHotDevClient.js"
   ],
   "dependencies": {
+    "ansi-html": "0.0.5",
     "chalk": "1.1.3",
     "escape-string-regexp": "1.0.5",
-    "opn": "4.0.2"
+    "html-entities": "1.2.0",
+    "opn": "4.0.2",
+    "sockjs-client": "1.0.3",
+    "strip-ansi": "3.0.1"
   },
   "peerDependencies": {
     "webpack": "^1.13.2"
diff --git a/packages/react-dev-utils/webpackHotDevClient.js b/packages/react-dev-utils/webpackHotDevClient.js
new file mode 100644
index 000000000..d0aa54adb
--- /dev/null
+++ b/packages/react-dev-utils/webpackHotDevClient.js
@@ -0,0 +1,230 @@
+/**
+ * Copyright (c) 2015-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ */
+
+// This alternative WebpackDevServer combines the functionality of:
+// https://github.com/webpack/webpack-dev-server/blob/webpack-1/client/index.js
+// https://github.com/webpack/webpack/blob/webpack-1/hot/dev-server.js
+
+// It only supports their simplest configuration (hot updates on same server).
+// It makes some opinionated choices on top, like adding a syntax error overlay
+// that looks similar to our console output. The error overlay is inspired by:
+// https://github.com/glenjamin/webpack-hot-middleware
+
+var ansiHTML = require('ansi-html');
+var SockJS = require('sockjs-client');
+var stripAnsi = require('strip-ansi');
+var url = require('url');
+var formatWebpackMessages = require('./formatWebpackMessages');
+var Entities = require('html-entities').AllHtmlEntities;
+var entities = new Entities();
+
+// Color scheme inspired by https://github.com/glenjamin/webpack-hot-middleware
+var colors = {
+  reset: ['transparent', 'transparent'],
+  black: '181818',
+  red: 'E36049',
+  green: 'B3CB74',
+  yellow: 'FFD080',
+  blue: '7CAFC2',
+  magenta: '7FACCA',
+  cyan: 'C3C2EF',
+  lightgrey: 'EBE7E3',
+  darkgrey: '6D7891'
+};
+ansiHTML.setColors(colors);
+
+function showErrorOverlay(message) {
+  // Use an iframe so that document styles don't mess up the overlay.
+  var iframeID = 'react-dev-utils-webpack-hot-dev-client-overlay';
+  var iframe =
+    document.getElementById(iframeID) ||
+    document.createElement('iframe');
+  iframe.id = iframeID;
+  iframe.style.position = 'fixed';
+  iframe.style.left = 0;
+  iframe.style.top = 0;
+  iframe.style.right = 0;
+  iframe.style.bottom = 0;
+  iframe.style.width = '100vw';
+  iframe.style.height = '100vh';
+  iframe.style.border = 'none';
+  iframe.style.zIndex = 9999999999;
+  document.body.appendChild(iframe);
+
+  // Inside, make a div.
+  var overlayID = 'react-dev-utils-webpack-hot-dev-client-overlay-div';
+  var overlay =
+    iframe.contentDocument.getElementById(overlayID) ||
+    iframe.contentDocument.createElement('div');
+  overlay.id = overlayID;
+  overlay.style.position = 'fixed';
+  overlay.style.left = 0;
+  overlay.style.top = 0;
+  overlay.style.right = 0;
+  overlay.style.bottom = 0;
+  overlay.style.width = '100vw';
+  overlay.style.height = '100vh';
+  overlay.style.backgroundColor = 'black';
+  overlay.style.color = '#E8E8E8';
+  overlay.style.fontFamily = 'Menlo, Consolas, monospace';
+  overlay.style.fontSize = 'large';
+  overlay.style.padding = '2rem';
+  overlay.style.lineHeight = '1.2';
+  overlay.style.whiteSpace = 'pre-wrap';
+  overlay.style.overflow = 'auto';
+
+  // Make it look similar to our terminal.
+  overlay.innerHTML =
+    '<span style="color: #' +
+    colors.red +
+    '">Failed to compile.</span><br><br>' +
+    ansiHTML(entities.encode(message));
+
+  // Render!
+  iframe.contentDocument.body.appendChild(overlay);
+}
+
+// Connect to WebpackDevServer via a socket.
+var connection = new SockJS(url.format({
+  protocol: window.location.protocol,
+  hostname: window.location.hostname,
+  port: window.location.port,
+  // Hardcoded in WebpackDevServer
+  pathname: '/sockjs-node'
+}));
+// Note: unlike WebpackDevServer's built-in client,
+// we don't handle disconnect. If connection fails,
+// just leave it instead of spamming the console.
+
+// Remember some state related to hot module replacement.
+var isFirstCompilation = true;
+var mostRecentCompilationHash = null;
+
+// Successful compilation.
+function handleSuccess() {
+  var isHotUpdate = !isFirstCompilation;
+  isFirstCompilation = false;
+
+  // Attempt to apply hot updates or reload.
+  if (isHotUpdate) {
+    tryApplyUpdates();
+  }
+}
+
+// Compilation with warnings (e.g. ESLint).
+function handleWarnings(warnings) {
+  var isHotUpdate = !isFirstCompilation;
+  isFirstCompilation = false;
+
+  function printWarnings() {
+    // Print warnings to the console.
+    for (var i = 0; i < warnings.length; i++) {
+      console.warn(stripAnsi(warnings[i]));
+    }
+  }
+
+  // Attempt to apply hot updates or reload.
+  if (isHotUpdate) {
+    tryApplyUpdates(function onSuccessfulHotUpdate() {
+      // Only print warnings if we aren't refreshing the page.
+      // Otherwise they'll disappear right away anyway.
+      printWarnings();
+    });
+  } else {
+    // Print initial warnings immediately.
+    printWarnings();
+  }
+}
+
+// Compilation with errors (e.g. syntax error or missing modules).
+function handleErrors(errors) {
+  isFirstCompilation = false;
+
+  // "Massage" webpack messages.
+  var formatted = formatWebpackMessages({
+    errors: errors,
+    warnings: []
+  });
+
+  // Only show the first error.
+  showErrorOverlay(formatted.errors[0]);
+  // Do not attempt to reload now.
+  // We will reload on next success instead.
+}
+
+// There is a newer version of the code available.
+function handleAvailableHash(hash) {
+  // Update last known compilation hash.
+  mostRecentCompilationHash = hash;
+}
+
+// Handle messages from the server.
+connection.onmessage = function(e) {
+  var message = JSON.parse(e.data);
+  switch (message.type) {
+  case 'hash':
+    handleAvailableHash(message.data);
+    break;
+  case 'ok':
+    handleSuccess();
+    break;
+  case 'warnings':
+    handleWarnings(message.data);
+    break;
+  case 'errors':
+    handleErrors(message.data);
+    break;
+  default:
+    // Do nothing.
+  }
+}
+
+// Is there a newer version of this code available?
+function isUpdateAvailable() {
+  /* globals __webpack_hash__ */
+  // __webpack_hash__ is the hash of the current compilation.
+  // It's a global variable injected by Webpack.
+  return mostRecentCompilationHash !== __webpack_hash__;
+}
+
+// Webpack disallows updates in other states.
+function canApplyUpdates() {
+  return module.hot.status() === 'idle';
+}
+
+// Attempt to update code on the fly, fall back to a hard reload.
+function tryApplyUpdates(onHotUpdateSuccess) {
+  if (!module.hot) {
+    // HotModuleReplacementPlugin is not in Webpack configuration.
+    window.location.reload();
+    return;
+  }
+
+  if (!isUpdateAvailable() || !canApplyUpdates()) {
+    return;
+  }
+
+  // https://webpack.github.io/docs/hot-module-replacement.html#check
+  module.hot.check(/* autoApply */true, function(err, updatedModules) {
+    if (err || !updatedModules) {
+      window.location.reload();
+      return;
+    }
+
+    if (typeof onHotUpdateSuccess === 'function') {
+      // Maybe we want to do something.
+      onHotUpdateSuccess();
+    }
+
+    if (isUpdateAvailable()) {
+      // While we were updating, there was a new update! Do it again.
+      tryApplyUpdates();
+    }
+  });
+};
diff --git a/packages/react-scripts/config/webpack.config.dev.js b/packages/react-scripts/config/webpack.config.dev.js
index 0c3e336e7..67ba6f122 100644
--- a/packages/react-scripts/config/webpack.config.dev.js
+++ b/packages/react-scripts/config/webpack.config.dev.js
@@ -43,22 +43,18 @@ module.exports = {
   // This means they will be the "root" imports that are included in JS bundle.
   // The first two entry points enable "hot" CSS and auto-refreshes for JS.
   entry: [
-    // Include WebpackDevServer client. It connects to WebpackDevServer via
-    // sockets and waits for recompile notifications. When WebpackDevServer
-    // recompiles, it sends a message to the client by socket. If only CSS
-    // was changed, the app reload just the CSS. Otherwise, it will refresh.
-    // The "?/" bit at the end tells the client to look for the socket at
-    // the root path, i.e. /sockjs-node/. Otherwise visiting a client-side
-    // route like /todos/42 would make it wrongly request /todos/42/sockjs-node.
-    // The socket server is a part of WebpackDevServer which we are using.
-    // The /sockjs-node/ path I'm referring to is hardcoded in WebpackDevServer.
-    require.resolve('webpack-dev-server/client') + '?/',
-    // Include Webpack hot module replacement runtime. Webpack is pretty
-    // low-level so we need to put all the pieces together. The runtime listens
-    // to the events received by the client above, and applies updates (such as
-    // new CSS) to the running application.
-    require.resolve('webpack/hot/dev-server'),
-    // We ship a few polyfills by default.
+    // Include an alternative client for WebpackDevServer. A client's job is to
+    // connect to WebpackDevServer by a socket and get notified about changes.
+    // When you save a file, the client will either apply hot updates (in case
+    // of CSS changes), or refresh the page (in case of JS changes). When you
+    // make a syntax error, this client will display a syntax error overlay.
+    // Note: instead of the default WebpackDevServer client, we use a custom one
+    // to bring better experience for Create React App users. You can replace
+    // the line below with these two lines if you prefer the stock client:
+    // require.resolve('webpack-dev-server/client') + '?/',
+    // require.resolve('webpack/hot/dev-server'),
+    require.resolve('react-dev-utils/webpackHotDevClient'),
+    // We ship a few polyfills by default:
     require.resolve('./polyfills'),
     // Finally, this is your app's code:
     paths.appIndexJs
diff --git a/packages/react-scripts/scripts/start.js b/packages/react-scripts/scripts/start.js
index b47e61cfd..297507f18 100644
--- a/packages/react-scripts/scripts/start.js
+++ b/packages/react-scripts/scripts/start.js
@@ -76,7 +76,7 @@ function setupCompiler(host, port, protocol) {
     // We have switched off the default Webpack output in WebpackDevServer
     // options so we are going to "massage" the warnings and errors and present
     // them in a readable focused way.
-    var messages = formatWebpackMessages(stats);
+    var messages = formatWebpackMessages(stats.toJson({}, true));
     if (!messages.errors.length && !messages.warnings.length) {
       console.log(chalk.green('Compiled successfully!'));
       console.log();
-- 
GitLab