diff --git a/packages/react-dev-utils/README.md b/packages/react-dev-utils/README.md index 4a07dbae28e..a36af204412 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 4fda66d4b39..d72d5f734ca 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 b2fad26cefd..69439a11311 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 00000000000..d0aa54adb10 --- /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 = + 'Failed to compile.

' + + 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 0c3e336e781..67ba6f122a3 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 b47e61cfdcd..297507f18c6 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();