Webpack 5.107

Webpack 5.107 is out, and the headline of this release is the very first step toward handling .html files natively in webpack core. For years, building a webpack project with a real HTML entry point has meant pulling in html-webpack-plugin and html-loader. This release starts replacing both of them with first-class support, in the same way experiments.css is gradually replacing css-loader, style-loader, and mini-css-extract-plugin.

The HTML pieces are experimental and live behind a new opt-in flag, but the direction is clear: you should eventually be able to build a complete web app with zero extra loaders or plugins for HTML and CSS.

Alongside the HTML work, this release also continues maturing the built-in CSS pipeline, and ships a handful of improvements for tree shaking, deferred imports, and module resolution.

Explore what's new:

HTML Modules (Experimental)

A real-world webpack setup that needs an HTML entry point has historically required at least two extra dependencies. The first is html-webpack-plugin, which generates or emits the HTML file and injects the right bundle URLs into it. The second is html-loader, which lets webpack walk through <img src>, <link href>, <script src>, and friends so those references go through the normal resolver and asset pipeline.

Both of these have served the community well for a long time, but they live outside the core. Webpack 5.107 starts bringing that responsibility inward.

experiments.html Flag

Everything else in this section sits behind a single opt-in flag. The new experiments.html option registers the html module type on NormalModuleFactory and turns on the HTML behaviors described below.

// webpack.config.js
module.exports = {
  experiments: {
    html: true,
  },
  entry: "./src/index.html",
};

With the flag enabled, an .html file becomes a valid webpack entry point on its own. No plugin or loader is needed to bring it in. The architecture mirrors experiments.css: a single boolean that unlocks an entire pipeline.

Inline <style> Tags

When webpack finds a <style> block inside an HTML module, it routes the CSS body through the same CSS pipeline you'd use for a .css file. The block is treated as a virtual CSS module with exportType: "text", so any url() references and @import statements inside the style are resolved relative to the HTML file. Once processing finishes, the transformed CSS is written back into the original <style> tag.

<!-- src/index.html -->
<!doctype html>
<html>
  <head>
    <style>
      @import "./reset.css";

      body {
        background: url("./bg.png");
      }
    </style>
  </head>
  <body>
    ...
  </body>
</html>

<style type="text/css"> and <style> with no type attribute are processed. Anything with a non-CSS type is passed through untouched. This covers the inline-style behavior of html-loader without needing it in the pipeline.

<script src> and <link rel="modulepreload">

The other big chunk of html-webpack-plugin's job is wiring HTML to the bundles webpack emits. In 5.107, that responsibility starts moving into core too. Both <script src> and <link rel="modulepreload"> references inside an HTML module now become real webpack entries, and the emitted chunk URL is rewritten back into the HTML so hashed filenames stay correct.

<!-- src/index.html -->
<!doctype html>
<html>
  <head>
    <link rel="modulepreload" href="./preloaded.js" />
  </head>
  <body>
    <script src="./entry.js"></script>
    <script src="./second.js"></script>
  </body>
</html>

A few behaviors are worth knowing about:

  • Multiple <script src> tags in the same page share a single runtime. Within each group (classic or type="module"), the leader holds the runtime and the rest declare dependOn on it.
  • <link rel="modulepreload"> entries stay independent and are never imported by sibling scripts, so they preload without executing, exactly as the spec requires.
  • When output.module is enabled, classic <script src> tags are auto-upgraded to <script type="module" src> so the emitted ES-module chunks load in the right mode.
  • Non-JS script types like application/ld+json or importmap, as well as data URIs, flow through unchanged and are not bundled as JS.

webpackIgnore Magic Comment

The familiar webpackIgnore: true magic comment now works inside HTML modules. Place an HTML comment with the directive right before a tag and webpack will leave that tag's URLs untouched in the output. This is exactly how html-loader handles the same case.

<!doctype html>
<html>
  <body>
    <!-- webpackIgnore: true -->
    <img src="https://cdn.example.com/logo.png" />

    <!-- webpackIgnore: true -->
    <script src="/legacy/external.js"></script>
  </body>
