From cd3d04b71e91f533bdbdc3856775e1da81d445cf Mon Sep 17 00:00:00 2001 From: Tharaka Wijebandara Date: Tue, 3 Oct 2017 21:45:15 +0530 Subject: [PATCH] Make error overlay to run in the context of the iframe (#3142) * Make error overlay to run in the context of the iframe * Configure webpack to build the entire package * Remove inline raw-loader config * Configure watch mode for error-overlay webpack build * Add polyfills to the error-overlay iframe script * Add header comment * Configure to fail CI on error or warning * Suppress flow-type error on importing iframe-bundle * Change webpack to a dev dependency and pin some versions * Disable webpack cache * Update license headers to MIT --- packages/react-error-overlay/build.js | 95 +++++++++++++++++++ packages/react-error-overlay/package.json | 18 ++-- .../react-error-overlay/src/iframeScript.js | 57 +++++++++++ packages/react-error-overlay/src/index.js | 87 ++++++++--------- .../src/utils/pollyfills.js | 18 ++++ .../webpack.config.iframe.js | 27 ++++++ .../react-error-overlay/webpack.config.js | 38 ++++++++ 7 files changed, 284 insertions(+), 56 deletions(-) create mode 100644 packages/react-error-overlay/build.js create mode 100644 packages/react-error-overlay/src/iframeScript.js create mode 100644 packages/react-error-overlay/src/utils/pollyfills.js create mode 100644 packages/react-error-overlay/webpack.config.iframe.js create mode 100644 packages/react-error-overlay/webpack.config.js diff --git a/packages/react-error-overlay/build.js b/packages/react-error-overlay/build.js new file mode 100644 index 0000000000..592da141ff --- /dev/null +++ b/packages/react-error-overlay/build.js @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +const webpack = require('webpack'); +const chalk = require('chalk'); +const webpackConfig = require('./webpack.config.js'); +const iframeWebpackConfig = require('./webpack.config.iframe.js'); +const rimraf = require('rimraf'); +const chokidar = require('chokidar'); + +const args = process.argv.slice(2); +const watchMode = args[0] === '--watch' || args[0] === '-w'; + +const isCI = + process.env.CI && + (typeof process.env.CI !== 'string' || + process.env.CI.toLowerCase() !== 'false'); + +function build(config, name, callback) { + console.log(chalk.cyan('Compiling ' + name)); + webpack(config).run((error, stats) => { + if (error) { + console.log(chalk.red('Failed to compile.')); + console.log(error.message || error); + console.log(); + } + + if (stats.compilation.errors.length) { + console.log(chalk.red('Failed to compile.')); + console.log(stats.toString({ all: false, errors: true })); + } + + if (stats.compilation.warnings.length) { + console.log(chalk.yellow('Compiled with warnings.')); + console.log(stats.toString({ all: false, warnings: true })); + } + + // Fail the build if running in a CI server + if ( + error || + stats.compilation.errors.length || + stats.compilation.warnings.length + ) { + isCI && process.exit(1); + return; + } + + console.log( + stats.toString({ colors: true, modules: false, version: false }) + ); + console.log(); + + callback(stats); + }); +} + +function runBuildSteps() { + build(iframeWebpackConfig, 'iframeScript.js', () => { + build(webpackConfig, 'index.js', () => { + console.log(chalk.bold.green('Compiled successfully!\n\n')); + }); + }); +} + +function setupWatch() { + const watcher = chokidar.watch('./src', { + ignoreInitial: true, + }); + + watcher.on('change', runBuildSteps); + watcher.on('add', runBuildSteps); + + watcher.on('ready', () => { + runBuildSteps(); + }); + + process.on('SIGINT', function() { + watcher.close(); + process.exit(0); + }); + + watcher.on('error', error => { + console.error('Watcher failure', error); + process.exit(1); + }); +} + +// Clean up lib folder +rimraf('lib/', () => { + console.log('Cleaned up the lib folder.\n'); + watchMode ? setupWatch() : runBuildSteps(); +}); diff --git a/packages/react-error-overlay/package.json b/packages/react-error-overlay/package.json index 7640d1c5d9..82ae43ec62 100644 --- a/packages/react-error-overlay/package.json +++ b/packages/react-error-overlay/package.json @@ -5,10 +5,10 @@ "main": "lib/index.js", "scripts": { "prepublishOnly": "npm run build:prod && npm test", - "start": "rimraf lib/ && cross-env NODE_ENV=development npm run build -- --watch", - "test": "flow && jest", - "build": "rimraf lib/ && babel src/ -d lib/", - "build:prod": "rimraf lib/ && cross-env NODE_ENV=production babel src/ -d lib/" + "start": "cross-env NODE_ENV=development node build.js --watch", + "test": "flow && cross-env NODE_ENV=test jest", + "build": "cross-env NODE_ENV=development node build.js", + "build:prod": "cross-env NODE_ENV=production node build.js" }, "repository": "facebookincubator/create-react-app", "license": "MIT", @@ -35,15 +35,19 @@ "babel-code-frame": "6.22.0", "babel-runtime": "6.26.0", "html-entities": "1.2.1", + "object-assign": "4.1.1", + "promise": "8.0.1", "react": "^15 || ^16", "react-dom": "^15 || ^16", "settle-promise": "1.0.0", "source-map": "0.5.6" }, "devDependencies": { - "babel-cli": "6.24.1", "babel-eslint": "7.2.3", "babel-preset-react-app": "^3.0.3", + "babel-loader": "^7.1.2", + "chalk": "^2.1.0", + "chokidar": "^1.7.0", "cross-env": "5.0.5", "eslint": "4.4.1", "eslint-config-react-app": "^2.0.1", @@ -54,7 +58,9 @@ "flow-bin": "^0.54.0", "jest": "20.0.4", "jest-fetch-mock": "1.2.1", - "rimraf": "^2.6.1" + "raw-loader": "^0.5.1", + "rimraf": "^2.6.1", + "webpack": "^3.6.0" }, "jest": { "setupFiles": [ diff --git a/packages/react-error-overlay/src/iframeScript.js b/packages/react-error-overlay/src/iframeScript.js new file mode 100644 index 0000000000..c95ea36b1a --- /dev/null +++ b/packages/react-error-overlay/src/iframeScript.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import './utils/pollyfills.js'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import CompileErrorContainer from './containers/CompileErrorContainer'; +import RuntimeErrorContainer from './containers/RuntimeErrorContainer'; +import { overlayStyle } from './styles'; +import { applyStyles } from './utils/dom/css'; + +let iframeRoot = null; + +function render({ + currentBuildError, + currentRuntimeErrorRecords, + dismissRuntimeErrors, + launchEditorEndpoint, +}) { + if (currentBuildError) { + return ; + } + if (currentRuntimeErrorRecords.length > 0) { + return ( + + ); + } + return null; +} + +window.updateContent = function updateContent(errorOverlayProps) { + let renderedElement = render(errorOverlayProps); + + if (renderedElement === null) { + ReactDOM.unmountComponentAtNode(iframeRoot); + return false; + } + // Update the overlay + ReactDOM.render(renderedElement, iframeRoot); + return true; +}; + +document.body.style.margin = '0'; +// Keep popup within body boundaries for iOS Safari +document.body.style['max-width'] = '100vw'; +iframeRoot = document.createElement('div'); +applyStyles(iframeRoot, overlayStyle); +document.body.appendChild(iframeRoot); +window.parent.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__.iframeReady(); diff --git a/packages/react-error-overlay/src/index.js b/packages/react-error-overlay/src/index.js index 23f9e78388..52ff9199bc 100644 --- a/packages/react-error-overlay/src/index.js +++ b/packages/react-error-overlay/src/index.js @@ -6,15 +6,15 @@ */ /* @flow */ -import React from 'react'; -import type { Element } from 'react'; -import ReactDOM from 'react-dom'; -import CompileErrorContainer from './containers/CompileErrorContainer'; -import RuntimeErrorContainer from './containers/RuntimeErrorContainer'; import { listenToRuntimeErrors } from './listenToRuntimeErrors'; -import { iframeStyle, overlayStyle } from './styles'; +import { iframeStyle } from './styles'; import { applyStyles } from './utils/dom/css'; +// Importing iframe-bundle generated in the pre build step as +// a text using webpack raw-loader. See webpack.config.js file. +// $FlowFixMe +import iframeScript from 'iframeScript'; + import type { ErrorRecord } from './listenToRuntimeErrors'; type RuntimeReportingOptions = {| @@ -25,8 +25,8 @@ type RuntimeReportingOptions = {| let iframe: null | HTMLIFrameElement = null; let isLoadingIframe: boolean = false; +var isIframeReady: boolean = false; -let renderedElement: null | Element = null; let currentBuildError: null | string = null; let currentRuntimeErrorRecords: Array = []; let currentRuntimeErrorOptions: null | RuntimeReportingOptions = null; @@ -88,15 +88,14 @@ export function stopReportingRuntimeErrors() { } function update() { - renderedElement = render(); // Loading iframe can be either sync or async depending on the browser. if (isLoadingIframe) { // Iframe is loading. // First render will happen soon--don't need to do anything. return; } - if (iframe) { - // Iframe has already loaded. + if (isIframeReady) { + // Iframe is ready. // Just update it. updateIframeContent(); return; @@ -108,58 +107,46 @@ function update() { loadingIframe.onload = function() { const iframeDocument = loadingIframe.contentDocument; if (iframeDocument != null && iframeDocument.body != null) { - iframeDocument.body.style.margin = '0'; - // Keep popup within body boundaries for iOS Safari - iframeDocument.body.style['max-width'] = '100vw'; - const iframeRoot = iframeDocument.createElement('div'); - applyStyles(iframeRoot, overlayStyle); - iframeDocument.body.appendChild(iframeRoot); - - // Ready! Now we can update the UI. iframe = loadingIframe; - isLoadingIframe = false; - updateIframeContent(); + const script = loadingIframe.contentWindow.document.createElement( + 'script' + ); + script.type = 'text/javascript'; + script.innerHTML = iframeScript; + iframeDocument.body.appendChild(script); } }; const appDocument = window.document; appDocument.body.appendChild(loadingIframe); } -function render() { - if (currentBuildError) { - return ; - } - if (currentRuntimeErrorRecords.length > 0) { - if (!currentRuntimeErrorOptions) { - throw new Error('Expected options to be injected.'); - } - return ( - - ); +function updateIframeContent() { + if (!currentRuntimeErrorOptions) { + throw new Error('Expected options to be injected.'); } - return null; -} -function updateIframeContent() { - if (iframe === null) { + if (!iframe) { throw new Error('Iframe has not been created yet.'); } - const iframeBody = iframe.contentDocument.body; - if (!iframeBody) { - throw new Error('Expected iframe to have a body.'); - } - const iframeRoot = iframeBody.firstChild; - if (renderedElement === null) { - // Destroy iframe and force it to be recreated on next error + + const isRendered = iframe.contentWindow.updateContent({ + currentBuildError, + currentRuntimeErrorRecords, + dismissRuntimeErrors, + launchEditorEndpoint: currentRuntimeErrorOptions.launchEditorEndpoint, + }); + + if (!isRendered) { window.document.body.removeChild(iframe); - ReactDOM.unmountComponentAtNode(iframeRoot); iframe = null; - return; + isIframeReady = false; } - // Update the overlay - ReactDOM.render(renderedElement, iframeRoot); } + +window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__ = + window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__ || {}; +window.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__.iframeReady = function iframeReady() { + isIframeReady = true; + isLoadingIframe = false; + updateIframeContent(); +}; diff --git a/packages/react-error-overlay/src/utils/pollyfills.js b/packages/react-error-overlay/src/utils/pollyfills.js new file mode 100644 index 0000000000..ddd5aeb965 --- /dev/null +++ b/packages/react-error-overlay/src/utils/pollyfills.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +if (typeof Promise === 'undefined') { + // Rejection tracking prevents a common issue where React gets into an + // inconsistent state due to an error, but it gets swallowed by a Promise, + // and the user has no idea what causes React's erratic future behavior. + require('promise/lib/rejection-tracking').enable(); + window.Promise = require('promise/lib/es6-extensions.js'); +} + +// Object.assign() is commonly used with React. +// It will use the native implementation if it's present and isn't buggy. +Object.assign = require('object-assign'); diff --git a/packages/react-error-overlay/webpack.config.iframe.js b/packages/react-error-overlay/webpack.config.iframe.js new file mode 100644 index 0000000000..9fa742b720 --- /dev/null +++ b/packages/react-error-overlay/webpack.config.iframe.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +'use strict'; + +const path = require('path'); + +module.exports = { + devtool: 'cheap-module-source-map', + entry: './src/iframeScript.js', + output: { + path: path.join(__dirname, './lib'), + filename: 'iframe-bundle.js', + }, + module: { + rules: [ + { + test: /\.js$/, + include: path.resolve(__dirname, './src'), + use: 'babel-loader', + }, + ], + }, +}; diff --git a/packages/react-error-overlay/webpack.config.js b/packages/react-error-overlay/webpack.config.js new file mode 100644 index 0000000000..5d640e05ca --- /dev/null +++ b/packages/react-error-overlay/webpack.config.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +'use strict'; + +const path = require('path'); + +module.exports = { + devtool: 'cheap-module-source-map', + entry: './src/index.js', + output: { + path: path.join(__dirname, './lib'), + filename: 'index.js', + library: 'ReactErrorOverlay', + libraryTarget: 'umd', + }, + module: { + rules: [ + { + test: /iframe-bundle\.js$/, + use: 'raw-loader', + }, + { + test: /\.js$/, + include: path.resolve(__dirname, './src'), + use: 'babel-loader', + }, + ], + }, + resolve: { + alias: { + iframeScript$: path.resolve(__dirname, './lib/iframe-bundle.js'), + }, + }, +};