Webpack from Nothing

Stack Traces and Line Numbers that Make Sense

We're getting a better and better setup for developing and deploying our applications. We can organize our JavaScript and CSS, we can use third party libraries for both, can deploy to production in a proper way, and run tests. But we're still missing something every other programming language has: stack traces.

Stack traces tell us where in our code errors are happening. In most programming languages, when an uncaught error happens, we see some information about what line of code raised the error, as well as the path through the code that led to that error. Even in a language like C, we can do this. Not so in JavaScript.

The reason is that the file we're editing is not the file that's being executed. Webpack is compiling all of our files together, as well as minifying them and including our third party libraries.

Let's see the problem in action.

Add a throw to the function in markdownPreviewer.js, after event.preventDefault().

Re-run Webpack and open up dev/index.html, then open the JavaScript console, and then click the “Preview” button:

Image of a useless stack trace

The error came from line 1 of our bundle. This is technically true, since our bundle is minified and all the code is on one line. Since we aren't editing this file directly, we have no idea where this error came from in our original source code.

The solution to this is a feature that most browsers support called sourcemaps.

Sourcemaps

If a sourcemap file exists, browsers know to look at it when giving you a stack trace (though you may need to enable this feature in your browser). The sourcemap lets the browser tell us that the problem wasn't in line 1 of our bundle, but on line 10 of markdownPreviewer.js.

Webpack can produce sourcemaps. The configuration option is, of course, not called something intuitive like sourceMap, but instead is called devtool.

The possible values for devtool are many, and poorly documented. Since we have different configurations for production and development, we can use different source map strategies. Let's try dev first as that's where we need the most help.

The docs' first recommandation is eval which, and I'm not making this up, is documented to not work at all:

This [eval] is pretty fast. The main disadvantage is that it doesn't display line numbers correctly

Like…why am I using source maps if I am also OK with the line numbers being wrong? That's the entire point of source maps. Ugh. Unfortunately, the second choice, eval-source-map works for JS and not for CSS. So, we'll go with inline-source-map which, so far, works for both. Add it to config/webpack.dev.config.js like so:

const path         = require('path');
const Merge        = require('webpack-merge');
const CommonConfig = require('./webpack.common.config.js');
const MiniCssExtractPlugin = require("mini-css-extract-plugin")

module.exports = Merge(CommonConfig, {
  output: {
    path: path.join(__dirname, '../dev'),
    filename: 'bundle.js'
  },
/* start new code */
  devtool: "inline-source-map",
/* end new code */
  plugins: [
    new MiniCssExtractPlugin({ filename: "styles.css" })
  ]
});

Run Webpack:

> yarn webpack
yarn run v1.13.0
$ webpack $npm_package_config_webpack_args
Hash: 3df951cd5a52cedbabb2
Version: webpack 4.29.6
Time: 622ms
Built at: 2019-03-30 10:25:35
     Asset       Size  Chunks             Chunk Names
 bundle.js    210 KiB       0  [emitted]  main
index.html  833 bytes          [emitted]  
styles.css    327 KiB       0  [emitted]  main
Entrypoint main = styles.css bundle.js
[0] ./js/index.js 421 bytes {0} [built]
[2] ./css/styles.css 39 bytes {0} [built]
[3] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 9 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [0] ./node_modules/html-webpack-plugin/lib/loader.js!./html/index.html 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!css/styles.css:
    Entrypoint mini-css-extract-plugin = *
    [0] ./node_modules/css-loader/dist/cjs.js!./css/styles.css 210 bytes {0} [built]
        + 1 hidden module
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!node_modules/tachyons/css/tachyons.css:
    Entrypoint mini-css-extract-plugin = *
       2 modules
Done in 1.25s.

And, if we follow the same steps in the browser, we'll see that we can now see the line number where our error is coming from!

Image of a useful stack trace

If you are in Chrome and click the stack, it even shows you the code where the error came from!

Image of code where the error originated

Nice!

Unfortunately, we cannot enable source maps in production. While it is documented to work, it actually doesn't. The documentation provides for several different production-quality source map configurations and none of them produce usable source maps at this time.

The reason this is so bad is that in a real production application, we will have some level of error monitoring. We need to know if there are real errors in the front-end and where they are happening. Without source maps we cannot know that (especially when minifying).

This means that for production we either use a development-style source map (which increases our bundle size), or we don't get source maps at all. Welp.

What about our CSS? Sometimes it's nice to know where certain styles are defined or where they came from.

Sourcemaps for CSS

