From 00ed100b26adc519fd90e09ebffd83c8d7dc4343 Mon Sep 17 00:00:00 2001
From: Tharaka Wijebandara <tharaka.nw@gmail.com>
Date: Fri, 6 Oct 2017 05:51:17 +0530
Subject: [PATCH] Add click-to-open support for build errors (#3100)

* Implement click-to-open for babel syntax errors in build error overlay

* Add click-to-open support for lint errors and refactor parser

* Reactor code to reuse open-in-editor functionality in both build and runtime error overlays

* Fix some eslint warnings

* Add a comment about keeping middleware and dev client in sync

* Remove es6 features from webpack dev client

* Make open-in-editor functionality to work with new iframe script

* Rename `openInEditor` to `editorHandler`
 -  Remove indirection of openInEditorListener
 -  Check editorHandler for null before styling error clickable

* Fix flow errors
---
 .../react-dev-utils/webpackHotDevClient.js    | 11 +++-
 .../src/containers/CompileErrorContainer.js   | 20 ++++++-
 .../src/containers/RuntimeError.js            |  7 ++-
 .../src/containers/RuntimeErrorContainer.js   |  5 +-
 .../src/containers/StackFrame.js              | 48 +++++++---------
 .../src/containers/StackTrace.js              | 12 ++--
 .../react-error-overlay/src/iframeScript.js   | 11 +++-
 packages/react-error-overlay/src/index.js     | 21 ++++++-
 .../src/utils/parseCompileError.js            | 57 +++++++++++++++++++
 9 files changed, 142 insertions(+), 50 deletions(-)
 create mode 100644 packages/react-error-overlay/src/utils/parseCompileError.js

diff --git a/packages/react-dev-utils/webpackHotDevClient.js b/packages/react-dev-utils/webpackHotDevClient.js
index 611fc6ba0..e375c3fab 100644
--- a/packages/react-dev-utils/webpackHotDevClient.js
+++ b/packages/react-dev-utils/webpackHotDevClient.js
@@ -23,6 +23,16 @@ var launchEditorEndpoint = require('./launchEditorEndpoint');
 var formatWebpackMessages = require('./formatWebpackMessages');
 var ErrorOverlay = require('react-error-overlay');
 
+ErrorOverlay.setEditorHandler(function editorHandler(errorLocation) {
+  // Keep this sync with errorOverlayMiddleware.js
+  fetch(
+    `${launchEditorEndpoint}?fileName=` +
+      window.encodeURIComponent(errorLocation.fileName) +
+      '&lineNumber=' +
+      window.encodeURIComponent(errorLocation.lineNumber || 1)
+  );
+});
+
 // We need to keep track of if there has been a runtime error.
 // Essentially, we cannot guarantee application state was not corrupted by the
 // runtime error. To prevent confusing behavior, we forcibly reload the entire
@@ -31,7 +41,6 @@ var ErrorOverlay = require('react-error-overlay');
 // See https://github.com/facebookincubator/create-react-app/issues/3096
 var hadRuntimeError = false;
 ErrorOverlay.startReportingRuntimeErrors({
-  launchEditorEndpoint: launchEditorEndpoint,
   onError: function() {
     hadRuntimeError = true;
   },
diff --git a/packages/react-error-overlay/src/containers/CompileErrorContainer.js b/packages/react-error-overlay/src/containers/CompileErrorContainer.js
index 5d491a9d4..9d1e399fd 100644
--- a/packages/react-error-overlay/src/containers/CompileErrorContainer.js
+++ b/packages/react-error-overlay/src/containers/CompileErrorContainer.js
@@ -12,18 +12,34 @@ import Footer from '../components/Footer';
 import Header from '../components/Header';
 import CodeBlock from '../components/CodeBlock';
 import generateAnsiHTML from '../utils/generateAnsiHTML';
+import parseCompileError from '../utils/parseCompileError';
+import type { ErrorLocation } from '../utils/parseCompileError';
+
+const codeAnchorStyle = {
+  cursor: 'pointer',
+};
 
 type Props = {|
   error: string,
+  editorHandler: (errorLoc: ErrorLocation) => void,
 |};
 
 class CompileErrorContainer extends PureComponent<Props, void> {
   render() {
-    const { error } = this.props;
+    const { error, editorHandler } = this.props;
+    const errLoc: ?ErrorLocation = parseCompileError(error);
+    const canOpenInEditor = errLoc !== null && editorHandler !== null;
     return (
       <ErrorOverlay>
         <Header headerText="Failed to compile" />
-        <CodeBlock main={true} codeHTML={generateAnsiHTML(error)} />
+        <a
+          onClick={
+            canOpenInEditor && errLoc ? () => editorHandler(errLoc) : null
+          }
+          style={canOpenInEditor ? codeAnchorStyle : null}
+        >
+          <CodeBlock main={true} codeHTML={generateAnsiHTML(error)} />
+        </a>
         <Footer line1="This error occurred during the build time and cannot be dismissed." />
       </ErrorOverlay>
     );
diff --git a/packages/react-error-overlay/src/containers/RuntimeError.js b/packages/react-error-overlay/src/containers/RuntimeError.js
index c247ba52a..1db2aba79 100644
--- a/packages/react-error-overlay/src/containers/RuntimeError.js
+++ b/packages/react-error-overlay/src/containers/RuntimeError.js
@@ -11,6 +11,7 @@ import Header from '../components/Header';
 import StackTrace from './StackTrace';
 
 import type { StackFrame } from '../utils/stack-frame';
+import type { ErrorLocation } from '../utils/parseCompileError';
 
 const wrapperStyle = {
   display: 'flex',
@@ -26,10 +27,10 @@ export type ErrorRecord = {|
 
 type Props = {|
   errorRecord: ErrorRecord,
-  launchEditorEndpoint: ?string,
+  editorHandler: (errorLoc: ErrorLocation) => void,
 |};
 
-function RuntimeError({ errorRecord, launchEditorEndpoint }: Props) {
+function RuntimeError({ errorRecord, editorHandler }: Props) {
   const { error, unhandledRejection, contextSize, stackFrames } = errorRecord;
   const errorName = unhandledRejection
     ? 'Unhandled Rejection (' + error.name + ')'
@@ -58,7 +59,7 @@ function RuntimeError({ errorRecord, launchEditorEndpoint }: Props) {
         stackFrames={stackFrames}
         errorName={errorName}
         contextSize={contextSize}
-        launchEditorEndpoint={launchEditorEndpoint}
+        editorHandler={editorHandler}
       />
     </div>
   );
diff --git a/packages/react-error-overlay/src/containers/RuntimeErrorContainer.js b/packages/react-error-overlay/src/containers/RuntimeErrorContainer.js
index 80585f051..91d4a4f54 100644
--- a/packages/react-error-overlay/src/containers/RuntimeErrorContainer.js
+++ b/packages/react-error-overlay/src/containers/RuntimeErrorContainer.js
@@ -14,11 +14,12 @@ import RuntimeError from './RuntimeError';
 import Footer from '../components/Footer';
 
 import type { ErrorRecord } from './RuntimeError';
+import type { ErrorLocation } from '../utils/parseCompileError';
 
 type Props = {|
   errorRecords: ErrorRecord[],
   close: () => void,
-  launchEditorEndpoint: ?string,
+  editorHandler: (errorLoc: ErrorLocation) => void,
 |};
 
 type State = {|
@@ -74,7 +75,7 @@ class RuntimeErrorContainer extends PureComponent<Props, State> {
         )}
         <RuntimeError
           errorRecord={errorRecords[this.state.currentIndex]}
-          launchEditorEndpoint={this.props.launchEditorEndpoint}
+          editorHandler={this.props.editorHandler}
         />
         <Footer
           line1="This screen is visible only in development. It will not appear if the app crashes in production."
diff --git a/packages/react-error-overlay/src/containers/StackFrame.js b/packages/react-error-overlay/src/containers/StackFrame.js
index f16ae6435..9ae910434 100644
--- a/packages/react-error-overlay/src/containers/StackFrame.js
+++ b/packages/react-error-overlay/src/containers/StackFrame.js
@@ -12,6 +12,7 @@ import { getPrettyURL } from '../utils/getPrettyURL';
 import { darkGray } from '../styles';
 
 import type { StackFrame as StackFrameType } from '../utils/stack-frame';
+import type { ErrorLocation } from '../utils/parseCompileError';
 
 const linkStyle = {
   fontSize: '0.9em',
@@ -45,10 +46,10 @@ const toggleStyle = {
 
 type Props = {|
   frame: StackFrameType,
-  launchEditorEndpoint: ?string,
   contextSize: number,
   critical: boolean,
   showCode: boolean,
+  editorHandler: (errorLoc: ErrorLocation) => void,
 |};
 
 type State = {|
@@ -66,47 +67,35 @@ class StackFrame extends Component<Props, State> {
     }));
   };
 
-  getEndpointUrl(): string | null {
-    if (!this.props.launchEditorEndpoint) {
-      return null;
-    }
-    const { _originalFileName: sourceFileName } = this.props.frame;
+  getErrorLocation(): ErrorLocation | null {
+    const {
+      _originalFileName: fileName,
+      _originalLineNumber: lineNumber,
+    } = this.props.frame;
     // Unknown file
-    if (!sourceFileName) {
+    if (!fileName) {
       return null;
     }
     // e.g. "/path-to-my-app/webpack/bootstrap eaddeb46b67d75e4dfc1"
-    const isInternalWebpackBootstrapCode =
-      sourceFileName.trim().indexOf(' ') !== -1;
+    const isInternalWebpackBootstrapCode = fileName.trim().indexOf(' ') !== -1;
     if (isInternalWebpackBootstrapCode) {
       return null;
     }
     // Code is in a real file
-    return this.props.launchEditorEndpoint || null;
+    return { fileName, lineNumber: lineNumber || 1 };
   }
 
-  openInEditor = () => {
-    const endpointUrl = this.getEndpointUrl();
-    if (endpointUrl === null) {
+  editorHandler = () => {
+    const errorLoc = this.getErrorLocation();
+    if (!errorLoc) {
       return;
     }
-
-    const {
-      _originalFileName: sourceFileName,
-      _originalLineNumber: sourceLineNumber,
-    } = this.props.frame;
-    // Keep this in sync with react-error-overlay/middleware.js
-    fetch(
-      `${endpointUrl}?fileName=` +
-        window.encodeURIComponent(sourceFileName) +
-        '&lineNumber=' +
-        window.encodeURIComponent(sourceLineNumber || 1)
-    ).then(() => {}, () => {});
+    this.props.editorHandler(errorLoc);
   };
 
   onKeyDown = (e: SyntheticKeyboardEvent<>) => {
     if (e.key === 'Enter') {
-      this.openInEditor();
+      this.editorHandler();
     }
   };
 
@@ -166,14 +155,15 @@ class StackFrame extends Component<Props, State> {
       }
     }
 
-    const canOpenInEditor = this.getEndpointUrl() !== null;
+    const canOpenInEditor =
+      this.getErrorLocation() !== null && this.props.editorHandler !== null;
     return (
       <div>
         <div>{functionName}</div>
         <div style={linkStyle}>
           <a
             style={canOpenInEditor ? anchorStyle : null}
-            onClick={canOpenInEditor ? this.openInEditor : null}
+            onClick={canOpenInEditor ? this.editorHandler : null}
             onKeyDown={canOpenInEditor ? this.onKeyDown : null}
             tabIndex={canOpenInEditor ? '0' : null}
           >
@@ -183,7 +173,7 @@ class StackFrame extends Component<Props, State> {
         {codeBlockProps && (
           <span>
             <a
-              onClick={canOpenInEditor ? this.openInEditor : null}
+              onClick={canOpenInEditor ? this.editorHandler : null}
               style={canOpenInEditor ? codeAnchorStyle : null}
             >
               <CodeBlock {...codeBlockProps} />
diff --git a/packages/react-error-overlay/src/containers/StackTrace.js b/packages/react-error-overlay/src/containers/StackTrace.js
index 0be342ddc..abec286a3 100644
--- a/packages/react-error-overlay/src/containers/StackTrace.js
+++ b/packages/react-error-overlay/src/containers/StackTrace.js
@@ -13,6 +13,7 @@ import { isInternalFile } from '../utils/isInternalFile';
 import { isBultinErrorName } from '../utils/isBultinErrorName';
 
 import type { StackFrame as StackFrameType } from '../utils/stack-frame';
+import type { ErrorLocation } from '../utils/parseCompileError';
 
 const traceStyle = {
   fontSize: '1em',
@@ -25,17 +26,12 @@ type Props = {|
   stackFrames: StackFrameType[],
   errorName: string,
   contextSize: number,
-  launchEditorEndpoint: ?string,
+  editorHandler: (errorLoc: ErrorLocation) => void,
 |};
 
 class StackTrace extends Component<Props> {
   renderFrames() {
-    const {
-      stackFrames,
-      errorName,
-      contextSize,
-      launchEditorEndpoint,
-    } = this.props;
+    const { stackFrames, errorName, contextSize, editorHandler } = this.props;
     const renderedFrames = [];
     let hasReachedAppCode = false,
       currentBundle = [],
@@ -59,7 +55,7 @@ class StackTrace extends Component<Props> {
           contextSize={contextSize}
           critical={index === 0}
           showCode={!shouldCollapse}
-          launchEditorEndpoint={launchEditorEndpoint}
+          editorHandler={editorHandler}
         />
       );
       const lastElement = index === stackFrames.length - 1;
diff --git a/packages/react-error-overlay/src/iframeScript.js b/packages/react-error-overlay/src/iframeScript.js
index c95ea36b1..3da820264 100644
--- a/packages/react-error-overlay/src/iframeScript.js
+++ b/packages/react-error-overlay/src/iframeScript.js
@@ -19,17 +19,22 @@ function render({
   currentBuildError,
   currentRuntimeErrorRecords,
   dismissRuntimeErrors,
-  launchEditorEndpoint,
+  editorHandler,
 }) {
   if (currentBuildError) {
-    return <CompileErrorContainer error={currentBuildError} />;
+    return (
+      <CompileErrorContainer
+        error={currentBuildError}
+        editorHandler={editorHandler}
+      />
+    );
   }
   if (currentRuntimeErrorRecords.length > 0) {
     return (
       <RuntimeErrorContainer
         errorRecords={currentRuntimeErrorRecords}
         close={dismissRuntimeErrors}
-        launchEditorEndpoint={launchEditorEndpoint}
+        editorHandler={editorHandler}
       />
     );
   }
diff --git a/packages/react-error-overlay/src/index.js b/packages/react-error-overlay/src/index.js
index 52ff9199b..a800595cf 100644
--- a/packages/react-error-overlay/src/index.js
+++ b/packages/react-error-overlay/src/index.js
@@ -16,22 +16,32 @@ import { applyStyles } from './utils/dom/css';
 import iframeScript from 'iframeScript';
 
 import type { ErrorRecord } from './listenToRuntimeErrors';
+import type { ErrorLocation } from './utils/parseCompileError';
 
 type RuntimeReportingOptions = {|
   onError: () => void,
-  launchEditorEndpoint: string,
   filename?: string,
 |};
 
+type EditorHandler = (errorLoc: ErrorLocation) => void;
+
 let iframe: null | HTMLIFrameElement = null;
 let isLoadingIframe: boolean = false;
 var isIframeReady: boolean = false;
 
+let editorHandler: null | EditorHandler = null;
 let currentBuildError: null | string = null;
 let currentRuntimeErrorRecords: Array<ErrorRecord> = [];
 let currentRuntimeErrorOptions: null | RuntimeReportingOptions = null;
 let stopListeningToRuntimeErrors: null | (() => void) = null;
 
+export function setEditorHandler(handler: EditorHandler | null) {
+  editorHandler = handler;
+  if (iframe) {
+    update();
+  }
+}
+
 export function reportBuildError(error: string) {
   currentBuildError = error;
   update();
@@ -46,6 +56,13 @@ export function startReportingRuntimeErrors(options: RuntimeReportingOptions) {
   if (stopListeningToRuntimeErrors !== null) {
     throw new Error('Already listening');
   }
+  if (options.launchEditorEndpoint) {
+    console.warn(
+      'Warning: `startReportingRuntimeErrors` doesn’t accept ' +
+        '`launchEditorEndpoint` argument anymore. Use `listenToOpenInEditor` ' +
+        'instead with your own implementation to open errors in editor '
+    );
+  }
   currentRuntimeErrorOptions = options;
   listenToRuntimeErrors(errorRecord => {
     try {
@@ -133,7 +150,7 @@ function updateIframeContent() {
     currentBuildError,
     currentRuntimeErrorRecords,
     dismissRuntimeErrors,
-    launchEditorEndpoint: currentRuntimeErrorOptions.launchEditorEndpoint,
+    editorHandler,
   });
 
   if (!isRendered) {
diff --git a/packages/react-error-overlay/src/utils/parseCompileError.js b/packages/react-error-overlay/src/utils/parseCompileError.js
new file mode 100644
index 000000000..2c9b6e60e
--- /dev/null
+++ b/packages/react-error-overlay/src/utils/parseCompileError.js
@@ -0,0 +1,57 @@
+// @flow
+import Anser from 'anser';
+
+export type ErrorLocation = {|
+  fileName: string,
+  lineNumber: number,
+|};
+
+const filePathRegex = /^\.(\/[^/\n ]+)+\.[^/\n ]+$/;
+
+const lineNumberRegexes = [
+  // Babel syntax errors
+  // Based on syntax error formating of babylon parser
+  // https://github.com/babel/babylon/blob/v7.0.0-beta.22/src/parser/location.js#L19
+  /^.*\((\d+):(\d+)\)$/,
+
+  // ESLint errors
+  // Based on eslintFormatter in react-dev-utils
+  /^Line (\d+):.+$/,
+];
+
+// Based on error formatting of webpack
+// https://github.com/webpack/webpack/blob/v3.5.5/lib/Stats.js#L183-L217
+function parseCompileError(message: string): ?ErrorLocation {
+  const lines: Array<string> = message.split('\n');
+  let fileName: string = '';
+  let lineNumber: number = 0;
+
+  for (let i = 0; i < lines.length; i++) {
+    const line: string = Anser.ansiToText(lines[i]).trim();
+    if (!line) {
+      continue;
+    }
+
+    if (!fileName && line.match(filePathRegex)) {
+      fileName = line;
+    }
+
+    let k = 0;
+    while (k < lineNumberRegexes.length) {
+      const match: ?Array<string> = line.match(lineNumberRegexes[k]);
+      if (match) {
+        lineNumber = parseInt(match[1], 10);
+        break;
+      }
+      k++;
+    }
+
+    if (fileName && lineNumber) {
+      break;
+    }
+  }
+
+  return fileName && lineNumber ? { fileName, lineNumber } : null;
+}
+
+export default parseCompileError;
-- 
GitLab