</html>

The comment value is parsed with the same context the JS and CSS parsers use, so non-boolean values raise an UnsupportedFeatureWarning.

CSS Improvements

Scope Hoisting for CSS Modules

Module concatenation (also known as scope hoisting) used to be a JavaScript-only optimization. Even with experiments.css enabled, CSS Modules pulled into a concatenated bundle still produced separate runtime instances. Starting with 5.107, the same optimization applies to CSS Modules whose export type is text, css-style-sheet, style, or link. The result is lower runtime overhead and smaller output in CSS-heavy bundles.

module.exports = {
  experiments: { css: true },
  optimization: {
    concatenateModules: true,
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        type: "css/module",
        parser: {
          exportType: "css-style-sheet",
        },
      },
    ],
  },
};

Pure Mode for CSS Modules

A new pure parser option for css/module and css/auto mirrors the strict pure mode of postcss-modules-local-by-default. When enabled, every selector must contain at least one local class or id; otherwise webpack raises a build error. The point is to catch accidentally global selectors in CSS Modules before they make it into production.

module.exports = {
  experiments: { css: true },
  module: {
    parser: {
      "css/module": {
        pure: true,
      },
    },
  },
};

Two comments offer opt-outs when you need them. The first suppresses the check for a single rule:

/* cssmodules-pure-ignore */
a {
  /* suppressed only for this rule */
  color: blue;
}

The second, placed among the leading comments of a file before any rule, disables the check for the entire file:

/* cssmodules-pure-no-check */
/* disables pure mode for this file */

a {
  /* would normally fail under pure mode */
  color: red;
}

Nested rules inside a local-bearing ancestor count as pure-compliant, & resolves to the parent rule's purity, and @keyframes and @counter-style bodies are exempt.

@value in URLs and @import

CSS Modules @value identifiers can now be used as the path argument to @import and inside url() references. This makes it easy to define shared paths and assets once and reuse them across stylesheets.

@value path: "./other.module.css";
@import path;

@value bg: "./image.png";

.a {
  background: url(bg);
}

Both quoted ("./x", './x') and bare (./x) forms of the value work. Whichever form you write is unwrapped and resolved as a module request, so the asset flows through the normal webpack resolver and asset pipeline instead of being left as a literal identifier.

Multiple Aliases via exportsConvention

The function form of generator.exportsConvention for CSS Modules now accepts string[] in addition to string. Returning an array exports the local class under every name in the array, matching css-loader's behavior. This is handy when you want to expose multiple aliases for a single class, for example both the original name and an uppercase version.

module.exports = {
  experiments: { css: true },
  module: {
    generator: {
      "css/module": {
        exportsConvention: (name) => [name, name.toUpperCase()],
      },
    },
  },
};
// Usage in JS
import styles from "./button.module.css";

console.log(styles.btn); // hashed class
console.log(styles.BTN); // same hashed class, uppercase alias

linkInsert Hook

If you've ever wanted to control where webpack inserts a stylesheet <link> in the document, you now have a hook for it. CssLoadingRuntimeModule.getCompilationHooks(compilation) exposes a new linkInsert hook. It receives the default insertion source (document.head.appendChild(link);) and the chunk, and returns the JS used to attach the link.

const webpack = require("webpack");

class MyLinkInsertPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap("MyLinkInsertPlugin", (compilation) => {
      const hooks =
        webpack.web.CssLoadingRuntimeModule.getCompilationHooks(compilation);

      // Override the default `document.head.appendChild(link);`
      hooks.linkInsert.tap(
        "MyLinkInsertPlugin",
        (source, chunk) =>
          'link.setAttribute("data-injected", "true"); document.body.appendChild(link);',
      );
    });
  }
}

module.exports = {
  experiments: { css: true },
  plugins: [new MyLinkInsertPlugin()],
};

The hook is a SyncWaterfallHook<[string, Chunk]>. Return the default source to keep the original behavior, or return your own JS to override where (and how) the link is attached.

