From dbb433ed395567c633d0cc4265502f39083563e4 Mon Sep 17 00:00:00 2001 From: hai-x Date: Sun, 13 Sep 2026 15:22:38 +0000 Subject: [PATCH] docs: build modern and legacy bundles from one source with layers Adds the differential-serving recipe the shimming guide was missing: an entry per layer so webpack walks the graph twice and issuerLayer picks the babel targets and polyfills for each copy, plus the array-of-configurations form for when the modern build has to be emitted as real ECMAScript modules. Also documents the polyfill entry array on the entry page, with what importing all of core-js/stable actually costs, and links both to the shimming guide. --- src/content/configuration/entry-context.mdx | 17 +++ src/content/guides/shimming.mdx | 127 ++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/content/configuration/entry-context.mdx b/src/content/configuration/entry-context.mdx index dce03691abe1..aafd15f6f92d 100644 --- a/src/content/configuration/entry-context.mdx +++ b/src/content/configuration/entry-context.mdx @@ -186,6 +186,23 @@ export default { }; ``` +### Polyfills in an entry + +The array form is also how polyfills are loaded, because the order of an entry array is the order the files run in. Anything a polyfill repairs has to be repaired before the application touches it, so the polyfill goes first: + +```js +export default { + // ... + entry: { + main: ["core-js/stable", "./src/index.js"], + }, +}; +``` + +W> Importing all of `core-js/stable` ships every polyfill regardless of what the code uses or what the browsers need — 637 modules, 215 KB minified and 71 KB gzipped with core-js 3.50. Prefer `@babel/preset-env` with [`useBuiltIns: 'usage'`](https://babeljs.io/docs/babel-preset-env#usebuiltins) and a [browserslist](https://github.com/browserslist/browserslist) query, which imports only the polyfills your source actually reaches and your targets actually lack. + +See [Loading Polyfills](/guides/shimming/#loading-polyfills) for when a separate polyfill entry is worth the extra request, and [Serving a smaller polyfill to modern browsers](/guides/shimming/#serving-a-smaller-polyfill-to-modern-browsers) for building the same source twice so modern browsers download less. + ### Dynamic entry If a function is passed then it will be invoked on every [make](/api/compiler-hooks/#make) event. diff --git a/src/content/guides/shimming.mdx b/src/content/guides/shimming.mdx index f76da17c6a1f..8d36025f933c 100644 --- a/src/content/guides/shimming.mdx +++ b/src/content/guides/shimming.mdx @@ -492,6 +492,133 @@ export default { W> This is the trade-off the section above warns about: the polyfills now load asynchronously, so nothing outside the entry may run before they do — which is why the application itself is imported rather than being part of the entry. `isLegacyBrowser()` also has to be written in syntax the oldest target parses, since it runs before any polyfill. +### Two bundles from one source with layers + +The `?legacy` query above tags imports one at a time. A [layer](/configuration/entry-context/#entry-descriptor) tags a whole entry instead: webpack walks the entry's graph once per layer, so every file it reaches exists twice as two separate modules, and [`Rule.issuerLayer`](/configuration/module/#ruleissuerlayer) chooses the loaders for each copy. The polyfills go into the legacy entry only, and no browser detection runs in the page. + +**webpack.config.js** + +```js +import path from "node:path"; + +const src = path.resolve(process.cwd(), "src"); + +const babel = (targets, corejs) => ({ + loader: "babel-loader", + options: { + presets: [ + [ + "@babel/preset-env", + { + targets, + useBuiltIns: corejs ? "usage" : false, + corejs, + }, + ], + ], + }, +}); + +export default { + entry: { + modern: { import: "./src/index.js", layer: "modern" }, + legacy: { import: "./src/index.js", layer: "legacy" }, + }, + output: { + filename: "[name].js", + path: path.resolve(process.cwd(), "dist"), + environment: { + arrowFunction: false, + const: false, + destructuring: false, + dynamicImport: false, + forOf: false, + module: false, + optionalChaining: false, + templateLiteral: false, + }, + }, + module: { + rules: [ + { + test: /\.js$/, + include: src, + issuerLayer: "modern", + use: babel("last 2 chrome versions, last 2 firefox versions", false), + }, + { + test: /\.js$/, + include: src, + issuerLayer: "legacy", + use: babel("ie 11", 3), + }, + ], + }, +}; +``` + +An entry's `layer` is what `issuerLayer` matches for the entry module itself, and every module the entry imports inherits it, so one rule per layer covers the whole graph. One compilation emits both bundles, and the page lets the browser pick — a browser that understands `type="module"` ignores `nomodule`, and one that does not ignores the module script: + +```html + + +``` + +T> Before webpack 5.102.0, layers had to be switched on with `experiments.layers: true`. + +W> [`output.environment`](/configuration/output/#outputenvironment) belongs to the compilation, not to an entry, so both bundles share one runtime — set it to the oldest browser you support, as above. It constrains the code **webpack generates**, and never transpiles your own source; that is what the per-layer `babel-loader` is for. + +### Two configurations, when the modern build has to be ESM + +Layers cannot vary the output format, because [`output.module`](/configuration/output/#outputmodule) is compilation-wide for the same reason `output.environment` is. When the modern build should be real ECMAScript modules — `export` statements, `import()` for chunk loading — pass an array of configurations and let each one be its own compilation: + +```js +import path from "node:path"; + +const common = { + mode: "production", + entry: { app: "./src/index.js" }, +}; + +export default [ + { + ...common, + name: "modern", + output: { + path: path.resolve(process.cwd(), "dist/modern"), + filename: "[name].mjs", + chunkFilename: "[name].chunk.mjs", + module: true, + chunkFormat: "module", + chunkLoading: "import", + library: { type: "module" }, + }, + }, + { + ...common, + name: "legacy", + output: { + path: path.resolve(process.cwd(), "dist/legacy"), + filename: "[name].js", + chunkFilename: "[name].chunk.js", + library: { type: "var", name: "App" }, + environment: { + arrowFunction: false, + const: false, + destructuring: false, + dynamicImport: false, + forOf: false, + module: false, + optionalChaining: false, + templateLiteral: false, + }, + }, + }, +]; +``` + +The modern compilation emits `export` and loads its chunks with `import()`; the legacy one emits a `var` library and loads its chunks with an injected `