index.js 9.12 KB
const RuntimeErrorFooter = require('./components/RuntimeErrorFooter.js');
const RuntimeErrorHeader = require('./components/RuntimeErrorHeader.js');
const CompileErrorContainer = require('./containers/CompileErrorContainer.js');
const RuntimeErrorContainer = require('./containers/RuntimeErrorContainer.js');
const theme = require('./theme.js');
const utils = require('./utils.js');

/**
 * @callback RenderFn
 * @returns {void}
 */

/* ===== Cached elements for DOM manipulations ===== */
/**
 * The iframe that contains the overlay.
 * @type {HTMLIFrameElement}
 */
let iframeRoot = null;
/**
 * The document object from the iframe root, used to create and render elements.
 * @type {Document}
 */
let rootDocument = null;
/**
 * The root div elements will attach to.
 * @type {HTMLDivElement}
 */
let root = null;
/**
 * A Cached function to allow deferred render.
 * @type {RenderFn | null}
 */
let scheduledRenderFn = null;

/* ===== Overlay State ===== */
/**
 * The latest error message from Webpack compilation.
 * @type {string}
 */
let currentCompileErrorMessage = '';
/**
 * Index of the error currently shown by the overlay.
 * @type {number}
 */
let currentRuntimeErrorIndex = 0;
/**
 * The latest runtime error objects.
 * @type {Error[]}
 */
let currentRuntimeErrors = [];
/**
 * The render mode the overlay is currently in.
 * @type {'compileError' | 'runtimeError' | null}
 */
let currentMode = null;

/**
 * @typedef {Object} IframeProps
 * @property {function(): void} onIframeLoad
 */

/**
 * Creates the main `iframe` the overlay will attach to.
 * Accepts a callback to be ran after iframe is initialized.
 * @param {Document} document
 * @param {HTMLElement} root
 * @param {IframeProps} props
 * @returns {HTMLIFrameElement}
 */
function IframeRoot(document, root, props) {
  const iframe = document.createElement('iframe');
  iframe.id = 'react-refresh-overlay';
  iframe.src = 'about:blank';

  iframe.style.border = 'none';
  iframe.style.height = '100%';
  iframe.style.left = '0';
  iframe.style.minHeight = '100vh';
  iframe.style.minHeight = '-webkit-fill-available';
  iframe.style.position = 'fixed';
  iframe.style.top = '0';
  iframe.style.width = '100vw';
  iframe.style.zIndex = '2147483647';
  iframe.addEventListener('load', function onLoad() {
    // Reset margin of iframe body
    iframe.contentDocument.body.style.margin = '0';
    props.onIframeLoad();
  });

  // We skip mounting and returns as we need to ensure
  // the load event is fired after we setup the global variable
  return iframe;
}

/**
 * Creates the main `div` element for the overlay to render.
 * @param {Document} document
 * @param {HTMLElement} root
 * @returns {HTMLDivElement}
 */
function OverlayRoot(document, root) {
  const div = document.createElement('div');
  div.id = 'react-refresh-overlay-error';

  // Style the contents container
  div.style.backgroundColor = '#' + theme.grey;
  div.style.boxSizing = 'border-box';
  div.style.color = '#' + theme.white;
  div.style.fontFamily = [
    '-apple-system',
    'BlinkMacSystemFont',
    '"Segoe UI"',
    '"Helvetica Neue"',
    'Helvetica',
    'Arial',
    'sans-serif',
    '"Apple Color Emoji"',
    '"Segoe UI Emoji"',
    'Segoe UI Symbol',
  ].join(', ');
  div.style.fontSize = '0.875rem';
  div.style.height = '100%';
  div.style.lineHeight = '1.3';
  div.style.overflow = 'auto';
  div.style.padding = '1rem 1.5rem 0';
  div.style.paddingTop = 'max(1rem, env(safe-area-inset-top))';
  div.style.paddingRight = 'max(1.5rem, env(safe-area-inset-right))';
  div.style.paddingBottom = 'env(safe-area-inset-bottom)';
  div.style.paddingLeft = 'max(1.5rem, env(safe-area-inset-left))';
  div.style.width = '100vw';

  root.appendChild(div);
  return div;
}

/**
 * Ensures the iframe root and the overlay root are both initialized before render.
 * If check fails, render will be deferred until both roots are initialized.
 * @param {RenderFn} renderFn A function that triggers a DOM render.
 * @returns {void}
 */
function ensureRootExists(renderFn) {
  if (root) {
    // Overlay root is ready, we can render right away.
    renderFn();
    return;
  }

  // Creating an iframe may be asynchronous so we'll defer render.
  // In case of multiple calls, function from the last call will be used.
  scheduledRenderFn = renderFn;

  if (iframeRoot) {
    // Iframe is already ready, it will fire the load event.
    return;
  }

  // Create the iframe root, and, the overlay root inside it when it is ready.
  iframeRoot = IframeRoot(document, document.body, {
    onIframeLoad: function onIframeLoad() {
      rootDocument = iframeRoot.contentDocument;
      root = OverlayRoot(rootDocument, rootDocument.body);
      scheduledRenderFn();
    },
  });

  // We have to mount here to ensure `iframeRoot` is set when `onIframeLoad` fires.
  // This is because onIframeLoad() will be called synchronously
  // or asynchronously depending on the browser.
  document.body.appendChild(iframeRoot);
}

