Repository: mapbox/batfish
Pull request #258 (2018-03-25 18:42:59)

A few small improvements
A few commits of smallish improvements.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aed0eb4..6f46dc6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## Head
+
+- **Add:** Improve logging by adding approximate times for build phases.
+- **Add:** Helpful default 404 page (in development mode only).
+- **Fix:** If you provide an `applicationWrapper` in `spa` mode, it will be included in the static HTML build.
+
 ## 1.6.2
 
 - **Fix:** Add several packages to `dependencies` which were mistakingly added to `devDependencies`.
diff --git a/flow-typed/defs.js b/flow-typed/defs.js
index 64ebb7a..ef67ea5 100644
--- a/flow-typed/defs.js
+++ b/flow-typed/defs.js
@@ -64,7 +64,8 @@ declare type InlineJsEntry = { filename: string, uglify?: boolean };
 declare type BatfishPageData = {
   filePath: string,
   path: string,
-  frontMatter: { [string]: JsonValue }
+  frontMatter: { [string]: JsonValue },
+  is404?: boolean
 };
 
 declare type BatfishSiteData = {
diff --git a/flow-typed/webpack.js b/flow-typed/webpack.js
index 81c748d..2b9cdaf 100644
--- a/flow-typed/webpack.js
+++ b/flow-typed/webpack.js
@@ -9,7 +9,9 @@
 declare type webpack$Stats = {
   hash: string,
   hasErrors(): boolean,
-  toJson(): JsonValue
+  toJson(): JsonValue,
+  endTime: number,
+  startTime: number
 };
 
 declare type webpack$Condition =
diff --git a/src/node/append-task-time.js b/src/node/append-task-time.js
new file mode 100644
index 0000000..b7ee032
--- /dev/null
+++ b/src/node/append-task-time.js
@@ -0,0 +1,17 @@
+// @flow
+'use strict';
+
+const prettyMs = require('pretty-ms');
+const now = require('./now');
+
+module.exports = function appendTaskTime(
+  message: string,
+  startTime?: number,
+  endTime?: number
+): string {
+  if (startTime === undefined) {
+    return message;
+  }
+  endTime = endTime !== undefined ? endTime : now();
+  return `${message} in ${prettyMs(endTime - startTime)}`;
+};
diff --git a/src/node/build.js b/src/node/build.js
index b8ac181..e4363e9 100644
--- a/src/node/build.js
+++ b/src/node/build.js
@@ -7,6 +7,7 @@ const EventEmitter = require('events');
 const createWebpackConfigClient = require('./create-webpack-config-client');
 const createWebpackConfigStatic = require('./create-webpack-config-static');
 const validateConfig = require('./validate-config');
+const appendTaskTime = require('./append-task-time');
 const inlineCss = require('./inline-css');
 const generateSitemap = require('./generate-sitemap');
 const compileStylesheets = require('./compile-stylesheets');
@@ -16,6 +17,7 @@ const nonPageFiles = require('./non-page-files');
 const writeWebpackStats = require('./write-webpack-stats');
 const buildHtml = require('./build-html');
 const webpackCompilePromise = require('./webpack-compile-promise');
+const now = require('./now');
 
 function build(rawConfig?: Object, projectDirectory?: string): EventEmitter {
   // Default production to true when building.
@@ -52,76 +54,109 @@ function build(rawConfig?: Object, projectDirectory?: string): EventEmitter {
   });
 
   const buildClient = (): Promise<void> => {
+    emitNotification('Bundling files for the browser ...');
     return createWebpackConfigClient(tailoredBatfishConfig)
       .then(webpackCompilePromise)
       .then(stats => {
+        emitNotification(
+          appendTaskTime(
+            'Finished bundling files for the browser',
+            stats.startTime,
+            stats.endTime
+          )
+        );
         return writeWebpackStats(outputDirectory, stats);
       });
   };
 
-  const buildStatic = (): Promise<webpack$Stats> => {
-    return createWebpackConfigStatic(tailoredBatfishConfig).then(
-      webpackCompilePromise
-    );
+  const buildStatic = (): Promise<void> => {
+    emitNotification('Processing files for static HTML ...');
+    return createWebpackConfigStatic(tailoredBatfishConfig)
+      .then(webpackCompilePromise)
+      .then(stats => {
+        emitNotification(
+          appendTaskTime(
+            'Finished processing files for static HTML',
+            stats.startTime,
+            stats.endTime
+          )
+        );
+      });
   };
 
   // The compiled CSS filename will differ depending on whether this is a
   // production build or not. So it needs to be a variable.
   let cssFilename;
+
+  const inlineCssInHtml = (): Promise<void> => {
+    if (!cssFilename) {
+      return Promise.resolve();
+    }
+    return inlineCss(outputDirectory, cssFilename, {
+      verbose: batfishConfig.verbose,
+      onNotification: emitNotification
+    });
+  };
+
+  const logTask = (
+    description: string,
+    task: () => Promise<void>
+  ): Promise<void> => {
+    emitNotification(`${_.capitalize(description)} ...`);
+    const startTime = now();
+    return task().then(() => {
+      emitNotification(appendTaskTime(`Finished ${description}`, startTime));
+    });
+  };
+
+  const buildCss = (): Promise<void> => {
+    return compileStylesheets(batfishConfig, assetsDirectory).then(filename => {
+      cssFilename = filename;
+    });
+  };
+
+  const buildStartTime = now();
+  setImmediate(() => {
+    emitNotification('Starting build ...');
+  });
+
   maybeClearOutputDirectory(batfishConfig)
     .then(() => {
       if (stylesheetsIsEmpty) return;
-      emitNotification('Compiling CSS.');
-      return compileStylesheets(batfishConfig, assetsDirectory).then(
-        filename => {
-          cssFilename = filename;
-        }
-      );
-    })
-    .then(() => {
-      emitNotification('Starting the Webpack bundling.');
-      emitNotification('Creating the client bundle.');
-      return buildClient();
-    })
-    .then(() => {
-      emitNotification('Creating the static-page-rendering Node bundle.');
-      return buildStatic();
+      return logTask('compiling css', buildCss);
     })
+    .then(() => buildClient())
+    .then(() => buildStatic())
     .then(() => {
-      emitNotification('Copying unprocessed files.');
-      return nonPageFiles.copy(batfishConfig);
+      return logTask('copying non-page files', () => {
+        return nonPageFiles.copy(batfishConfig);
+      });
     })
     .then(() => {
-      emitNotification('Building HTML.');
-      return buildHtml(batfishConfig, cssFilename);
+      return logTask('writing static HTML', () => {
+        return buildHtml(batfishConfig, cssFilename);
+      });
     })
     .then(() => {
-      if (!batfishConfig.production || !batfishConfig.staticHtmlInlineDeferCss)
+      if (
+        !batfishConfig.production ||
+        !batfishConfig.staticHtmlInlineDeferCss ||
+        stylesheetsIsEmpty
+      ) {
         return;
-      return new Promise((resolve, reject) => {
-        // This line within the callback function scope to please Flow about
-        // the cssFilename variable.
-        if (stylesheetsIsEmpty || !cssFilename) return resolve();
-        const inlineCssEmitter = inlineCss(outputDirectory, cssFilename, {
-          verbose: batfishConfig.verbose
-        });
-        inlineCssEmitter.on(constants.EVENT_NOTIFICATION, emitNotification);
-        inlineCssEmitter.on(constants.EVENT_ERROR, reject);
-        inlineCssEmitter.on(constants.EVENT_DONE, resolve);
+      }
+      return logTask('inlining CSS in static HTML', () => {
+        return inlineCssInHtml();
       });
     })
     .then(() => {
-      if (!batfishConfig.sitemap) return;
-      if (batfishConfig.siteOrigin) {
-        emitNotification('Building the sitemap.');
+      if (!batfishConfig.sitemap || !batfishConfig.siteOrigin) return;
+      return logTask('building sitemap', () => {
         return generateSitemap(batfishConfig);
-      } else {
-        emitNotification(
-          'siteOrigin is not specified; unable to generate sitemap.'
-        );
-      }
+      });
     })
     .then(() => {
+      emitNotification(appendTaskTime('Finished build', buildStartTime));
       emitter.emit(constants.EVENT_DONE);
     })
     .catch(emitError);
diff --git a/src/node/get-pages-data.js b/src/node/get-pages-data.js
index ddbf88e..bb76a54 100644
--- a/src/node/get-pages-data.js
+++ b/src/node/get-pages-data.js
@@ -5,7 +5,6 @@ const _ = require('lodash');
 const fs = require('fs');
 const path = require('path');
 const pify = require('pify');
-const pTry = require('p-try');
 const globby = require('globby');
 const micromatch = require('micromatch');
 const grayMatter = require('gray-matter');
@@ -22,79 +21,93 @@ const wrapError = require('./wrap-error');
 function getPagesData(
   batfishConfig: BatfishConfiguration
 ): Promise<{ [string]: BatfishPageData }> {
-  return pTry(() => {
-    const base = batfishConfig.siteBasePath;
-    const pagesGlob = [
-      path.join(batfishConfig.pagesDirectory, `**/*.${constants.PAGE_EXT_GLOB}`)
-    ];
+  // Convert a page's file path to its URL path.
+  const pageFilePathToUrlPath = (filePath: string): string => {
+    const relativePath = path.relative(batfishConfig.pagesDirectory, filePath);
+    if (/^index\.(js|md)$/.test(relativePath)) {
+      if (base === '/') return base;
+      return base + '/';
+    }
+    if (/\/index\.(js|md)$/.test(relativePath)) {
+      return joinUrlParts(base, path.dirname(relativePath), '');
+    }
+    return joinUrlParts(base, relativePath.replace(/\.(js|md)$/, ''), '');
+  };
 
-    // Convert a page's file path to its URL path.
-    const pageFilePathToUrlPath = (filePath: string): string => {
-      const relativePath = path.relative(
-        batfishConfig.pagesDirectory,
-        filePath
-      );
-      if (/^index\.(js|md)$/.test(relativePath)) {
-        if (base === '/') return base;
-        return base + '/';
+  let pagesData = {};
+  const registerPage = (filePath: string): Promise<void> => {
+    const is404 =
+      filePath.replace(/\.(js|md)$/, '') ===
+      path.join(batfishConfig.pagesDirectory, '404');
+
+    return pify(fs.readFile)(filePath, 'utf8').then((content: string) => {
+      const isMarkdown = path.extname(filePath) === '.md';
+      const grayMatterOptions = isMarkdown
+        ? { delims: ['---', '---'] }
+        : { delims: ['/*---', '---*/'] };
+      let parsedFrontMatter;
+      try {
+        parsedFrontMatter = grayMatter(content, grayMatterOptions);
+      } catch (parseError) {
+        throw wrapError(parseError, errorTypes.FrontMatterError, {
+          filePath
+        });
       }
-      if (/\/index\.(js|md)$/.test(relativePath)) {
-        return joinUrlParts(base, path.dirname(relativePath), '');
+      const published = parsedFrontMatter.data.published !== false;
+      if (!published && batfishConfig.production) return;
+
+      const pagePath = pageFilePathToUrlPath(filePath);
+      const pageData: BatfishPageData = {
+        filePath,
+        path: pagePath,
+        frontMatter: parsedFrontMatter.data
+      };
+      if (is404) {
+        pageData.is404 = true;
+        pageData.path = '/404/';
       }
-      return joinUrlParts(base, relativePath.replace(/\.(js|md)$/, ''), '');
-    };
+      pagesData[pagePath] = pageData;
+    });
+  };
 
-    let pagesData = {};
-    const registerPage = (filePath: string): Promise<void> => {
-      return pify(fs.readFile)(filePath, 'utf8').then((content: string) => {
-        const isMarkdown = path.extname(filePath) === '.md';
-        const grayMatterOptions = isMarkdown
-          ? { delims: ['---', '---'] }
-          : { delims: ['/*---', '---*/'] };
-        let parsedFrontMatter;
-        try {
-          parsedFrontMatter = grayMatter(content, grayMatterOptions);
-        } catch (parseError) {
-          throw wrapError(parseError, errorTypes.FrontMatterError, {
-            filePath
-          });
-        }
-        const published = parsedFrontMatter.data.published !== false;
-        if (!published && batfishConfig.production) return;
+  const base = batfishConfig.siteBasePath;
+  const pagesGlob = [
+    path.join(batfishConfig.pagesDirectory, `**/*.${constants.PAGE_EXT_GLOB}`)
+  ];
 
-        const pagePath = pageFilePathToUrlPath(filePath);
-        const pageData = {
-          filePath,
-          path: pagePath,
-          frontMatter: parsedFrontMatter.data
+  return globby(pagesGlob)
+    .then(pageFilePaths => {
+      // Filter out any unprocessedPageFiles
+      if (batfishConfig.unprocessedPageFiles) {
+        const unprocessed = micromatch(
+          pageFilePaths,
+          batfishConfig.unprocessedPageFiles
+        );
+        return _.difference(pageFilePaths, unprocessed);
+      } else {
+        return pageFilePaths;
+      }
+    })
+    .then(pageFilePaths => {
+      if (batfishConfig.spa && pageFilePaths.length > 1) {
+        throw new errorTypes.ConfigFatalError(
+          'In your Batfish config you set `spa: true`, but you have more than one page. SPA mode only allows one Batfish page.'
+        );
+      }
+      return Promise.all(pageFilePaths.map(registerPage));
+    })
+    .then(() => {
+      if (!pagesData['/404/'] && !batfishConfig.production) {
+        pagesData['/404/'] = {
+          filePath: path.join(__dirname, '../webpack/default-not-found.js'),
+          path: '/404/',
+          is404: true,
+          frontMatter: {}
         };
-        pagesData[pagePath] = pageData;
-      });
-    };
+      }
 
-    return globby(pagesGlob)
-      .then(pageFilePaths => {
-        // Filter out any unprocessedPageFiles
-        if (batfishConfig.unprocessedPageFiles) {
-          const unprocessed = micromatch(
-            pageFilePaths,
-            batfishConfig.unprocessedPageFiles
-          );
-          return _.difference(pageFilePaths, unprocessed);
-        } else {
-          return pageFilePaths;
-        }
-      })
-      .then(pageFilePaths => {
-        if (batfishConfig.spa && pageFilePaths.length > 1) {
-          throw new errorTypes.ConfigFatalError(
-            'In your Batfish config you set `spa: true`, but you have more than one page. SPA mode only allows one Batfish page.'
-          );
-        }
-        return Promise.all(pageFilePaths.map(registerPage));
-      })
-      .then(() => pagesData);
-  });
+      return pagesData;
+    });
 }
 
 module.exports = getPagesData;
diff --git a/src/node/inline-css.js b/src/node/inline-css.js
index eab6610..8ffe0a6 100644
--- a/src/node/inline-css.js
+++ b/src/node/inline-css.js
@@ -3,12 +3,10 @@
 
 const path = require('path');
 const globby = require('globby');
-const EventEmitter = require('events');
 const workerFarm = require('worker-farm');
 const workers = workerFarm(require.resolve('./inline-css-worker'));
 const wrapError = require('./wrap-error');
 const errorTypes = require('./error-types');
-const constants = require('./constants');
 
 // When errors emerge from the worker farm, they retain their name but lose
 // their type.
@@ -30,19 +28,12 @@ function reTypeError(error: *): Error {
 function inlineCss(
   htmlRoot: string,
   cssPath: string,
-  options?: { verbose?: boolean }
-): EventEmitter {
-  const defaultedOptions = Object.assign(
-    {
-      verbose: false
-    },
-    options
-  );
-  const emitter = new EventEmitter();
-  const emitNotification = message => {
-    emitter.emit(constants.EVENT_NOTIFICATION, message);
-  };
-
+  options: {
+    onNotification: string => void,
+    verbose?: boolean
+  }
+): Promise<void> {
+  const { onNotification, verbose } = options;
   // This number is determined below, after the glob is analyzed.
   let totalHtmlFiles;
   let processesStarted = 0;
@@ -51,8 +42,8 @@ function inlineCss(
     processesStarted += 1;
     const isLastChunk = processesStarted === totalHtmlFiles;
     return new Promise((resolve, reject) => {
-      if (defaultedOptions.verbose) {
-        emitNotification(`Inline CSS for ${htmlPath}.`);
+      if (verbose) {
+        onNotification(`Inline CSS for ${htmlPath}.`);
       }
       workers(htmlPath, cssPath, (error: Error, output: string) => {
         if (isLastChunk) {
@@ -61,8 +52,8 @@ function inlineCss(
         if (error) {
           return reject(error);
         }
-        if (output && defaultedOptions.verbose) {
-          emitNotification(output);
+        if (output && verbose) {
+          onNotification(output);
         }
         resolve();
       });
@@ -70,20 +61,18 @@ function inlineCss(
   };
 
   const pagesGlob = path.join(htmlRoot, '**/*.html');
-  globby(pagesGlob)
+
+  return globby(pagesGlob)
     .then(htmlPaths => {
       totalHtmlFiles = htmlPaths.length;
-      emitNotification(`Inlining CSS in ${htmlPaths.length} files.`);
       return Promise.all(htmlPaths.map(processPage));
     })
-    .then(() => {
-      emitter.emit('done');
-    })
-    .catch(error => {
-      emitter.emit('error', reTypeError(error));
-    });
-
-  return emitter;
+    .then(
+      () => {},
+      error => {
+        throw reTypeError(error);
+      }
+    );
 }
 
 module.exports = inlineCss;
diff --git a/src/node/non-page-files.js b/src/node/non-page-files.js
index 5496c0f..9de12e6 100644
--- a/src/node/non-page-files.js
+++ b/src/node/non-page-files.js
@@ -4,7 +4,8 @@
 const cpy = require('cpy');
 const del = require('del');
 const chokidar = require('chokidar');
-const prettyMs = require('pretty-ms');
+const appendTaskTime = require('./append-task-time');
+const now = require('./now');
 
 function getNonPageFileGlob(
   batfishConfig: BatfishConfiguration
@@ -48,26 +49,22 @@ function watch(
   });
 
   const handleChangeAdd = filename => {
-    let startTime = Date.now();
+    const startTime = now();
     cpy(filename, batfishConfig.outputDirectory, {
       parents: true,
       cwd: batfishConfig.pagesDirectory
     }).then(() => {
       if (onNotification !== undefined) {
-        onNotification(
-          `${filename} copied in ${prettyMs(Date.now() - startTime)}.`
-        );
+        onNotification(appendTaskTime(`${filename} copied`, startTime));
       }
     }, onError);
   };
 
   const handleUnlink = filename => {
-    let startTime = Date.now();
+    const startTime = now();
     del(filename, { cwd: batfishConfig.outputDirectory }).then(() => {
       if (onNotification !== undefined) {
-        onNotification(
-          `${filename} deleted in ${prettyMs(Date.now() - startTime)}.`
-        );
+        onNotification(appendTaskTime(`${filename} deleted`, startTime));
       }
     }, onError);
   };
diff --git a/src/node/now.js b/src/node/now.js
new file mode 100644
index 0000000..c20db8e
--- /dev/null
+++ b/src/node/now.js
@@ -0,0 +1,6 @@
+// @flow
+'use strict';
+
+module.exports = function now(): number {
+  return Date.now();
+};
diff --git a/src/node/watch-css.js b/src/node/watch-css.js
index 627eacd..db2fde1 100644
--- a/src/node/watch-css.js
+++ b/src/node/watch-css.js
@@ -3,8 +3,9 @@
 
 const _ = require('lodash');
 const chokidar = require('chokidar');
-const prettyMs = require('pretty-ms');
+const appendTaskTime = require('./append-task-time');
 const compileStylesheets = require('./compile-stylesheets');
+const now = require('./now');
 
 function watchCss(
   batfishConfig: BatfishConfiguration,
@@ -18,10 +19,10 @@ function watchCss(
 
   const cssWatcher = chokidar.watch(batfishConfig.stylesheets);
   cssWatcher.on('change', () => {
-    let startTime = Date.now();
+    let startTime = now();
     compileStylesheets(batfishConfig).then(() => {
       if (onNotification !== undefined) {
-        onNotification(`CSS compiled in ${prettyMs(Date.now() - startTime)}.`);
+        onNotification(appendTaskTime('CSS compiled', startTime));
       }
     }, onError);
   });
diff --git a/src/node/watch-webpack.js b/src/node/watch-webpack.js
index 52084d5..353a04c 100644
--- a/src/node/watch-webpack.js
+++ b/src/node/watch-webpack.js
@@ -7,7 +7,7 @@ const path = require('path');
 const chalk = require('chalk');
 const webpackMerge = require('webpack-merge');
 const HtmlWebpackPlugin = require('html-webpack-plugin');
-const prettyMs = require('pretty-ms');
+const appendTaskTime = require('./append-task-time');
 const errorTypes = require('./error-types');
 const wrapError = require('./wrap-error');
 const constants = require('./constants');
@@ -88,11 +88,9 @@ function watchWebpack(
             })
           );
         }
-        const timing =
-          stats.endTime && stats.startTime
-            ? ` in ${prettyMs(stats.endTime - stats.startTime)}`
-            : '';
-        onNotification(`Webpack compiled${timing}.`);
+        onNotification(
+          appendTaskTime('Webpack compiled', stats.startTime, stats.endTime)
+        );
       };
 
       compiler.watch(
diff --git a/src/node/write-context-module.js b/src/node/write-context-module.js
index 7e88fff..7f9348d 100644
--- a/src/node/write-context-module.js
+++ b/src/node/write-context-module.js
@@ -65,11 +65,10 @@ function writeContextModule(
 
       return writePageModule(batfishConfig, pageData).then(
         pageModuleFilePath => {
-          const is404 =
-            pageData.filePath.replace(/\.(js|md)$/, '') ===
-            path.join(batfishConfig.pagesDirectory, '404');
-          const chunkName = is404 ? 'not-found' : slugg(pagePath) || 'home';
-          const is404Property = is404 ? 'is404: true,' : '';
+          const chunkName = pageData.is404
+            ? 'not-found'
+            : slugg(pagePath) || 'home';
+          const is404Property = pageData.is404 ? 'is404: true,' : '';
           // "eager" mode (as opposed to the default "lazy" mode) means the import
           // will not create a separate async chunk, but will be bundled up with
           // its parent.
@@ -88,7 +87,7 @@ function writeContextModule(
             ${is404Property}
           }`;
 
-          if (is404) {
+          if (pageData.is404) {
             notFoundStringifiedRouteData = stringifiedRouteData;
           }
 
diff --git a/src/webpack/default-not-found.js b/src/webpack/default-not-found.js
new file mode 100644
index 0000000..67f8b29
--- /dev/null
+++ b/src/webpack/default-not-found.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { prefixUrl } from '@mapbox/batfish/modules/prefix-url';
+import { withLocation } from './public/with-location';
+
+class DefaultNotFound extends React.Component {
+  static propTypes = {
+    location: PropTypes.shape({ pathname: PropTypes.string.isRequired })
+      .isRequired
+  };
+
+  render() {
+    return (
+      <div style={{ fontFamily: 'Arial, sans-serif' }}>
+        <h1>Route not found</h1>
+        <p>
+          Batfish can't find a route for{' '}
+          <span style={{ color: 'red', textDecoration: 'underline' }}>
+            {this.props.location.pathname}
+          </span>.
+        </p>
+        <p>
+          <a href={prefixUrl('/')} style={{ fontWeight: 'bold' }}>
+            Go somewhere safe
+          </a>
+        </p>
+        <p style={{ fontSize: '0.8em', marginTop: '2em' }}>
+          This is Batfish's default <em>development only</em> 404 page.
+          <br />
+          It will not appear in production builds.
+        </p>
+        <div aria-hidden={true}>
+          <code style={{ fontSize: '3em' }}>(•́﹏•̀)</code>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default withLocation(DefaultNotFound);
diff --git a/src/webpack/render-html-page.js b/src/webpack/render-html-page.js
index 3c2ebbd..8f9833a 100644
--- a/src/webpack/render-html-page.js
+++ b/src/webpack/render-html-page.js
@@ -2,9 +2,9 @@
 import React from 'react';
 import ReactDOMServer from 'react-dom/server';
 import Helmet from 'react-helmet';
-import ApplicationWrapper from 'batfish-internal/application-wrapper';
+import { BatfishApp } from './batfish-app';
+import { BatfishSpaApp } from './batfish-spa-app';
 import { StaticHtmlPage } from './static-html-page';
-import { Router } from './router';
 import constants from '../node/constants';
 
 export function renderHtmlPage(options: {
@@ -22,16 +22,10 @@ export function renderHtmlPage(options: {
     // by React.
     let pageContent;
     if (options.spa) {
-      pageContent = React.createElement(pageModule.component, pageModule.props);
+      pageContent = <BatfishSpaApp pageModule={pageModule} />;
     } else {
       pageContent = (
-        <ApplicationWrapper>
-          <Router
-            startingPath={options.route.path}
-            startingComponent={pageModule.component}
-            startingProps={pageModule.props}
-          />
-        </ApplicationWrapper>
+        <BatfishApp startingPath={options.route.path} pageModule={pageModule} />
       );
     }
     const rawAppHtml = ReactDOMServer.renderToString(pageContent);


Test patch:
diff --git a/test/__snapshots__/build.test.js.snap b/test/__snapshots__/build.test.js.snap
index 51a0299..009c075 100644
--- a/test/__snapshots__/build.test.js.snap
+++ b/test/__snapshots__/build.test.js.snap
@@ -2,25 +2,36 @@
 
 exports[`build order of notifications, *with* CSS inlining and sitemap 1`] = `
 Array [
-  "Compiling CSS.",
-  "Starting the Webpack bundling.",
-  "Creating the client bundle.",
-  "Creating the static-page-rendering Node bundle.",
-  "Copying unprocessed files.",
-  "Building HTML.",
+  "Compiling css ...",
+  "Finished compiling css in 0ms",
+  "Bundling files for the browser ...",
+  "Finished bundling files for the browser in 5ms",
+  "Processing files for static HTML ...",
+  "Finished processing files for static HTML in 5ms",
+  "Copying non-page files ...",
+  "Finished copying non-page files in 0ms",
+  "Writing static html ...",
+  "Finished writing static HTML in 0ms",
+  "Inlining css in static html ...",
   "CSS inlining message 1",
   "CSS inlining message 2",
-  "Building the sitemap.",
+  "Finished inlining CSS in static HTML in 0ms",
+  "Building sitemap ...",
+  "Finished building sitemap in 0ms",
+  "Finished build in 0ms",
 ]
 `;
 
 exports[`build order of notifications, without CSS inlining or sitemap 1`] = `
 Array [
-  "Starting the Webpack bundling.",
-  "Creating the client bundle.",
-  "Creating the static-page-rendering Node bundle.",
-  "Copying unprocessed files.",
-  "Building HTML.",
-  "siteOrigin is not specified; unable to generate sitemap.",
+  "Bundling files for the browser ...",
+  "Finished bundling files for the browser in 5ms",
+  "Processing files for static HTML ...",
+  "Finished processing files for static HTML in 5ms",
+  "Copying non-page files ...",
+  "Finished copying non-page files in 0ms",
+  "Writing static html ...",
+  "Finished writing static HTML in 0ms",
+  "Finished build in 0ms",
 ]
 `;
diff --git a/test/__snapshots__/render-html-page.test.js.snap b/test/__snapshots__/render-html-page.test.js.snap
index ce80cab..8f7b81c 100644
--- a/test/__snapshots__/render-html-page.test.js.snap
+++ b/test/__snapshots__/render-html-page.test.js.snap
@@ -2,7 +2,7 @@
 
 exports[`renderHtmlPage SPA mode 1`] = `
 "<!doctype html><div id=\\"static-html-page\\" data-props=\\"{
-  \\"rawAppHtml\\": \\"<div data-reactroot=\\\\\\"\\\\\\">Mock component</div>\\",
+  \\"rawAppHtml\\": \\"<div id=\\\\\\"application-wrapper\\\\\\" data-reactroot=\\\\\\"\\\\\\">{}<div>Mock component</div></div>\\",
   \\"htmlAttributes\\": \\"htmlAttributes.mockToComponent\\",
   \\"bodyAttributes\\": \\"bodyAttributes.mockToComponent\\",
   \\"appendToHead\\": [
diff --git a/test/build.test.js b/test/build.test.js
index e1d5ea1..cca89fb 100644
--- a/test/build.test.js
+++ b/test/build.test.js
@@ -15,13 +15,10 @@ const buildHtml = require('../src/node/build-html');
 const webpackCompilePromise = require('../src/node/webpack-compile-promise');
 const validateConfig = require('../src/node/validate-config');
 
+jest.useFakeTimers();
+
 jest.mock('../src/node/inline-css', () => {
-  const mockEmitter = {
-    on: jest.fn()
-  };
-  const fn = jest.fn(() => mockEmitter);
-  fn.mockEmitter = mockEmitter;
-  return fn;
+  return jest.fn(() => Promise.resolve());
 });
 
 jest.mock('../src/node/generate-sitemap', () => {
@@ -59,7 +56,12 @@ jest.mock('../src/node/build-html', () => {
 });
 
 jest.mock('../src/node/webpack-compile-promise', () => {
-  return jest.fn(() => Promise.resolve());
+  return jest.fn(() =>
+    Promise.resolve({
+      startTime: 10,
+      endTime: 15
+    })
+  );
 });
 
 jest.mock('../src/node/validate-config', () => {
@@ -68,6 +70,8 @@ jest.mock('../src/node/validate-config', () => {
   return fn;
 });
 
+jest.mock('../src/node/now', () => jest.fn(() => 10));
+
 describe('build', () => {
   beforeEach(() => {
     validateConfig.mockValidatedConfig = {
@@ -416,31 +420,26 @@ describe('build', () => {
     const emitter = build();
     emitter.on(constants.EVENT_ERROR, logEmitterError);
     process.nextTick(() => {
-      expect(inlineCss).toHaveBeenCalledWith(
-        '/mock/output',
-        'mock-stylesheet.css',
-        {
-          verbose: false
-        }
+      expect(inlineCss).toHaveBeenCalledTimes(1);
+      expect(inlineCss.mock.calls[0][0]).toBe('/mock/output');
+      expect(inlineCss.mock.calls[0][1]).toBe('mock-stylesheet.css');
+      expect(inlineCss.mock.calls[0][2].verbose).toBe(false);
+      expect(inlineCss.mock.calls[0][2].onNotification).toBeInstanceOf(
+        Function
       );
       done();
     });
   });
 
   test('catches errors while inlining CSS', done => {
-    const expectedError = new Error();
     validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    const expectedError = new Error();
+    inlineCss.mockReturnValueOnce(Promise.reject(expectedError));
     const emitter = build();
     emitter.on(constants.EVENT_ERROR, error => {
       expect(error).toBe(expectedError);
       done();
     });
-    process.nextTick(() => {
-      const errorHandler = inlineCss.mockEmitter.on.mock.calls.find(
-        call => call[0] === constants.EVENT_ERROR
-      )[1];
-      errorHandler(expectedError);
-    });
   });
 
   test('handles notifications while inlining CSS', done => {
@@ -448,32 +447,12 @@ describe('build', () => {
     const emitter = build();
     emitter.on(constants.EVENT_ERROR, logEmitterError);
     process.nextTick(() => {
-      const notificationHandler = inlineCss.mockEmitter.on.mock.calls.find(
-        call => call[0] === constants.EVENT_NOTIFICATION
-      )[1];
       emitter.on(constants.EVENT_NOTIFICATION, message => {
-        expect(message).toBe('xyz');
-        done();
+        if (message === 'xyz') {
+          done();
+        }
       });
-      notificationHandler('xyz');
-    });
-  });
-
-  test('does not finish until the inlining CSS fires its done event', done => {
-    validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
-    const emitter = build();
-    emitter.on(constants.EVENT_ERROR, logEmitterError);
-    let buildDone = false;
-    emitter.on(constants.EVENT_DONE, () => {
-      buildDone = true;
-      done();
-    });
-    process.nextTick(() => {
-      const doneHandler = inlineCss.mockEmitter.on.mock.calls.find(
-        call => call[0] === constants.EVENT_DONE
-      )[1];
-      expect(buildDone).toBe(false);
-      doneHandler();
+      inlineCss.mock.calls[0][2].onNotification('xyz');
     });
   });
 
@@ -507,14 +486,13 @@ describe('build', () => {
     });
   });
 
-  test('if config does not include siteOrigin, does not create a sitemap and emits a notification', done => {
+  test('if config does not include siteOrigin, does not create a sitemap', done => {
     validateConfig.mockValidatedConfig.siteOrigin = undefined;
     const emitter = build();
     emitter.on(constants.EVENT_ERROR, logEmitterError);
-    emitter.on(constants.EVENT_NOTIFICATION, message => {
-      if (/unable to generate sitemap/.test(message)) {
-        done();
-      }
+    process.nextTick(() => {
+      expect(generateSitemap).not.toHaveBeenCalled();
+      done();
     });
   });
 
@@ -544,6 +522,11 @@ describe('build', () => {
 
   test('order of notifications, *with* CSS inlining and sitemap', done => {
     validateConfig.mockValidatedConfig.stylesheets = ['one.css', 'two.css'];
+    inlineCss.mockImplementationOnce((a, b, { onNotification }) => {
+      onNotification('CSS inlining message 1');
+      onNotification('CSS inlining message 2');
+      return Promise.resolve();
+    });
     const notifications = [];
     const emitter = build();
     emitter.on(constants.EVENT_ERROR, logEmitterError);
@@ -554,17 +537,6 @@ describe('build', () => {
       expect(notifications).toMatchSnapshot();
       done();
     });
-    process.nextTick(() => {
-      const cssInliningNotificationHandler = inlineCss.mockEmitter.on.mock.calls.find(
-        call => call[0] === constants.EVENT_NOTIFICATION
-      )[1];
-      cssInliningNotificationHandler('CSS inlining message 1');
-      cssInliningNotificationHandler('CSS inlining message 2');
-      const cssInliningDoneHandler = inlineCss.mockEmitter.on.mock.calls.find(
-        call => call[0] === constants.EVENT_DONE
-      )[1];
-      cssInliningDoneHandler();
-    });
   });
 });
 
diff --git a/test/fixtures/get-pages-data-404-js/404.js b/test/fixtures/get-pages-data-404-js/404.js
new file mode 100644
index 0000000..29d5893
--- /dev/null
+++ b/test/fixtures/get-pages-data-404-js/404.js
@@ -0,0 +1,5 @@
+/*---
+title: Not found
+---*/
+
+// Some front matter.
diff --git a/test/fixtures/get-pages-data-404-js/index.md b/test/fixtures/get-pages-data-404-js/index.md
new file mode 100644
index 0000000..432b1a9
--- /dev/null
+++ b/test/fixtures/get-pages-data-404-js/index.md
@@ -0,0 +1,1 @@
+Nothing.
\ No newline at end of file
diff --git a/test/fixtures/get-pages-data-404-md/404.md b/test/fixtures/get-pages-data-404-md/404.md
new file mode 100644
index 0000000..da9ec29
--- /dev/null
+++ b/test/fixtures/get-pages-data-404-md/404.md
@@ -0,0 +1,5 @@
+---
+title: Not found
+---
+
+// Some front matter.
diff --git a/test/fixtures/get-pages-data-404-md/index.md b/test/fixtures/get-pages-data-404-md/index.md
new file mode 100644
index 0000000..432b1a9
--- /dev/null
+++ b/test/fixtures/get-pages-data-404-md/index.md
@@ -0,0 +1,1 @@
+Nothing.
\ No newline at end of file
diff --git a/test/get-pages-data.test.js b/test/get-pages-data.test.js
index 9ada0b7..ac59d85 100644
--- a/test/get-pages-data.test.js
+++ b/test/get-pages-data.test.js
@@ -177,4 +177,59 @@ describe('getPagesData', () => {
       expect(result['/about/']).toBeUndefined();
     });
   });
+
+  test('includes the default 404 page in development mode', () => {
+    const config = validateConfig({
+      pagesDirectory: fixtureDir
+    });
+    return getPagesData(config).then(result => {
+      expect(result['/404/']).not.toBeUndefined();
+      expect(result['/404/'].is404).toBe(true);
+      expect(result['/404/'].filePath).toMatch(/default-not-found\.js$/);
+    });
+  });
+
+  test('does not include the default 404 page in production mode', () => {
+    const config = validateConfig({
+      pagesDirectory: fixtureDir,
+      production: true
+    });
+    return getPagesData(config).then(result => {
+      expect(result['/404/']).toBeUndefined();
+    });
+  });
+
+  test('does not include the default 404 page if there is a custom 404 JS page', () => {
+    const config = validateConfig({
+      pagesDirectory: path.join(__dirname, 'fixtures/get-pages-data-404-js')
+    });
+    return getPagesData(config).then(result => {
+      expect(result['/404/']).not.toBeUndefined();
+      expect(result['/404/'].is404).toBe(true);
+      expect(result['/404/'].filePath).not.toMatch(/default-not-found\.js$/);
+      expect(result['/404/'].filePath).toMatch(
+        /get-pages-data-404-js\/404\.js$/
+      );
+      expect(result['/404/'].frontMatter).toEqual({
+        title: 'Not found'
+      });
+    });
+  });
+
+  test('does not include the default 404 page if there is a custom 404 Markdown page', () => {
+    const config = validateConfig({
+      pagesDirectory: path.join(__dirname, 'fixtures/get-pages-data-404-md')
+    });
+    return getPagesData(config).then(result => {
+      expect(result['/404/']).not.toBeUndefined();
+      expect(result['/404/'].is404).toBe(true);
+      expect(result['/404/'].filePath).not.toMatch(/default-not-found\.js$/);
+      expect(result['/404/'].filePath).toMatch(
+        /get-pages-data-404-md\/404\.md$/
+      );
+      expect(result['/404/'].frontMatter).toEqual({
+        title: 'Not found'
+      });
+    });
+  });
 });
diff --git a/test/write-context-module.test.js b/test/write-context-module.test.js
index 5bf08ae..ee6c503 100644
--- a/test/write-context-module.test.js
+++ b/test/write-context-module.test.js
@@ -132,7 +132,8 @@ describe('writeContextModule', () => {
         '/404/': {
           filePath: 'fake/pages/directory/404.js',
           path: '/404/',
-          frontMatter: {}
+          frontMatter: {},
+          is404: true
         }
       })
     );