JavaScript and ESM

Anonymous Default Export Naming

Webpack 5.106 introduced a fix-up that sets .name to "default" for anonymous default exports, matching the ES spec. It works correctly, but it injects unmangleable Object.defineProperty calls that inflate the bundle. For library consumers, who rarely rely on .name === "default", that extra runtime helper is pure overhead.

5.107 introduces a new option, module.parser.javascript.anonymousDefaultExportName, to control this behavior. It defaults to true for applications and false for libraries (when output.library is set). Apps stay spec-compliant by default; library authors stop paying for the extra runtime helper without having to know about it.

// input
export default function () {
  /* ... */
}

// with `anonymousDefaultExportName: true` (default for apps)
// the runtime sets .name = "default" matching native ESM behavior

You can override the default explicitly:

module.exports = {
  module: {
    parser: {
      javascript: {
        anonymousDefaultExportName: false,
      },
    },
  },
};

Preserving defer and source Phase on Externals

Webpack now preserves the defer and source import phase keywords on external dependencies in ESM output, the same way import attributes are already preserved. Previously, the phase keyword was stripped from the emitted statement, so an import defer * as ns from "x" against an external lost its deferred semantics in the output.

For static module externals, namespace defer imports and single-default source imports are now emitted as native phase syntax at the top of the bundle:

// webpack.config.js
module.exports = {
  output: { module: true },
  externalsType: "module",
  externals: { "external-mod": "external-mod" },
};
// input
import defer * as ns from "external-mod";
import source v from "external-mod";

// emitted output
import defer * as ns from "external-mod";
import source v from "external-mod";

For dynamic import externals, import.defer("x") and import.source("x") are emitted directly:

// input
const ns = await import.defer("external-mod");
const src = await import.source("external-mod");

// emitted output
const ns = await import.defer("external-mod");
const src = await import.source("external-mod");

One related improvement: the same external imported with two different phases (or attribute sets) no longer collapses into a single ExternalModule. Each combination produces its own emit, so neither phase is silently dropped.

#__NO_SIDE_EFFECTS__ Annotation

Webpack now supports the #__NO_SIDE_EFFECTS__ annotation to mark functions as pure for better tree shaking. Calls to functions annotated this way can be eliminated from the bundle when their return value is unused, even if the function body is not statically analyzable as pure.

// utils.js
/*#__NO_SIDE_EFFECTS__*/
export function createLogger(prefix) {
  return (msg) => console.log(`[${prefix}] ${msg}`);
}

export function realWork() {
  // ...
}
// app.js
import { createLogger, realWork } from "./utils";

// dropped, because `createLogger` is annotated and its result is unused
const unused = createLogger("debug");

realWork();

Resolver Updates

Webpack now adds "module-sync" to the default conditionNames for resolver defaults, aligning with Node.js. Node.js exposes the module-sync community condition for synchronously-loadable ESM, and this change affects the ESM, CJS, AMD, worker, wasm, and build-dependency resolvers.

Concretely, the resolver defaults now include module-sync right before module in the condition chain:

// Before (5.106)
conditionNames: ["require", "module", "..."]; // CJS deps
conditionNames: ["import", "module", "..."]; // ESM deps

// After (5.107)
conditionNames: ["require", "module-sync", "module", "..."];
conditionNames: ["import", "module-sync", "module", "..."];

This means packages that publish a module-sync export condition in their package.json will be picked up automatically without any additional configuration:

{
  "name": "my-package",
  "exports": {
    ".": {
      "module-sync": "./esm/index.js",
      "default": "./cjs/index.js"
    }
  }
}

Bug Fixes

Several bug fixes have been resolved since version 5.106. Check the changelog for all the details.

Thanks

A big thank you to all our contributors and sponsors who made Webpack 5.107 possible. Your support, whether through code contributions, documentation, or financial sponsorship, helps keep Webpack evolving and improving for everyone.

Edit this page·
« Previous
Blog

1 Contributor

bjohansebas