Monday, 29 January 2018

Finding webpack 4 (use a Map)

Update: 03/02/2018

Tobias Koppers has written a migration guide for plugins / loaders as well - take a read here. It's very useful.

webpack 4

webpack 4 is on the horizon. The beta dropped last Friday. So what do you, as a plugin / loader author need to do? What needs to change to make your loader / plugin webpack 4 friendly?

This is a guide that should inform you about the changes you might need to make. It's based on my own experiences migrating ts-loader and the fork-ts-checker-webpack-plugin. If you'd like to see this in action then take a look at the PRs related to these. The ts-loader PR can be found here. The fork-ts-checker-webpack-plugin PR can be found here.

Plugins

One of the notable changes to webpack with v4 is the change to the plugin architecture. In terms of implications it's worth reading the comments made by Tobias Koppers here and here.

Previously, if your plugin was tapping into a compiler hook you'd write code that looked something like this:


this.compiler.plugin('watch-close', () => {
   // do your thing here
});

With webpack 4 things done changed. You'd now write something like this:


this.compiler.hooks.watchClose.tap('name-to-identify-your-plugin-goes-here', () => {
   // do your thing here
});

Hopefully that's fairly clear; we're using the new hooks property and tapping into our event of choice by camelCasing what was previously kebab-cased. So in this case plugin('watch-close' => hooks.watchClose.tap.

In the example above we were attaching to a sync hook. Now let's look at an async hook:


this.compiler.plugin('watch-run', (watching, callback) => {
   // do your thing here
   callback();
});

This would change to be:


this.compiler.hooks.watchRun.tapAsync('name-to-identify-your-plugin-goes-here', (compiler, callback) => {
   // do your thing here
   callback();
});

Note that rather than using tap here, we're using tapAsync. If you're more into promises there's a tapPromise you could use instead.

Custom Hooks

Prior to webpack 4, you could use your own custom hooks within your plugin. Usage was as simple as this:


this.compiler.applyPluginsAsync('fork-ts-checker-service-before-start', () => {
   // do your thing here
});

You can still use custom hooks with webpack 4, but there's a little more ceremony involved. Essentially, you need to tell webpack up front what you're planning. Not hard, I promise you.

First of all, you'll need to add the package tapable as a dependency. Then, inside your plugin you'll need to import the type of hook that you want to use; in the case of the fork-ts-checker-webpack-plugin we used both a sync and an async hook:


const AsyncSeriesHook = require("tapable").AsyncSeriesHook;
const SyncHook = require("tapable").SyncHook;

Then, inside your apply method you need to register your hooks:


    if (this.compiler.hooks.forkTsCheckerServiceBeforeStart
      || this.compiler.hooks.forkTsCheckerCancel
      // other hooks...
      || this.compiler.hooks.forkTsCheckerEmit) {
      throw new Error('fork-ts-checker-webpack-plugin hooks are already in use');
    }
    this.compiler.hooks.forkTsCheckerServiceBeforeStart = new AsyncSeriesHook([]);

    this.compiler.hooks.forkTsCheckerCancel = new SyncHook([]);
    // other sync hooks...
    this.compiler.hooks.forkTsCheckerDone = new SyncHook([]);

If you're interested in backwards compatibility then you should use the _pluginCompat to wire that in:


    this.compiler._pluginCompat.tap('fork-ts-checker-webpack-plugin', options => {
      switch (options.name) {
        case 'fork-ts-checker-service-before-start':
          options.async = true;
          break;
        case 'fork-ts-checker-cancel':
        // other sync hooks...
        case 'fork-ts-checker-done':
          return true;
      }
      return undefined;
    });

With your registration in place, you just need to replace your calls to compiler.applyPlugins('sync-hook-name', and compiler.applyPluginsAsync('async-hook-name', with calls to compiler.hooks.syncHookName.call( and compiler.hooks.asyncHookName.callAsync(. So to migrate our fork-ts-checker-service-before-start hook we'd write:


this.compiler.hooks.forkTsCheckerServiceBeforeStart.callAsync(() => {
   // do your thing here
});

Loaders

Loaders are impacted by the changes to the plugin architecture. Mostly this means applying the same plugin changes as discussed above. ts-loader hooks into 2 plugin events:


    loader._compiler.plugin("after-compile", /* callback goes here */);
    loader._compiler.plugin("watch-run", /* callback goes here */);

With webpack 4 these become:


    loader._compiler.hooks.afterCompile.tapAsync("ts-loader", /* callback goes here */);
    loader._compiler.hooks.watchRun.tapAsync("ts-loader", /* callback goes here */);

Note again, we're using the string "ts-loader" to identify our loader.

I need a Map

When I initially ported to webpack 4, ts-loader simply wasn't working. In the end I tied this down to problems in our watch-run callback. There's 2 things of note here.

Firstly, as per the changelog, the watch-run hook now has the Compiler as the first parameter. Previously this was a subproperty on the supplied watching parameter. So swapping over to use the compiler directly was necessary. Incidentally, ts-loader previously made use of the watching.startTime property that was supplied in webpack's 1, 2 and 3. It seems to be coping without it; so hopefully that's fine.

Secondly, with webpack 4 it's "ES2015 all the things!" That is to say, with webpack now requiring a minimum of node 6, the codebase is free to start using ES2015. So if you're a consumer of compiler.fileTimestamps (and ts-loader is) then it's time to make a change to cater for the different API that a Map offers instead of indexing into an object literal with a string key.

What this means is, code that would once have looked like this:


Object.keys(watching.compiler.fileTimestamps)
 .filter(filePath =>
  watching.compiler.fileTimestamps[filePath] > lastTimes[filePath]
 )
 .forEach(filePath => {
  lastTimes[filePath] = times[filePath];
  // ...
 });

Now looks more like this:


for (const [filePath, date] of compiler.fileTimestamps) {
 if (date > lastTimes.get(filePath)) {
  continue;
 }

 lastTimes.set(filePath, date);
 // ...
}

Happy Porting!

I hope your own port to webpack 4 goes well. Do let me know if there's anything I've missed out / any inaccuracies etc and I'll update this guide.