If you remove the configuration we just made, reload your app, inspect and element and examine the styles, you'll see they are all defined somewhere in styles.css. That is obviously not true. If you restore the use of inline-source-map in dev, you should see that the definition of styles is correctly mapped to where those styles were defined.

As with JS, it's not possible to get this to work in production mode while style hashing and minifying our CSS. This is less of an issue because CSS doesn't generate stack traces, but it still sucks that the tool documents a thing that doesn't actually work.

What about tests?

Stack Traces in Tests

If we introduce a failure into our tests, we'll see a stack trace, but the line number is useless, as before.

First, remove the throw you added before from js/markdownPreviewer.js. Next, let's introduce a test failure in our test.

Since our test isn't a real test, we'll replace the expectation that we've loaded our code with a nonsense test that undefined is defined:

import markdownPreviewer from "../js/markdownPreviewer"

describe("markdownPreviewer", function() {
  it("should exist", function() {
/* start new code */
    expect(undefined).toBeDefined();
/* end new code */
  });
});
> yarn test
yarn run v1.13.0
$ yarn webpack:test && yarn jest
$ webpack --config test/webpack.test.config.js --display-error-details
Hash: d08c9c32c5a3e7d15f9b
Version: webpack 4.29.6
Time: 182ms
Built at: 2019-03-30 10:25:37
         Asset      Size  Chunks             Chunk Names