/**
 * Creates the main `div` element for the overlay to render.
 * @returns {void}
 */
function render() {
  ensureRootExists(function () {
    const currentFocus = rootDocument.activeElement;
    let currentFocusId;
    if (currentFocus.localName === 'button' && currentFocus.id) {
      currentFocusId = currentFocus.id;
    }

    utils.removeAllChildren(root);

    if (currentCompileErrorMessage) {
      currentMode = 'compileError';

      CompileErrorContainer(rootDocument, root, {
        errorMessage: currentCompileErrorMessage,
      });
    } else if (currentRuntimeErrors.length) {
      currentMode = 'runtimeError';

      RuntimeErrorHeader(rootDocument, root, {
        currentErrorIndex: currentRuntimeErrorIndex,
        totalErrors: currentRuntimeErrors.length,
      });
      RuntimeErrorContainer(rootDocument, root, {
        currentError: currentRuntimeErrors[currentRuntimeErrorIndex],
      });
      RuntimeErrorFooter(rootDocument, root, {
        initialFocus: currentFocusId,
        multiple: currentRuntimeErrors.length > 1,
        onClickCloseButton: function onClose() {
          clearRuntimeErrors();
        },
        onClickNextButton: function onNext() {
          if (currentRuntimeErrorIndex === currentRuntimeErrors.length - 1) {
            return;
          }
          currentRuntimeErrorIndex += 1;
          ensureRootExists(render);
        },
        onClickPrevButton: function onPrev() {
          if (currentRuntimeErrorIndex === 0) {
            return;
          }
          currentRuntimeErrorIndex -= 1;
          ensureRootExists(render);
        },
      });
    }
  });
}

/**
 * Destroys the state of the overlay.
 * @returns {void}
 */
function cleanup() {
  // Clean up and reset all internal state.
  document.body.removeChild(iframeRoot);
  scheduledRenderFn = null;
  root = null;
  iframeRoot = null;
}

/**
 * Clears Webpack compilation errors and dismisses the compile error overlay.
 * @returns {void}
 */
function clearCompileError() {
  if (!root || currentMode !== 'compileError') {
    return;
  }

  currentCompileErrorMessage = '';
  currentMode = null;
  cleanup();
}

/**
 * Clears runtime error records and dismisses the runtime error overlay.
 * @param {boolean} [dismissOverlay] Whether to dismiss the overlay or not.
 * @returns {void}
 */
function clearRuntimeErrors(dismissOverlay) {
  if (!root || currentMode !== 'runtimeError') {
    return;
  }

  currentRuntimeErrorIndex = 0;
  currentRuntimeErrors = [];

  if (typeof dismissOverlay === 'undefined' || dismissOverlay) {
    currentMode = null;
    cleanup();
  }
}

/**
 * Shows the compile error overlay with the specific Webpack error message.
 * @param {string} message
 * @returns {void}
 */
function showCompileError(message) {
  if (!message) {
    return;
  }

  currentCompileErrorMessage = message;

  render();
}

/**
 * Shows the runtime error overlay with the specific error records.
 * @param {Error[]} errors
 * @returns {void}
 */
function showRuntimeErrors(errors) {
  if (!errors || !errors.length) {
    return;
  }

  currentRuntimeErrors = errors;

  render();
}

/**
 * The debounced version of `showRuntimeErrors` to prevent frequent renders
 * due to rapid firing listeners.
 * @param {Error[]} errors
 * @returns {void}
 */
const debouncedShowRuntimeErrors = utils.debounce(showRuntimeErrors, 30);

/**
 * Detects if an error is a Webpack compilation error.
 * @param {Error} error The error of interest.
 * @returns {boolean} If the error is a Webpack compilation error.
 */
function isWebpackCompileError(error) {
  return /Module [A-z ]+\(from/.test(error.message) || /Cannot find module/.test(error.message);
}

/**
 * Handles runtime error contexts captured with EventListeners.
 * Integrates with a runtime error overlay.
 * @param {Error} error A valid error object.
 * @returns {void}
 */
function handleRuntimeError(error) {
  if (error && !isWebpackCompileError(error) && currentRuntimeErrors.indexOf(error) === -1) {
    currentRuntimeErrors = currentRuntimeErrors.concat(error);
  }
  debouncedShowRuntimeErrors(currentRuntimeErrors);
}

module.exports = Object.freeze({
  clearCompileError: clearCompileError,
  clearRuntimeErrors: clearRuntimeErrors,
  handleRuntimeError: handleRuntimeError,
  showCompileError: showCompileError,
  showRuntimeErrors: showRuntimeErrors,
});