bundle.test.js  80.4 KiB       0  [emitted]  main
Entrypoint main = bundle.test.js
[0] multi ./test/canary.test.js ./test/markdownPreviewer.test.js 40 bytes {0} [built]
[1] ./test/canary.test.js 107 bytes {0} [built]
[2] ./test/markdownPreviewer.test.js 221 bytes {0} [built]
[3] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
$ jest test/bundle.test.js
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
FAIL test/bundle.test.js
  canary
    ✓ can run a test (3ms)
  markdownPreviewer
    ✕ should exist (2ms)

  ● markdownPreviewer › should exist

    expect(received).toBeDefined()

    Received: undefined

      116 |   it("should exist", function() {
      117 | /* start new code */
    > 118 |     expect(undefined).toBeDefined();
          |                       ^
      119 | /* end new code */
      120 |   });
      121 | });

      at Object.toBeDefined (test/bundle.test.js:118:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.133s
Ran all test suites matching /test\/bundle.test.js/i.
error Command failed with exit code 1.
error Command failed with exit code 1.

As you can see, the stack trace Jest generates references a line of code in our bundle and not the test. Fortunately, we can get this by setting up devtool in our test webpack config, which you'll recall is still totally separate. Let's keep it that way for now and use the inline-source-map devtool we use in our dev configuration:

const path = require('path');
const glob = require('glob');

const testFiles = glob.sync("**/*.test.js").
                       filter(function(element) {
  return element != "test/bundle.test.js";
}).map(function(element) {
  return "./" + element;
});

module.exports = {
  entry: testFiles,
/* start new code */
  devtool: "inline-source-map",
/* end new code */
  output: {
    path: path.resolve(__dirname, "."),
    filename: "bundle.test.js"
  },
  mode: "none"
};

Now, when we run our test again, we should see correct line numbers!

> rm test/bundle.test.js
> yarn test
yarn run v1.13.0
$ yarn webpack:test && yarn jest
$ webpack --config test/webpack.test.config.js --display-error-details
Hash: d08c9c32c5a3e7d15f9b
Version: webpack 4.29.6
Time: 223ms
Built at: 2019-03-30 10:25:41
         Asset     Size  Chunks             Chunk Names
bundle.test.js  209 KiB       0  [emitted]  main
Entrypoint main = bundle.test.js
[0] multi ./test/canary.test.js ./test/markdownPreviewer.test.js 40 bytes {0} [built]
[1] ./test/canary.test.js 107 bytes {0} [built]
[2] ./test/markdownPreviewer.test.js 221 bytes {0} [built]
[3] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
$ jest test/bundle.test.js
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
FAIL test/bundle.test.js
  canary
    ✓ can run a test (2ms)
  markdownPreviewer
    ✕ should exist (1ms)

  ● markdownPreviewer › should exist

    expect(received).toBeDefined()

    Received: undefined

      at Object.<anonymous> (test/webpack:/test/markdownPreviewer.test.js:6:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.129s
Ran all test suites matching /test\/bundle.test.js/i.
error Command failed with exit code 1.
error Command failed with exit code 1.

Amazing, yeah? Let's now take some time to consolidate our Webpack configurations.

Consolidate Test Webpack Config

The more we start configuring Webpack, we run a risk of diverging critical things if our test configuration isn't kept up to date. Even though it's currently fairly different, let's consolidate it now so when we add more configuration we are forced to decide if that should apply to testing or not.

Create config/webpack.test.config.js like so:

const path         = require('path');
const glob         = require('glob');
const Merge        = require('webpack-merge');
const CommonConfig = require('./webpack.common.config.js');

const testFiles = glob.sync("**/*.test.js").
                       filter(function(element) {
  return element != "test/bundle.test.js";
}).map(function(element) {
  return "./" + element;
});

module.exports = Merge(CommonConfig, {
  entry: testFiles,
  output: {
    path: path.join(__dirname, '../test'),
    filename: 'bundle.test.js'
  },
  devtool: "inline-source-map",
  mode: "none"
});

Let's delete the old file to avoid confusion:

> rm test/webpack.test.config.js

Now, we'll change our npm script in package.json, so the scripts section looks like so:

{
  "scripts": {
    "webpack": "webpack $npm_package_config_webpack_args",
    "webpack:production": "webpack $npm_package_config_webpack_args --env=production",
    "webpack:test": "webpack $npm_package_config_webpack_args --env=test",
    "jest": "jest test/bundle.test.js",
    "test": "yarn webpack:test && yarn jest"
  }
}

And with that, yarn test should still run (and fail showing us a good stack trace):

> yarn test
yarn run v1.13.0
$ yarn webpack:test && yarn jest
$ webpack $npm_package_config_webpack_args --env=test
Hash: af9bfbcf57a9705d147c
Version: webpack 4.29.6
Time: 429ms
Built at: 2019-03-30 10:25:45
         Asset       Size  Chunks             Chunk Names
bundle.test.js    209 KiB       0  [emitted]  main
    index.html  797 bytes          [emitted]  
Entrypoint main = bundle.test.js
[0] multi ./test/canary.test.js ./test/markdownPreviewer.test.js 40 bytes {0} [built]
[1] ./test/canary.test.js 107 bytes {0} [built]
[2] ./test/markdownPreviewer.test.js 221 bytes {0} [built]
[3] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [0] ./node_modules/html-webpack-plugin/lib/loader.js!./html/index.html 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
$ jest test/bundle.test.js
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
FAIL test/bundle.test.js
  canary
    ✓ can run a test (5ms)
  markdownPreviewer
    ✕ should exist (6ms)

  ● markdownPreviewer › should exist

    expect(received).toBeDefined()

    Received: undefined

      at Object.<anonymous> (test/webpack:/test/markdownPreviewer.test.js:6:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.069s
Ran all test suites matching /test\/bundle.test.js/i.
error Command failed with exit code 1.
error Command failed with exit code 1.

Let's go ahead and undo our change to the test before we move on:

import markdownPreviewer from "../js/markdownPreviewer"

describe("markdownPreviewer", function() {
  it("should exist", function() {
/* start new code */
    expect(markdownPreviewer).toBeDefined();
/* end new code */
  });
});

And now our tests are passing again:

> yarn test
yarn run v1.13.0
$ yarn webpack:test && yarn jest
$ webpack $npm_package_config_webpack_args --env=test
Hash: d3de932071db80067771
Version: webpack 4.29.6
Time: 414ms
Built at: 2019-03-30 10:25:50
         Asset       Size  Chunks             Chunk Names
bundle.test.js    209 KiB       0  [emitted]  main
    index.html  797 bytes          [emitted]  
Entrypoint main = bundle.test.js
[0] multi ./test/canary.test.js ./test/markdownPreviewer.test.js 40 bytes {0} [built]
[1] ./test/canary.test.js 107 bytes {0} [built]
[2] ./test/markdownPreviewer.test.js 229 bytes {0} [built]
[3] ./js/markdownPreviewer.js 381 bytes {0} [built]
    + 6 hidden modules
Child html-webpack-plugin for "index.html":
     1 asset
    Entrypoint undefined = index.html
    [0] ./node_modules/html-webpack-plugin/lib/loader.js!./html/index.html 961 bytes {0} [built]
    [2] (webpack)/buildin/global.js 472 bytes {0} [built]
    [3] (webpack)/buildin/module.js 497 bytes {0} [built]
        + 1 hidden module
$ jest test/bundle.test.js
Done in 3.91s.
PASS test/bundle.test.js
  canary
    ✓ can run a test (3ms)
  markdownPreviewer
    ✓ should exist

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.094s
Ran all test suites matching /test\/bundle.test.js/i.

With what we have now, we can get really far, but let's add one more tweak to our dev environment, and configure auto-reloading of code as we make changes.