From e50aeeec4e3236b2bbbfa57fc82af19ff736fcd1 Mon Sep 17 00:00:00 2001 From: xiaoxiaojx <784487301@qq.com> Date: Tue, 8 Sep 2026 22:15:50 +0800 Subject: [PATCH 1/7] feat: load minimizers by module path in workers --- src/implementation.js | 112 ++++++++++ src/index.js | 191 +++++++++++------- src/minify.js | 17 +- src/options.json | 39 +++- .../parallel-option.test.js.snap | 10 + .../validate-options.test.js.snap | 15 +- test/extractComments-option.test.js | 31 ++- test/fixtures/minify-default-export.js | 9 + test/fixtures/minify-default-property.js | 11 + test/implementation.test.js | 163 +++++++++++++++ test/parallel-option.test.js | 94 ++++++++- types/implementation.d.ts | 41 ++++ types/index.d.ts | 41 ++-- types/minify.d.ts | 8 + 14 files changed, 663 insertions(+), 119 deletions(-) create mode 100644 src/implementation.js create mode 100644 test/fixtures/minify-default-export.js create mode 100644 test/fixtures/minify-default-property.js create mode 100644 test/implementation.test.js create mode 100644 types/implementation.d.ts diff --git a/src/implementation.js b/src/implementation.js new file mode 100644 index 00000000..ea63f1d5 --- /dev/null +++ b/src/implementation.js @@ -0,0 +1,112 @@ +/** @typedef {import("./index.js").MinimizedResult} MinimizedResult */ +/** @typedef {import("./index.js").CustomOptions} CustomOptions */ +/** @typedef {import("./index.js").MinimizeFunctionHelpers} MinimizeFunctionHelpers */ +/** @typedef {import("./index.js").ImplementationModuleRef} ImplementationModuleRef */ +/** + * @typedef {import("./index.js").BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerFn + */ + +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {ImplementationModuleRef | undefined} how to `require` it in a worker + */ +function getImplementationModuleRef(implementation) { + if (typeof implementation === "string") { + return { path: implementation }; + } + + if ( + implementation && + typeof implementation === "object" && + typeof (/** @type {ImplementationModuleRef} */ (implementation).path) === + "string" + ) { + const ref = /** @type {ImplementationModuleRef} */ (implementation); + + return typeof ref.export === "string" && ref.export.length > 0 + ? { path: ref.path, export: ref.export } + : { path: ref.path }; + } + + return undefined; +} + +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {MinimizerFn} the minify function + */ +function loadImplementation(implementation) { + if (typeof implementation === "function") { + return /** @type {MinimizerFn} */ (implementation); + } + + const ref = getImplementationModuleRef(implementation); + + if (!ref) { + throw new TypeError( + "Invalid minimizer implementation: expected a function, module path string, or { path, export }", + ); + } + + const mod = require(ref.path); + + const loaded = + typeof ref.export === "string" + ? mod[ref.export] + : typeof mod === "function" + ? mod + : mod && mod.default; + + if (typeof loaded !== "function") { + throw new TypeError( + typeof ref.export === "string" + ? `Minimizer export "${ref.export}" is not a function in ${ref.path}` + : `Minimizer module does not export a function: ${ref.path}`, + ); + } + + return /** @type {MinimizerFn} */ (loaded); +} + +/** + * True when every `minimizer.implementation` is a module path (`string` or + * `{ path, export }`). Inline minify functions keep `transform`. When + * `embedded` is present, *every* configured implementation must be a path — + * a single inline function in the embedded set forces `transform` for the + * whole asset task, even if that asset's own matched minimizers are paths. + * @template T + * @param {import("./index.js").InternalOptions} options options + * @returns {boolean} whether `worker.minify` can run without `transform` + */ +function canMinifyByPath(options) { + /** + * @param {unknown} implementation implementation + * @returns {boolean} true when a module path is known + */ + const hasPath = (implementation) => + Boolean(getImplementationModuleRef(implementation)); + + const minimizers = Array.isArray(options.minimizer.implementation) + ? options.minimizer.implementation + : [options.minimizer.implementation]; + + if (!minimizers.every(hasPath)) { + return false; + } + + if (!options.embedded) { + return true; + } + + const embedded = Array.isArray(options.embedded.implementation) + ? options.embedded.implementation + : [options.embedded.implementation]; + + return embedded.every(hasPath); +} + +module.exports = { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, +}; diff --git a/src/index.js b/src/index.js index 362e0622..7a9ab4b9 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,11 @@ const crypto = require("crypto"); const os = require("os"); const path = require("path"); +const { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, +} = require("./implementation"); const { minify } = require("./minify"); const { cleanCssMinify, @@ -175,9 +180,20 @@ const { * @property {(minimizerOptions?: EXPECTED_OBJECT) => string[] | undefined=} getEmbeddedTypes the languages this minimizer can hand out from inside what it minifies, through the `renderEmbeddedSource` option. Empty (or absent) means it nests nothing a caller can reach, and the option is not passed */ +/** + * Module path form of `minimizer.implementation` (like sass-loader): the worker + * `require`s it instead of evaluating serialized function source via `new Function`. + * @typedef {{ path: string, export?: string }} ImplementationModuleRef + */ + +/** + * @template T + * @typedef {(BasicMinimizerImplementation & MinimizeFunctionHelpers) | string | ImplementationModuleRef} MinimizerImplementationValue + */ + /** * @template T - * @typedef {T extends EXPECTED_ANY[] ? { [P in keyof T]: BasicMinimizerImplementation & MinimizeFunctionHelpers } : BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerImplementation + * @typedef {T extends EXPECTED_ANY[] ? { [P in keyof T]: MinimizerImplementationValue } : MinimizerImplementationValue} MinimizerImplementation */ /** @@ -188,7 +204,7 @@ const { * @property {RawSourceMap | undefined} inputSourceMap input source map * @property {ExtractCommentsOptions | undefined} extractComments extract comments option * @property {{ implementation: MinimizerImplementation, options: MinimizerOptions }} minimizer minimizer - * @property {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] }=} embedded every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` is the languages each minifies and `offers` the languages each can hand out, both as data and both parallel to `implementation`, since a minify function reaches a worker as source and carries none of its properties; `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all + * @property {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] }=} embedded every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` / `offers` travel as data parallel to `implementation` so the legacy serialize path still knows what each entry minifies and can nest (a function shipped as source loses its helpers; a module path `require` restores them, but the arrays stay so both paths share one shape). `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all * @property {boolean=} module true when code is a EC module, otherwise false * @property {number | string=} ecma ecma version */ @@ -253,7 +269,10 @@ class TerserPlugin { // TODO make `minimizer` option instead `minify` and `terserOptions` in the next major release, also rename `terserMinify` to `terserMinimize` const { minify = /** @type {MinimizerImplementation} */ ( - /** @type {unknown} */ (terserMinify) + /** @type {unknown} */ ({ + path: require.resolve("./utils.js"), + export: "terserMinify", + }) ), minimizerOptions, terserOptions, @@ -490,13 +509,9 @@ class TerserPlugin { */ const matchesName = (name) => this.matchesName(compiler, name); - // Normalize the implementation list to an array so dispatch and the - // worker-pool capability checks below can iterate uniformly. The - // original shape on `this.options.minimizer.implementation` is preserved - // for chunk hashing. - const implementations = Array.isArray(this.options.minimizer.implementation) - ? this.options.minimizer.implementation - : [this.options.minimizer.implementation]; + // One slot per configured minimizer: keep the option value for the worker + // (path / function) and the loaded function for filter / capabilities. + const minimizerSlots = this.getMinimizerSlots(); /** * Collect the indices of minimizers whose `filter` accepts `name`. @@ -504,21 +519,19 @@ class TerserPlugin { * convention used by `supportsWorkerThreads`). * @param {string} name asset name * @param {AssetInfo} info asset info - * @returns {number[]} indices into `implementations` that accept the asset + * @returns {number[]} indices into `minimizerSlots` that accept the asset */ const matchingMinimizers = (name, info) => { const matched = []; const { filters } = this.options.minimizer; - for (let i = 0; i < implementations.length; i++) { - const impl = implementations[i]; + for (let i = 0; i < minimizerSlots.length; i++) { + const { fn } = minimizerSlots[i]; // What `minify` states about this entry answers for it; the property on // the function is what a minimizer says about itself, and is the // fallback rather than a second filter to satisfy. const filter = - filters && typeof filters[i] === "function" - ? filters[i] - : impl.filter; + filters && typeof filters[i] === "function" ? filters[i] : fn.filter; if (typeof filter !== "function" || filter(name, info) !== false) { matched.push(i); @@ -599,14 +612,20 @@ class TerserPlugin { // only to the minimizers its name matched, so one that cannot run in a // worker — an image minimizer, whose bytes have no way across — must not // take the pool away from the JavaScript ones configured beside it. - const workerCapable = implementations.map( - (impl) => - typeof impl.supportsWorker === "undefined" || - (typeof impl.supportsWorker === "function" && impl.supportsWorker()), + const workerCapable = minimizerSlots.map( + ({ fn }) => + typeof fn.supportsWorker === "undefined" || + (typeof fn.supportsWorker === "function" && fn.supportsWorker()), ); - const binaryCapable = implementations.map( - (impl) => - typeof impl.supportsBinary === "function" && impl.supportsBinary(), + const binaryCapable = minimizerSlots.map( + ({ fn }) => + typeof fn.supportsBinary === "function" && fn.supportsBinary(), + ); + const enableWorkerThreads = minimizerSlots.every( + ({ fn }, i) => + !workerCapable[i] || + typeof fn.supportsWorkerThreads === "undefined" || + fn.supportsWorkerThreads() !== false, ); const needCreateWorker = optimizeOptions.availableNumberOfCores > 0 && @@ -632,12 +651,7 @@ class TerserPlugin { new Worker(require.resolve("./minify"), { numWorkers: numberOfWorkers, // Only what can reach the pool decides how it is run. - enableWorkerThreads: implementations.every( - (impl, i) => - !workerCapable[i] || - typeof impl.supportsWorkerThreads === "undefined" || - impl.supportsWorkerThreads() !== false, - ), + enableWorkerThreads, }) ); @@ -666,10 +680,21 @@ class TerserPlugin { * @param {number[]} matched indices of the minimizers this asset is dispatched to * @returns {Promise} the result */ - const run = (options, matched) => - getWorker && matched.every((i) => workerCapable[i]) - ? getWorker().transform(getSerializeJavascript()(options)) - : minify(options); + const run = (options, matched) => { + if (!(getWorker && matched.every((i) => workerCapable[i]))) { + return minify(options); + } + + // Prefer `worker.minify` only when this task's implementations are all + // module paths — including every entry on `embedded`, not just the + // asset's matched subset. A mixed path + inline-function config keeps + // the whole asset on `transform`. + if (canMinifyByPath(options)) { + return getWorker().minify(options); + } + + return getWorker().transform(getSerializeJavascript()(options)); + }; /** @typedef {{ extractedCommentsSource: import("webpack").sources.RawSource, commentsFilename: string }} ExtractedCommentsInfo */ /** @type {Map} */ @@ -718,7 +743,7 @@ class TerserPlugin { // `module`/`ecma` without mutating the caller's object. const assetImplementation = /** @type {MinimizerImplementation} */ - (matched.map((i) => implementations[i])); + (matched.map((i) => minimizerSlots[i].implementation)); const sourceOptions = this.options.minimizer.options; const assetMinimizerOptions = /** @type {MinimizerOptions} */ @@ -740,7 +765,7 @@ class TerserPlugin { options: assetMinimizerOptions, }, extractComments: this.options.extractComments, - embedded: this.embeddedMinimizer(matched), + embedded: this.embeddedFromSlots(matched, minimizerSlots), }; if (typeof info.javascriptModule !== "undefined") { @@ -1062,42 +1087,43 @@ class TerserPlugin { } /** - * Every configured minimizer, in order. The `minify` option takes one or an - * array; embedded source is dispatched across all of them either way. + * One slot per configured minimizer: the option value for workers (path / + * function) and the loaded function for helpers (`getTypes`, `filter`, …). * @private - * @returns {(BasicMinimizerImplementation & MinimizeFunctionHelpers)[]} the minimizers + * @returns {{ implementation: MinimizerImplementationValue, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} loaded slots */ - minimizers() { + getMinimizerSlots() { const { implementation } = this.options.minimizer; + const list = Array.isArray(implementation) + ? implementation + : [implementation]; - return /** @type {(BasicMinimizerImplementation & MinimizeFunctionHelpers)[]} */ ( - /** @type {unknown} */ ( - Array.isArray(implementation) ? implementation : [implementation] - ) - ); + return list.map((one) => ({ + implementation: + /** @type {MinimizerImplementationValue} */ + (one), + fn: loadImplementation(one), + })); } /** - * Every configured minimizer and its options, for dispatching source one - * language embeds in another. The asset's own entry holds only what its - * filename matched, and a language's minimizer need not be among them — a - * `.css` asset embedding an `` reaches an SVG minifier that claims no - * asset at all. + * Build the embedded minimizer payload from already-loaded slots (path or + * function kept as configured; `fn` supplies claims / offers). * @private * @param {number[]} matched indices of the minimizers this input's own entry holds + * @param {{ implementation: unknown, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} slots loaded minimizer slots * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ - embeddedMinimizer(matched) { - const minimizers = this.minimizers(); - // What each declares travels as data, not on the function: a minify function - // reaches a worker as its source, which carries none of its properties. - const claims = minimizers.map((minimizer) => - typeof minimizer.getTypes === "function" - ? minimizer.getTypes() || [] - : [], + embeddedFromSlots(matched, slots) { + // `claims` / `offers` are duplicated as data so the serialize worker path + // still knows each entry's languages (function source drops helpers). Path + // `implementation` values are kept as configured so the worker can `require` + // them. + const claims = slots.map(({ fn }) => + typeof fn.getTypes === "function" ? fn.getTypes() || [] : [], ); - const offers = minimizers.map((minimizer, i) => { - const { getEmbeddedTypes } = minimizer; + const offers = slots.map(({ fn }, i) => { + const { getEmbeddedTypes } = fn; return typeof getEmbeddedTypes === "function" ? getEmbeddedTypes( @@ -1121,13 +1147,17 @@ class TerserPlugin { return { implementation: /** @type {MinimizerImplementation} */ - (/** @type {unknown} */ (minimizers)), + ( + /** @type {unknown} */ ( + slots.map(({ implementation }) => implementation) + ) + ), options: /** @type {MinimizerOptions} */ ( /** @type {unknown} */ ( - minimizers.map((_, i) => + slots.map((_, i) => getMinimizerOptionsAt(this.options.minimizer.options, i), ) ) @@ -1503,14 +1533,14 @@ class TerserPlugin { */ async renderEmbeddedSource(compiler, compilation, variesOn, source, info) { const { type, hostType, module } = info; - const minimizers = this.minimizers(); + const minimizerSlots = this.getMinimizerSlots(); const matched = []; // A minimizer that declares nothing takes no embedded source: such source // carries no filename to guess from, and guessing is what `getTypes` // replaces. - for (let i = 0; i < minimizers.length; i++) { - const { getTypes } = minimizers[i]; + for (let i = 0; i < minimizerSlots.length; i++) { + const { getTypes } = minimizerSlots[i].fn; if (typeof getTypes === "function" && (getTypes() || []).includes(type)) { matched.push(i); @@ -1565,7 +1595,10 @@ class TerserPlugin { minimizer: { implementation: /** @type {MinimizerImplementation} */ - (/** @type {unknown} */ (matched.map((i) => minimizers[i]))), + ( + /** @type {unknown} */ + (matched.map((i) => minimizerSlots[i].fn)) + ), options: /** @type {MinimizerOptions} */ ( @@ -1577,7 +1610,7 @@ class TerserPlugin { ) ), }, - embedded: this.embeddedMinimizer(matched), + embedded: this.embeddedFromSlots(matched, minimizerSlots), ecma: getEcmaVersion( /** @type {NonNullable["environment"]>} */ (compiler.options.output.environment), @@ -1947,18 +1980,32 @@ class TerserPlugin { compilation, ); /** - * @param {BasicMinimizerImplementation & MinimizeFunctionHelpers} impl implementation + * @param {MinimizerImplementationValue} impl implementation * @returns {string} minimizer version or "0.0.0" */ - const getVersion = (impl) => - typeof impl.getMinimizerVersion !== "undefined" - ? impl.getMinimizerVersion() || "0.0.0" + const getVersion = (impl) => { + // Path refs need a load; functions already carry helpers. Preset maps + // and other shapes are not a single minimizer — keep the prior "0.0.0". + const fn = + typeof impl === "function" + ? impl + : getImplementationModuleRef(impl) + ? loadImplementation(impl) + : undefined; + + if (!fn) { + return "0.0.0"; + } + + return typeof fn.getMinimizerVersion !== "undefined" + ? fn.getMinimizerVersion() || "0.0.0" : "0.0.0"; + }; const data = getSerializeJavascript()({ minimizer: Array.isArray(this.options.minimizer.implementation) ? this.options.minimizer.implementation.map(getVersion) : getVersion( - /** @type {BasicMinimizerImplementation & MinimizeFunctionHelpers} */ + /** @type {MinimizerImplementationValue} */ (this.options.minimizer.implementation), ), options: this.options.minimizer.options, @@ -2010,7 +2057,7 @@ class TerserPlugin { generator: Array.isArray(moduleGenerator.implementation) ? moduleGenerator.implementation.map(getVersion) : getVersion( - /** @type {BasicMinimizerImplementation & MinimizeFunctionHelpers} */ + /** @type {MinimizerImplementationValue} */ (moduleGenerator.implementation), ), options: moduleGenerator.options, diff --git a/src/minify.js b/src/minify.js index e704e430..425c0299 100644 --- a/src/minify.js +++ b/src/minify.js @@ -2,6 +2,11 @@ /** @typedef {import("./index.js").CustomOptions} CustomOptions */ /** @typedef {import("./index.js").RawSourceMap} RawSourceMap */ /** @typedef {import("./index.js").EXPECTED_ANY} EXPECTED_ANY */ +/** @typedef {import("./index.js").MinimizeFunctionHelpers} MinimizeFunctionHelpers */ +/** + * A concrete minify function, including optional worker-path helpers. + * @typedef {import("./index.js").BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerFn + */ /** * @template T * @typedef {import("./index.js").MinimizerOptions} MinimizerOptions @@ -299,6 +304,8 @@ function composeSourceMaps(currentMap, prevMap, name) { } /* eslint-enable prefer-destructuring, no-eq-null, eqeqeq */ +const { loadImplementation } = require("./implementation"); + /** * @template T * @param {import("./index.js").InternalOptions} options options @@ -463,7 +470,7 @@ async function minify(options) { for (let i = 0; i < implementations.length; i++) { const currentImplementation = /** @type {import("./index.js").BasicMinimizerImplementation & import("./index.js").MinimizeFunctionHelpers} */ - (implementations[i]); + (loadImplementation(implementations[i])); const baseOptions = /** @type {import("./index.js").MinimizerOptions & { module?: boolean, ecma?: number | string }} */ (optionsAt(i)); @@ -561,6 +568,9 @@ async function minify(options) { * @returns {Promise} minified result */ async function transform(options) { + // Legacy worker path: the whole task (including minify function source) is a + // string evaluated here. Prefer `minify` when every `implementation` is a + // module path (`string` / `{ path, export }`) so the worker can `require` it. // 'use strict' => this === undefined (Clean Scope) // Safer for possible security issues, albeit not critical at all here @@ -585,4 +595,7 @@ async function transform(options) { return minify(evaluatedOptions); } -module.exports = { minify, transform }; +module.exports = { + minify, + transform, +}; diff --git a/src/options.json b/src/options.json index c91fd78b..85d2f40f 100644 --- a/src/options.json +++ b/src/options.json @@ -31,6 +31,35 @@ "$ref": "#/definitions/Rule" } ] + }, + "MinimizerImplementation": { + "description": "The minimizer itself: a function, a module path string (worker `require`s it), or `{ path, export }` for a named export.", + "anyOf": [ + { + "instanceof": "Function" + }, + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "description": "Absolute path or resolvable id of the module that exports the minimizer.", + "type": "string", + "minLength": 1 + }, + "export": { + "description": "Named export when the module is not `module.exports` / `default`.", + "type": "string", + "minLength": 1 + } + }, + "required": ["path"] + } + ] } }, "title": "MinimizerPluginOptions", @@ -186,11 +215,11 @@ ] }, "minify": { - "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included.", + "description": "Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. A string or `{ path, export }` loads the minimizer by module path in workers (like sass-loader `implementation`).", "link": "https://github.com/webpack/minimizer-webpack-plugin#number", "anyOf": [ { - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, { "type": "array", @@ -198,7 +227,7 @@ "items": { "anyOf": [ { - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, { "type": "object", @@ -206,7 +235,7 @@ "properties": { "implementation": { "description": "The minimizer itself.", - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, "options": { "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", @@ -229,7 +258,7 @@ "properties": { "implementation": { "description": "The minimizer itself.", - "instanceof": "Function" + "$ref": "#/definitions/MinimizerImplementation" }, "options": { "description": "Options for this minimizer. Preferred over `minimizerOptions`, which is deprecated; setting both for one minimizer is an error.", diff --git a/test/__snapshots__/parallel-option.test.js.snap b/test/__snapshots__/parallel-option.test.js.snap index f59caf1b..3b7ceb1e 100644 --- a/test/__snapshots__/parallel-option.test.js.snap +++ b/test/__snapshots__/parallel-option.test.js.snap @@ -353,3 +353,13 @@ exports[`worker should match snapshot with options.inputSourceMap 1`] = ` "warnings": [], } `; + +exports[`worker should minify via implementation path without serialize/new Function 1`] = ` +{ + "code": "var foo=1;", + "errors": [], + "extractedComments": [], + "map": undefined, + "warnings": [], +} +`; diff --git a/test/__snapshots__/validate-options.test.js.snap b/test/__snapshots__/validate-options.test.js.snap index ebb9b02f..85af0eec 100644 --- a/test/__snapshots__/validate-options.test.js.snap +++ b/test/__snapshots__/validate-options.test.js.snap @@ -132,13 +132,20 @@ exports[`validation validate 9`] = ` exports[`validation validate 10`] = ` "Invalid options object. Terser Plugin has been initialized using an options object that does not match the API schema. - options.minify should be one of these: - function | [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } - -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. + function | non-empty string | object { path, export? } | [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) | object { implementation, options?, filter? } + -> Allows you to override default minify function. Written as an object it states how to run one minimizer, options included. A string or \`{ path, export }\` loads the minimizer by module path in workers (like sass-loader \`implementation\`). -> Read more at https://github.com/webpack/minimizer-webpack-plugin#number Details: - * options.minify should be an instance of function. + * options.minify should be one of these: + function | non-empty string | object { path, export? } + -> The minimizer itself: a function, a module path string (worker \`require\`s it), or \`{ path, export }\` for a named export. + Details: + * options.minify should be an instance of function. + * options.minify should be a non-empty string. + * options.minify should be an object: + object { path, export? } * options.minify should be an array: - [function | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) + [function | non-empty string | object { path, export? } | object { implementation, options?, filter? }, ...] (should not have fewer than 1 item) * options.minify should be an object: object { implementation, options?, filter? }" `; diff --git a/test/extractComments-option.test.js b/test/extractComments-option.test.js index 528960b2..cff84159 100644 --- a/test/extractComments-option.test.js +++ b/test/extractComments-option.test.js @@ -24,6 +24,13 @@ function createFilenameFn() { }; } +function pluginWithFunctionExtractComments(options) { + return new MinimizerPlugin({ + minify: MinimizerPlugin.terserMinify, + ...options, + }); +} + describe("extractComments option", () => { let compiler; @@ -113,7 +120,9 @@ describe("extractComments option", () => { }); it('should match snapshot for a "function" value', async () => { - new MinimizerPlugin({ extractComments: () => true }).apply(compiler); + pluginWithFunctionExtractComments({ extractComments: () => true }).apply( + compiler, + ); const stats = await compile(compiler); @@ -139,7 +148,7 @@ describe("extractComments option", () => { it("should match snapshot when extracts comments to multiple files", async () => { expect.assertions(8); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: createFilenameFn(), @@ -156,7 +165,7 @@ describe("extractComments option", () => { }); it("should match snapshot when extracts comments to a single file", async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "extracted-comments.js", @@ -174,7 +183,7 @@ describe("extractComments option", () => { }); it("should match snapshot when extracts without condition", async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "extracted-comments.js", @@ -211,7 +220,7 @@ describe("extractComments option", () => { it('should match snapshot when no condition, preserve only `/@license/i` comments and extract "some" comments', async () => { expect.assertions(8); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ terserOptions: { output: { comments: /@license/i, @@ -242,7 +251,7 @@ describe("extractComments option", () => { }); it("should match snapshot when extracts comments to a single file and dedupe duplicate comments", async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "extracted-comments.js", @@ -296,7 +305,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: "[file].LICENSE.txt?query=[query]&filebase=[base]", @@ -329,7 +338,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { condition: true, filename: createFilenameFn(), @@ -447,7 +456,7 @@ describe("extractComments option", () => { }); it('should match snapshot and do not preserve and extract "all" comments when the option if a function', async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: () => true, }).apply(compiler); @@ -459,7 +468,7 @@ describe("extractComments option", () => { }); it('should match snapshot and preserve "all" and extract "all" comments with output.comments "all"', async () => { - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: () => true, terserOptions: { output: { @@ -642,7 +651,7 @@ describe("extractComments option", () => { }, }); - new MinimizerPlugin({ + pluginWithFunctionExtractComments({ extractComments: { filename: (fileData) => fileData.filename === "b.js" ? "b.txt" : "shared.txt", diff --git a/test/fixtures/minify-default-export.js b/test/fixtures/minify-default-export.js new file mode 100644 index 00000000..2898d299 --- /dev/null +++ b/test/fixtures/minify-default-export.js @@ -0,0 +1,9 @@ +/** + * @param {import("../../src/index.js").Input} input input + * @returns {Promise} result + */ +module.exports = async function minifyDefaultExport(input) { + const [[name, code]] = Object.entries(input); + + return { code: String(code).replace(/\s+/g, " ").trim(), filename: name }; +}; diff --git a/test/fixtures/minify-default-property.js b/test/fixtures/minify-default-property.js new file mode 100644 index 00000000..26e6e5bd --- /dev/null +++ b/test/fixtures/minify-default-property.js @@ -0,0 +1,11 @@ +/** + * @param {import("../../src/index.js").Input} input input + * @returns {Promise} result + */ +async function minifyDefaultProperty(input) { + const [[name, code]] = Object.entries(input); + + return { code: String(code).replace(/\s+/g, " ").trim(), filename: name }; +} + +module.exports = { default: minifyDefaultProperty }; diff --git a/test/implementation.test.js b/test/implementation.test.js new file mode 100644 index 00000000..26bf6195 --- /dev/null +++ b/test/implementation.test.js @@ -0,0 +1,163 @@ +import path from "path"; + +import { + canMinifyByPath, + getImplementationModuleRef, + loadImplementation, +} from "../src/implementation.js"; +import { terserMinify } from "../src/utils.js"; + +describe("getImplementationModuleRef", () => { + it("should accept a module path string", () => { + expect(getImplementationModuleRef("/abs/utils.js")).toEqual({ + path: "/abs/utils.js", + }); + }); + + it("should accept { path } without export", () => { + expect(getImplementationModuleRef({ path: "/abs/utils.js" })).toEqual({ + path: "/abs/utils.js", + }); + }); + + it("should accept { path, export }", () => { + expect( + getImplementationModuleRef({ + path: "/abs/utils.js", + export: "terserMinify", + }), + ).toEqual({ path: "/abs/utils.js", export: "terserMinify" }); + }); + + it("should ignore an empty export name", () => { + expect( + getImplementationModuleRef({ path: "/abs/utils.js", export: "" }), + ).toEqual({ path: "/abs/utils.js" }); + }); + + it("should return undefined for functions and other values", () => { + expect(getImplementationModuleRef(terserMinify)).toBeUndefined(); + expect(getImplementationModuleRef(null)).toBeUndefined(); + expect( + getImplementationModuleRef({ export: "terserMinify" }), + ).toBeUndefined(); + }); +}); + +describe("loadImplementation", () => { + it("should return a function implementation as-is", () => { + expect(loadImplementation(terserMinify)).toBe(terserMinify); + }); + + it("should load a named export from { path, export }", () => { + expect( + loadImplementation({ + path: require.resolve("../src/utils.js"), + export: "terserMinify", + }), + ).toBe(terserMinify); + }); + + it("should load module.exports when it is the function", () => { + const fixture = path.resolve( + __dirname, + "./fixtures/minify-default-export.js", + ); + + expect(loadImplementation(fixture)).toBe(require(fixture)); + }); + + it("should load the default export when the module is not a function", () => { + const fixture = path.resolve( + __dirname, + "./fixtures/minify-default-property.js", + ); + + expect(loadImplementation(fixture)).toBe(require(fixture).default); + }); + + it("should throw for an invalid implementation value", () => { + expect(() => loadImplementation(null)).toThrow( + /expected a function, module path string, or \{ path, export \}/, + ); + }); + + it("should throw when a named export is not a function", () => { + expect(() => + loadImplementation({ + path: require.resolve("../src/utils.js"), + export: "CLASSIC_SCRIPT", + }), + ).toThrow(/Minimizer export "CLASSIC_SCRIPT" is not a function/); + }); + + it("should throw when the module does not export a function", () => { + expect(() => + loadImplementation(require.resolve("../src/utils.js")), + ).toThrow(/Minimizer module does not export a function/); + }); +}); + +describe("canMinifyByPath", () => { + const utilsPath = require.resolve("../src/utils.js"); + const pathImpl = { path: utilsPath, export: "terserMinify" }; + + it("should allow a single path implementation", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: pathImpl }, + }), + ).toBe(true); + }); + + it("should allow a string path implementation", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: path.resolve( + __dirname, + "./fixtures/minify-default-export.js", + ), + }, + }), + ).toBe(true); + }); + + it("should reject an inline function implementation", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: terserMinify }, + }), + ).toBe(false); + }); + + it("should allow embedded when every implementation is a path", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: [pathImpl] }, + embedded: { + implementation: pathImpl, + options: {}, + claims: [], + offers: [], + at: [0], + }, + }), + ).toBe(true); + }); + + it("should reject embedded when any implementation is a function", () => { + expect( + canMinifyByPath({ + minimizer: { implementation: [pathImpl] }, + embedded: { + implementation: [pathImpl, terserMinify], + options: [{}, {}], + claims: [[], []], + offers: [[], []], + at: [0], + }, + }), + ).toBe(false); + }); +}); diff --git a/test/parallel-option.test.js b/test/parallel-option.test.js index 04409835..f358e3cb 100644 --- a/test/parallel-option.test.js +++ b/test/parallel-option.test.js @@ -3,8 +3,9 @@ import path from "path"; import { Worker } from "jest-worker"; +import { canMinifyByPath } from "../src/implementation.js"; import MinimizerPlugin from "../src/index"; -import { transform } from "../src/minify.js"; +import { minify as minifyWorker, transform } from "../src/minify.js"; import serialize from "../src/serialize-javascript.js"; import { terserMinify } from "../src/utils.js"; @@ -31,6 +32,7 @@ jest.mock("os", () => { // Based on https://github.com/facebook/jest/blob/edde20f75665c2b1e3c8937f758902b5cf28a7b4/packages/jest-runner/src/__tests__/test_runner.test.js let workerTransform; +let workerMinify; let workerEnd; const ENABLE_WORKER_THREADS = @@ -43,6 +45,9 @@ jest.mock("jest-worker", () => ({ transform: (workerTransform = jest.fn((data) => require(workerPath).transform(data), )), + minify: (workerMinify = jest.fn((data) => + require(workerPath).minify(data), + )), end: (workerEnd = jest.fn()), getStderr: jest.fn(), getStdout: jest.fn(), @@ -85,9 +90,16 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: getParallelism() - 1, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); + expect(workerTransform).not.toHaveBeenCalled(); + expect(workerMinify.mock.calls[0][0].minimizer.implementation).toEqual([ + { + path: require.resolve("../src/utils.js"), + export: "terserMinify", + }, + ]); expect(workerEnd).toHaveBeenCalledTimes(1); expect(readsAssets(compiler, stats)).toMatchSnapshot("assets"); @@ -95,6 +107,49 @@ describe("parallel option", () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); }); + it("should use transform when implementation is an inline function", async () => { + const impl = async (input, map, options, extractComments) => + terserMinify(input, map, options, extractComments); + + new MinimizerPlugin({ parallel: true, minify: impl }).apply(compiler); + + const stats = await compile(compiler); + + expect(Worker).toHaveBeenCalledTimes(1); + expect(workerTransform).toHaveBeenCalledTimes( + Object.keys(stats.compilation.assets).length, + ); + expect(workerMinify).not.toHaveBeenCalled(); + expect(workerEnd).toHaveBeenCalledTimes(1); + }); + + it("should minify by path when implementation is a module path string", async () => { + new MinimizerPlugin({ + parallel: true, + minify: path.resolve(__dirname, "./fixtures/minify-default-export.js"), + }).apply(compiler); + + await compile(compiler); + + expect(workerMinify).toHaveBeenCalled(); + expect(workerTransform).not.toHaveBeenCalled(); + expect(workerMinify.mock.calls[0][0].minimizer.implementation).toEqual([ + path.resolve(__dirname, "./fixtures/minify-default-export.js"), + ]); + }); + + it("should minify by path when extractComments is a RegExp", async () => { + new MinimizerPlugin({ + parallel: true, + extractComments: /license/i, + }).apply(compiler); + + await compile(compiler); + + expect(workerMinify).toHaveBeenCalled(); + expect(workerTransform).not.toHaveBeenCalled(); + }); + it('should match snapshot for the "false" value', async () => { new MinimizerPlugin({ parallel: false }).apply(compiler); @@ -117,7 +172,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: getParallelism() - 1, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -137,7 +192,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: getParallelism() - 1, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -157,7 +212,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: 2, }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -181,7 +236,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(1, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -209,7 +264,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(Object.keys(entries).length, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -237,7 +292,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(Object.keys(entries).length, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -276,7 +331,7 @@ describe("parallel option", () => { enableWorkerThreads: ENABLE_WORKER_THREADS, numWorkers: Math.min(Object.keys(entries).length, os.cpus().length - 1), }); - expect(workerTransform).toHaveBeenCalledTimes( + expect(workerMinify).toHaveBeenCalledTimes( Object.keys(stats.compilation.assets).length, ); expect(workerEnd).toHaveBeenCalledTimes(1); @@ -307,6 +362,27 @@ describe("parallel option", () => { }); describe("worker", () => { + it("should minify via implementation path without serialize/new Function", async () => { + const options = { + name: "test1.js", + input: "var foo = 1;/* hello */", + minimizer: { + implementation: { + path: require.resolve("../src/utils.js"), + export: "terserMinify", + }, + }, + extractComments: false, + }; + + expect(canMinifyByPath(options)).toBe(true); + + const workerResult = await minifyWorker(options); + + expect(workerResult.code).toContain("foo"); + expect(workerResult).toMatchSnapshot(); + }); + it('should match snapshot when options.extractComments is "false"', async () => { const options = { name: "test1.js", diff --git a/types/implementation.d.ts b/types/implementation.d.ts new file mode 100644 index 00000000..eed3b9bb --- /dev/null +++ b/types/implementation.d.ts @@ -0,0 +1,41 @@ +export type MinimizedResult = import("./index.js").MinimizedResult; +export type CustomOptions = import("./index.js").CustomOptions; +export type MinimizeFunctionHelpers = + import("./index.js").MinimizeFunctionHelpers; +export type ImplementationModuleRef = + import("./index.js").ImplementationModuleRef; +export type MinimizerFn = + import("./index.js").BasicMinimizerImplementation & + MinimizeFunctionHelpers; +/** + * True when every `minimizer.implementation` is a module path (`string` or + * `{ path, export }`). Inline minify functions keep `transform`. When + * `embedded` is present, *every* configured implementation must be a path — + * a single inline function in the embedded set forces `transform` for the + * whole asset task, even if that asset's own matched minimizers are paths. + * @template T + * @param {import("./index.js").InternalOptions} options options + * @returns {boolean} whether `worker.minify` can run without `transform` + */ +export function canMinifyByPath( + options: import("./index.js").InternalOptions, +): boolean; +/** @typedef {import("./index.js").MinimizedResult} MinimizedResult */ +/** @typedef {import("./index.js").CustomOptions} CustomOptions */ +/** @typedef {import("./index.js").MinimizeFunctionHelpers} MinimizeFunctionHelpers */ +/** @typedef {import("./index.js").ImplementationModuleRef} ImplementationModuleRef */ +/** + * @typedef {import("./index.js").BasicMinimizerImplementation & MinimizeFunctionHelpers} MinimizerFn + */ +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {ImplementationModuleRef | undefined} how to `require` it in a worker + */ +export function getImplementationModuleRef( + implementation: unknown, +): ImplementationModuleRef | undefined; +/** + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {MinimizerFn} the minify function + */ +export function loadImplementation(implementation: unknown): MinimizerFn; diff --git a/types/index.d.ts b/types/index.d.ts index 6c746ba0..e16ef3a4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -72,23 +72,21 @@ declare class TerserPlugin { */ private optimize; /** - * Every configured minimizer, in order. The `minify` option takes one or an - * array; embedded source is dispatched across all of them either way. + * One slot per configured minimizer: the option value for workers (path / + * function) and the loaded function for helpers (`getTypes`, `filter`, …). * @private - * @returns {(BasicMinimizerImplementation & MinimizeFunctionHelpers)[]} the minimizers + * @returns {{ implementation: MinimizerImplementationValue, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} loaded slots */ - private minimizers; + private getMinimizerSlots; /** - * Every configured minimizer and its options, for dispatching source one - * language embeds in another. The asset's own entry holds only what its - * filename matched, and a language's minimizer need not be among them — a - * `.css` asset embedding an `` reaches an SVG minifier that claims no - * asset at all. + * Build the embedded minimizer payload from already-loaded slots (path or + * function kept as configured; `fn` supplies claims / offers). * @private * @param {number[]} matched indices of the minimizers this input's own entry holds + * @param {{ implementation: unknown, fn: BasicMinimizerImplementation & MinimizeFunctionHelpers }[]} slots loaded minimizer slots * @returns {{ implementation: MinimizerImplementation, options: MinimizerOptions, claims: string[][], offers: string[][], at: number[] } | undefined} every configured minimizer, or undefined when nothing nested could be reached */ - private embeddedMinimizer; + private embeddedFromSlots; /** * One generator, however it was written: as the generator itself or as an * object stating how to run it. @@ -274,6 +272,8 @@ declare namespace TerserPlugin { MinimizerOptions, BasicMinimizerImplementation, MinimizeFunctionHelpers, + ImplementationModuleRef, + MinimizerImplementationValue, MinimizerImplementation, InternalOptions, MinimizerWorker, @@ -534,12 +534,21 @@ type MinimizeFunctionHelpers = { getEmbeddedTypes?: ((minimizerOptions?: EXPECTED_OBJECT) => string[] | undefined) | undefined; }; +/** + * Module path form of `minimizer.implementation` (like sass-loader): the worker + * `require`s it instead of evaluating serialized function source via `new Function`. + */ +type ImplementationModuleRef = { + path: string; + export?: string; +}; +type MinimizerImplementationValue = + | (BasicMinimizerImplementation & MinimizeFunctionHelpers) + | string + | ImplementationModuleRef; type MinimizerImplementation = T extends EXPECTED_ANY[] - ? { - [P in keyof T]: BasicMinimizerImplementation & - MinimizeFunctionHelpers; - } - : BasicMinimizerImplementation & MinimizeFunctionHelpers; + ? { [P in keyof T]: MinimizerImplementationValue } + : MinimizerImplementationValue; type InternalOptions = { /** * name @@ -565,7 +574,7 @@ type InternalOptions = { options: MinimizerOptions; }; /** - * every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` is the languages each minifies and `offers` the languages each can hand out, both as data and both parallel to `implementation`, since a minify function reaches a worker as source and carries none of its properties; `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all + * every configured minimizer, for source one language embeds in another: it carries no filename, so `minimizer` — which holds only what this asset's name matched — is not the set to dispatch it across. `claims` / `offers` travel as data parallel to `implementation` so the legacy serialize path still knows what each entry minifies and can nest (a function shipped as source loses its helpers; a module path `require` restores them, but the arrays stay so both paths share one shape). `at` says which of them `minimizer` holds. Absent when no nested language is reachable at all */ embedded?: | { diff --git a/types/minify.d.ts b/types/minify.d.ts index d3ca4691..7d21c9aa 100644 --- a/types/minify.d.ts +++ b/types/minify.d.ts @@ -2,6 +2,14 @@ export type MinimizedResult = import("./index.js").MinimizedResult; export type CustomOptions = import("./index.js").CustomOptions; export type RawSourceMap = import("./index.js").RawSourceMap; export type EXPECTED_ANY = import("./index.js").EXPECTED_ANY; +export type MinimizeFunctionHelpers = + import("./index.js").MinimizeFunctionHelpers; +/** + * A concrete minify function, including optional worker-path helpers. + */ +export type MinimizerFn = + import("./index.js").BasicMinimizerImplementation & + MinimizeFunctionHelpers; export type MinimizerOptions = import("./index.js").MinimizerOptions; /** * @template T From 8cb5b9f9006d0bd8a8bbf115f3fb86dd4f1f9b1a Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 19 Sep 2026 13:13:06 +0000 Subject: [PATCH 2/7] docs: a minimizer named by module path, and a changeset for it Plus the case the test plan left open: one function among several paths leaves the whole task on the source path. --- .changeset/worker-implementation-path.md | 5 +++++ README.md | 22 ++++++++++++++++++++++ test/parallel-option.test.js | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 .changeset/worker-implementation-path.md diff --git a/.changeset/worker-implementation-path.md b/.changeset/worker-implementation-path.md new file mode 100644 index 00000000..4cc85d0f --- /dev/null +++ b/.changeset/worker-implementation-path.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": minor +--- + +Take a minimizer as a module path or `{ path, export }`, which a worker requires rather than rebuilding it from its source. diff --git a/README.md b/README.md index 2f875da6..b148253d 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,28 @@ default one, and the tables in [webpack's own minimizers](#webpacks-own-minimizers) for `cssMinify` and `htmlMinify`. +`implementation` may also name a **module path** rather than hold the function +itself — a string, or `{ path, export }` for a named export, the way +`sass-loader` takes its `implementation`. A worker then `require`s the +minimizer, instead of being handed its source to rebuild with `new Function`, +which is what the default terser does: + +```js +new MinimizerPlugin({ + minify: { + // Or the string on its own, where the module exports the function itself. + implementation: { + path: require.resolve("./my-minifier"), + export: "minify", + }, + }, +}); +``` + +The saving is per task, so it needs every minimizer of that task to be nameable: +one written as a function anywhere in the list leaves the whole task on the +source path, since that function has no other way across. + `filter(name, info)` states which assets this minimizer is offered — return `false` to decline one, and anything else (`undefined` included) to accept. It answers for a `filter` property on the minimizer function itself, which is what diff --git a/test/parallel-option.test.js b/test/parallel-option.test.js index f358e3cb..441ff149 100644 --- a/test/parallel-option.test.js +++ b/test/parallel-option.test.js @@ -138,6 +138,24 @@ describe("parallel option", () => { ]); }); + it("should use transform where one of several is an inline function", async () => { + new MinimizerPlugin({ + parallel: true, + minify: [ + path.resolve(__dirname, "./fixtures/minify-default-export.js"), + async (input, map, options, extractComments) => + terserMinify(input, map, options, extractComments), + ], + }).apply(compiler); + + await compile(compiler); + + // A worker requires what it is given a path to, and rebuilds what it is + // given a function from — one of each leaves the whole task on the second. + expect(workerTransform).toHaveBeenCalled(); + expect(workerMinify).not.toHaveBeenCalled(); + }); + it("should minify by path when extractComments is a RegExp", async () => { new MinimizerPlugin({ parallel: true, From 2575275dd595cfe8f6868f6046ea482d3d46b36a Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sat, 19 Sep 2026 13:45:44 +0000 Subject: [PATCH 3/7] fix: a payload holding a function keeps the whole task on transform A required minimizer is handed the payload as it is, so an extractComments callback or a function in a minimizer's own options died as could not be cloned; generators stay callable-only, which is all the schema takes. --- README.md | 10 +++++++--- src/implementation.js | 37 ++++++++++++++++++++++++++++++++++++ src/index.js | 11 +++++++++-- test/parallel-option.test.js | 30 +++++++++++++++++++++++++++++ types/index.d.ts | 17 ++++++++++++----- 5 files changed, 95 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b148253d..beedd214 100644 --- a/README.md +++ b/README.md @@ -407,8 +407,9 @@ default one, and the tables in `implementation` may also name a **module path** rather than hold the function itself — a string, or `{ path, export }` for a named export, the way `sass-loader` takes its `implementation`. A worker then `require`s the -minimizer, instead of being handed its source to rebuild with `new Function`, -which is what the default terser does: +minimizer, which is what the default terser is named by; one written as a +function is handed over as source for the worker to rebuild with `new +Function`: ```js new MinimizerPlugin({ @@ -424,7 +425,10 @@ new MinimizerPlugin({ The saving is per task, so it needs every minimizer of that task to be nameable: one written as a function anywhere in the list leaves the whole task on the -source path, since that function has no other way across. +source path, since that function has no other way across. So does a function +anywhere in what the task carries — an `extractComments` callback, or a +minimizer's own options holding one — because a required minimizer is handed +the payload as it is, and a structured clone throws on a function. `filter(name, info)` states which assets this minimizer is offered — return `false` to decline one, and anything else (`undefined` included) to accept. It diff --git a/src/implementation.js b/src/implementation.js index ea63f1d5..4f6dac7f 100644 --- a/src/implementation.js +++ b/src/implementation.js @@ -68,6 +68,30 @@ function loadImplementation(implementation) { return /** @type {MinimizerFn} */ (loaded); } +/** + * Whether a value holds a function anywhere inside it. A worker reached by + * module path is handed the payload as it is, and a structured clone throws on + * one rather than dropping it. + * @param {unknown} value what a worker would be handed + * @param {Set=} seen values already walked + * @returns {boolean} true when a function is in there + */ +function holdsFunction(value, seen = new Set()) { + if (typeof value === "function") { + return true; + } + + if (!value || typeof value !== "object" || seen.has(value)) { + return false; + } + + seen.add(value); + + return Object.values(/** @type {Record} */ (value)).some( + (one) => holdsFunction(one, seen), + ); +} + /** * True when every `minimizer.implementation` is a module path (`string` or * `{ path, export }`). Inline minify functions keep `transform`. When @@ -94,10 +118,23 @@ function canMinifyByPath(options) { return false; } + // `extractComments` and a minimizer's own options both take functions, and + // those only ever reached a worker as source. + if ( + holdsFunction(options.extractComments) || + holdsFunction(options.minimizer.options) + ) { + return false; + } + if (!options.embedded) { return true; } + if (holdsFunction(options.embedded.options)) { + return false; + } + const embedded = Array.isArray(options.embedded.implementation) ? options.embedded.implementation : [options.embedded.implementation]; diff --git a/src/index.js b/src/index.js index bf205448..0cfca205 100644 --- a/src/index.js +++ b/src/index.js @@ -221,10 +221,17 @@ const { * @typedef {undefined | boolean | number} Parallel */ +/** + * A generator is the function itself: nothing `require`s one in a worker, and + * the schema takes no module path for it. + * @template T + * @typedef {BasicMinimizerImplementation & MinimizeFunctionHelpers} GeneratorImplementation + */ + /** * One generator, written as an object stating how to run it. * @typedef {object} GeneratorDescriptor - * @property {MinimizerImplementation} implementation the generator itself + * @property {GeneratorImplementation} implementation the generator itself * @property {MinimizerOptions=} options options for this generator, preferred over the deprecated `generatorOptions` * @property {("import" | "asset")=} type `import` re-encodes a module as it is built, so the import that asked for it is renamed with it; `asset` writes a new file beside one already emitted * @property {(string | ((pathData: EXPECTED_ANY) => string))=} filename name for the generated asset, as a webpack filename template or a function answering with one. `asset` generators only @@ -238,7 +245,7 @@ const { /** * What `generate` may be written as: one generator, a list of them, a * descriptor, or an object naming descriptors an asset asks for with `?as=`. - * @typedef {MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor | { [preset: string]: MinimizerImplementation | MinimizerImplementation[] | GeneratorDescriptor }} Generate + * @typedef {GeneratorImplementation | GeneratorImplementation[] | GeneratorDescriptor | { [preset: string]: GeneratorImplementation | GeneratorImplementation[] | GeneratorDescriptor }} Generate */ /** diff --git a/test/parallel-option.test.js b/test/parallel-option.test.js index 441ff149..c3d9a030 100644 --- a/test/parallel-option.test.js +++ b/test/parallel-option.test.js @@ -156,6 +156,36 @@ describe("parallel option", () => { expect(workerMinify).not.toHaveBeenCalled(); }); + it("should use transform when `extractComments` is a function", async () => { + new MinimizerPlugin({ + parallel: true, + extractComments: (astNode, comment) => comment.value.includes("@license"), + }).apply(compiler); + + const stats = await compile(compiler); + + // The payload reaches a required minimizer as it is, and a structured + // clone throws on a function rather than dropping it. + expect(workerTransform).toHaveBeenCalled(); + expect(workerMinify).not.toHaveBeenCalled(); + expect(getErrors(stats)).toEqual([]); + }); + + it("should use transform when a minimizer's own options hold a function", async () => { + new MinimizerPlugin({ + parallel: true, + minimizerOptions: { + format: { comments: (astNode, comment) => comment.value.length > 0 }, + }, + }).apply(compiler); + + const stats = await compile(compiler); + + expect(workerTransform).toHaveBeenCalled(); + expect(workerMinify).not.toHaveBeenCalled(); + expect(getErrors(stats)).toEqual([]); + }); + it("should minify by path when extractComments is a RegExp", async () => { new MinimizerPlugin({ parallel: true, diff --git a/types/index.d.ts b/types/index.d.ts index 95c37cc7..7d046bdc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -312,6 +312,7 @@ declare namespace MinimizerPlugin { InternalOptions, MinimizerWorker, Parallel, + GeneratorImplementation, GeneratorDescriptor, Generate, BasePluginOptions, @@ -650,6 +651,12 @@ type MinimizerWorker = JestWorker & { minify: (options: InternalOptions) => Promise; }; type Parallel = undefined | boolean | number; +/** + * A generator is the function itself: nothing `require`s one in a worker, and + * the schema takes no module path for it. + */ +type GeneratorImplementation = BasicMinimizerImplementation & + MinimizeFunctionHelpers; /** * One generator, written as an object stating how to run it. */ @@ -657,7 +664,7 @@ type GeneratorDescriptor = { /** * the generator itself */ - implementation: MinimizerImplementation; + implementation: GeneratorImplementation; /** * options for this generator, preferred over the deprecated `generatorOptions` */ @@ -696,13 +703,13 @@ type GeneratorDescriptor = { * descriptor, or an object naming descriptors an asset asks for with `?as=`. */ type Generate = - | MinimizerImplementation - | MinimizerImplementation[] + | GeneratorImplementation + | GeneratorImplementation[] | GeneratorDescriptor | { [preset: string]: - | MinimizerImplementation - | MinimizerImplementation[] + | GeneratorImplementation + | GeneratorImplementation[] | GeneratorDescriptor; }; type BasePluginOptions = { From a102dd03781e7acccdcd6819796faa369710e36c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 20 Sep 2026 12:36:31 +0000 Subject: [PATCH 4/7] fix: a minimizer named by path keeps its helpers and its own identity The helpers are on the loaded function, so a module reference ran at the default stage and its flag never reached stats; and two paths reporting no version shared one cache entry. --- src/index.js | 50 +++++++++++-------- .../MinimizerPlugin.test.js.snap | 2 +- test/__snapshots__/test-option.test.js.snap | 4 +- test/parallel-option.test.js | 37 ++++++++++++++ test/stage-option.test.js | 45 +++++++++++++++++ types/index.d.ts | 2 +- 6 files changed, 115 insertions(+), 25 deletions(-) diff --git a/src/index.js b/src/index.js index 0cfca205..77c9a53e 100644 --- a/src/index.js +++ b/src/index.js @@ -659,7 +659,7 @@ class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {Record} assets assets - * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset, and what an earlier pass of this plugin already wrote onto each asset + * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset and from a run under different minimizers, and what an earlier pass of this plugin already wrote onto each asset * @returns {Promise} */ async optimize(compiler, compilation, assets, optimizeOptions) { @@ -1537,11 +1537,10 @@ class MinimizerPlugin { assetFlags() { const flags = new Set(); - for (const flag of declaredFlags( - this.options.minimizer.implementation, - "minimized", - )) { - flags.add(flag); + for (const { fn } of this.getMinimizerSlots()) { + for (const flag of declaredFlags(fn, "minimized")) { + flags.add(flag); + } } // Only the generators that write a file: an `import` one rewrites a module @@ -1915,16 +1914,15 @@ class MinimizerPlugin { * @returns {Map} the indices, by stage */ minimizersByStage(compiler) { - const { implementation } = this.options.minimizer; - const each = Array.isArray(implementation) - ? implementation - : [implementation]; + // The loaded functions rather than what was configured: a module reference + // carries none of the helpers that say where its minimizer runs. + const each = this.getMinimizerSlots(); const fallback = this.defaultStage(compiler); /** @type {Map} */ const byStage = new Map(); for (let i = 0; i < each.length; i++) { - const asked = declaredStage(compiler, each[i]); + const asked = declaredStage(compiler, each[i].fn); const at = typeof asked === "number" ? asked : fallback; const already = byStage.get(at); @@ -2419,20 +2417,22 @@ class MinimizerPlugin { const getVersion = (impl) => { // Path refs need a load; functions already carry helpers. Preset maps // and other shapes are not a single minimizer — keep the prior "0.0.0". + const ref = getImplementationModuleRef(impl); const fn = typeof impl === "function" ? impl - : getImplementationModuleRef(impl) + : ref ? loadImplementation(impl) : undefined; - - if (!fn) { - return "0.0.0"; - } - - return typeof fn.getMinimizerVersion !== "undefined" - ? fn.getMinimizerVersion() || "0.0.0" - : "0.0.0"; + const version = + fn && typeof fn.getMinimizerVersion !== "undefined" + ? fn.getMinimizerVersion() || "0.0.0" + : "0.0.0"; + + // Which module it is, not only what version it reports: two paths that + // report none are otherwise one identity, and a warm cache would answer + // for whichever ran first. + return ref ? `${version}|${ref.path}|${ref.export || ""}` : version; }; const data = getSerializeJavascript()({ minimizer: Array.isArray(this.options.minimizer.implementation) @@ -2443,6 +2443,11 @@ class MinimizerPlugin { ), options: this.options.minimizer.options, }); + const identity = crypto + .createHash("sha256") + .update(data) + .digest("hex") + .slice(0, 16); hooks.chunkHash.tap(pluginName, (chunk, hash) => { // Nothing minifying rewrites nothing, so no name owes it a hash of its @@ -2563,7 +2568,10 @@ class MinimizerPlugin { written, // Only where a second pass exists to be confused with: one pass // keeps the cache keys every earlier release wrote. - cacheSuffix: minimizersByStage.size > 1 ? `|${at}` : "", + // The minimizers and their options answer for what is cached + // under an asset's name, which otherwise varies only with its + // source. + cacheSuffix: `${minimizersByStage.size > 1 ? `|${at}` : ""}|${identity}`, }), ); } diff --git a/test/__snapshots__/MinimizerPlugin.test.js.snap b/test/__snapshots__/MinimizerPlugin.test.js.snap index 00303652..6588f7ef 100644 --- a/test/__snapshots__/MinimizerPlugin.test.js.snap +++ b/test/__snapshots__/MinimizerPlugin.test.js.snap @@ -182,7 +182,7 @@ exports[`MinimizerPlugin should work and do not use memory cache when the "cache exports[`MinimizerPlugin should work and generate real content hash: assets 1`] = ` { "389.__hash3__.__hash4__.__hash2__.js": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,a)=>{if(e[o])return void e[o].push(n);let c,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{c.onerror=c.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],c.parentNode?.removeChild(c),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=u.bind(null,c.onerror),c.onload=u.bind(null,c.onload),l&&document.head.appendChild(c)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const a=r.p+r.u(t),c=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;c.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",c.name="ChunkLoadError",c.type=e,c.request=r,c.event=o,n[1](c)}};r.l(a,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,a]=o;var c,l,s=0;if(n.some(t=>0!==e[t])){for(c in i)r.o(i,c)&&(r.m[c]=i[c]);if(a)a(r)}for(t&&t(o);s{console.log("Good")})})();", + "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", } `; diff --git a/test/__snapshots__/test-option.test.js.snap b/test/__snapshots__/test-option.test.js.snap index f191d6cc..595bca5d 100644 --- a/test/__snapshots__/test-option.test.js.snap +++ b/test/__snapshots__/test-option.test.js.snap @@ -3,7 +3,7 @@ exports[`test option should match snapshot and uglify "mjs": assets 1`] = ` { "389.389.mjs?ver=__hash0__": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.mjs?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".mjs?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", + "AsyncImportExport.mjs?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".mjs?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", "importExport.mjs?var=__hash0__": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.mjs?var=__hash0__": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.mjs?var=__hash0__": "(()=>{"use strict";function o(){console.log(11)}o()})();", @@ -817,7 +817,7 @@ exports[`test option should match snapshot for multiple "test" values ({String}) exports[`test option should match snapshot with empty value: assets 1`] = ` { "389.389.js?ver=__hash0__": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.js?var=__hash0__": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.js?var=__hash0__": "(()=>{"use strict";function o(){console.log(11)}o()})();", diff --git a/test/parallel-option.test.js b/test/parallel-option.test.js index c3d9a030..8396e554 100644 --- a/test/parallel-option.test.js +++ b/test/parallel-option.test.js @@ -156,6 +156,43 @@ describe("parallel option", () => { expect(workerMinify).not.toHaveBeenCalled(); }); + it("should cache a path minimizer apart from another path", async () => { + /** + * @param {string} fixture the minimizer's module + * @returns {Promise} the cache identifiers it asked for + */ + const identifiersFrom = async (fixture) => { + const own = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + }); + const asked = []; + + own.cache.hooks.get.tap({ name: "ReadCacheKeys", stage: -100 }, (id) => { + if (id.includes("TerserWebpackPlugin")) { + asked.push(id); + } + }); + new MinimizerPlugin({ + parallel: true, + minify: path.resolve(__dirname, fixture), + }).apply(own); + + await compile(own); + + return asked; + }; + + const first = await identifiersFrom("./fixtures/minify-default-export.js"); + const second = await identifiersFrom( + "./fixtures/minify-default-property.js", + ); + + // Neither module reports a version, so the path is all that tells them + // apart — a warm cache would otherwise answer for whichever ran first. + expect(first.length).toBeGreaterThan(0); + expect(second).not.toEqual(first); + }); + it("should use transform when `extractComments` is a function", async () => { new MinimizerPlugin({ parallel: true, diff --git a/test/stage-option.test.js b/test/stage-option.test.js index 3a81bad7..d0346b96 100644 --- a/test/stage-option.test.js +++ b/test/stage-option.test.js @@ -517,6 +517,51 @@ describe("a minimizer that asks for its own stage", () => { expect(getErrors(stats)).toEqual([]); }); + it("should run a minimizer named by module path where it asks", async () => { + /** + * @param {EXPECTED_ANY} minify what to minify with + * @returns {Promise<{ names: string[], printed: string }>} what it emitted + */ + const emittedBy = async (minify) => { + const own = getCompiler({ + entry: { one: path.resolve(__dirname, "./fixtures/entry.js") }, + output: { + path: path.resolve(__dirname, "./dist"), + filename: "[name].[contenthash].js", + }, + }); + + new MinimizerPlugin({ + test: /\.js$/i, + parallel: false, + minify, + minimizerOptions: { algorithm: "gzip" }, + }).apply(own); + + const stats = await compile(own); + + return { + names: Object.keys(stats.compilation.assets).sort(), + printed: stats.toString({ relatedAssets: true }), + }; + }; + + const byFunction = await emittedBy(MinimizerPlugin.compress); + // The helpers saying where it runs and what it marks are on the loaded + // function, not on this reference. + const byPath = await emittedBy({ + path: require.resolve("../src/utils.js"), + export: "compress", + }); + + // `compress` asks to run after the hash is taken, so the name is of what + // was compressed rather than of the compressed bytes — either way of + // naming it. + expect(byPath.names).toEqual(byFunction.names); + expect(byPath.printed).toContain("[compressed]"); + expect(byPath.printed).not.toContain("[minimized]"); + }); + it("should put `compress` after the minimizers on its own", async () => { const order = []; diff --git a/types/index.d.ts b/types/index.d.ts index 7d046bdc..a5b95f0a 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -67,7 +67,7 @@ declare class MinimizerPlugin { * @param {Compiler} compiler compiler * @param {Compilation} compilation compilation * @param {Record} assets assets - * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset, and what an earlier pass of this plugin already wrote onto each asset + * @param {{ availableNumberOfCores: number, only?: number[], cacheSuffix?: string, written: Map> }} optimizeOptions how many may run at once, which minimizers this pass runs, what keeps its cache apart from another pass over the same asset and from a run under different minimizers, and what an earlier pass of this plugin already wrote onto each asset * @returns {Promise} */ private optimize; From 51f787fbb820cc50a696c16cf28a55960c389b59 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Sun, 20 Sep 2026 23:38:32 +0000 Subject: [PATCH 5/7] fix: a minimizer's module path is a cache key, not a hash The default minimizer is named by module path, so salting chunk hashes with it made every emitted file depend on where the checkout sits. The path now reaches the cache identity alone. --- src/index.js | 33 ++++++++++++++----- .../MinimizerPlugin.test.js.snap | 2 +- test/__snapshots__/test-option.test.js.snap | 4 +-- test/minify-option.test.js | 28 ++++++++++++++++ 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/index.js b/src/index.js index 77c9a53e..83ac172c 100644 --- a/src/index.js +++ b/src/index.js @@ -2424,15 +2424,18 @@ class MinimizerPlugin { : ref ? loadImplementation(impl) : undefined; - const version = - fn && typeof fn.getMinimizerVersion !== "undefined" - ? fn.getMinimizerVersion() || "0.0.0" - : "0.0.0"; - - // Which module it is, not only what version it reports: two paths that - // report none are otherwise one identity, and a warm cache would answer - // for whichever ran first. - return ref ? `${version}|${ref.path}|${ref.export || ""}` : version; + return fn && typeof fn.getMinimizerVersion !== "undefined" + ? fn.getMinimizerVersion() || "0.0.0" + : "0.0.0"; + }; + /** + * @param {MinimizerImplementationValue} impl implementation + * @returns {string} which module it is, where one names it + */ + const getModuleRef = (impl) => { + const ref = getImplementationModuleRef(impl); + + return ref ? `${ref.path}|${ref.export || ""}` : ""; }; const data = getSerializeJavascript()({ minimizer: Array.isArray(this.options.minimizer.implementation) @@ -2443,9 +2446,21 @@ class MinimizerPlugin { ), options: this.options.minimizer.options, }); + // What an asset is cached under, never what it hashes to: where a + // module sits is where the checkout is, not what the build emits. const identity = crypto .createHash("sha256") .update(data) + .update( + getSerializeJavascript()( + Array.isArray(this.options.minimizer.implementation) + ? this.options.minimizer.implementation.map(getModuleRef) + : getModuleRef( + /** @type {MinimizerImplementationValue} */ + (this.options.minimizer.implementation), + ), + ), + ) .digest("hex") .slice(0, 16); diff --git a/test/__snapshots__/MinimizerPlugin.test.js.snap b/test/__snapshots__/MinimizerPlugin.test.js.snap index 6588f7ef..00303652 100644 --- a/test/__snapshots__/MinimizerPlugin.test.js.snap +++ b/test/__snapshots__/MinimizerPlugin.test.js.snap @@ -182,7 +182,7 @@ exports[`MinimizerPlugin should work and do not use memory cache when the "cache exports[`MinimizerPlugin should work and generate real content hash: assets 1`] = ` { "389.__hash3__.__hash4__.__hash2__.js": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,a)=>{if(e[o])return void e[o].push(n);let c,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{c.onerror=c.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],c.parentNode?.removeChild(c),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=u.bind(null,c.onerror),c.onload=u.bind(null,c.onload),l&&document.head.appendChild(c)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const a=r.p+r.u(t),c=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;c.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",c.name="ChunkLoadError",c.type=e,c.request=r,c.event=o,n[1](c)}};r.l(a,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,a]=o;var c,l,s=0;if(n.some(t=>0!==e[t])){for(c in i)r.o(i,c)&&(r.m[c]=i[c]);if(a)a(r)}for(t&&t(o);s{console.log("Good")})})();", } `; diff --git a/test/__snapshots__/test-option.test.js.snap b/test/__snapshots__/test-option.test.js.snap index 595bca5d..f191d6cc 100644 --- a/test/__snapshots__/test-option.test.js.snap +++ b/test/__snapshots__/test-option.test.js.snap @@ -3,7 +3,7 @@ exports[`test option should match snapshot and uglify "mjs": assets 1`] = ` { "389.389.mjs?ver=__hash0__": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.mjs?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".mjs?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.mjs?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".mjs?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.mjs?var=__hash0__": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.mjs?var=__hash0__": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.mjs?var=__hash0__": "(()=>{"use strict";function o(){console.log(11)}o()})();", @@ -817,7 +817,7 @@ exports[`test option should match snapshot for multiple "test" values ({String}) exports[`test option should match snapshot with empty value: assets 1`] = ` { "389.389.js?ver=__hash0__": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", "importExport.js?var=__hash0__": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.js?var=__hash0__": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.js?var=__hash0__": "(()=>{"use strict";function o(){console.log(11)}o()})();", diff --git a/test/minify-option.test.js b/test/minify-option.test.js index 946a3fca..76188e80 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1864,4 +1864,32 @@ describe("minify option written as an object", () => { }), ).toThrow(/`minify` sets its own `options`/); }); + + it("should emit the same file wherever the minimizer's module sits", async () => { + /** + * @param {string} fixture the minimizer's module + * @returns {Promise} the names it emitted + */ + const namesFrom = async (fixture) => { + const compiler = getCompiler({ + entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + output: { + path: path.resolve(__dirname, "./dist-terser"), + filename: "[name].[fullhash].js", + }, + }); + + new MinimizerPlugin({ + minify: path.resolve(__dirname, fixture), + }).apply(compiler); + + return Object.keys((await compile(compiler)).compilation.assets); + }; + + // Both modules minify identically, so only where they sit differs — and + // where a module sits is where the checkout is, not what the build emits. + expect( + await namesFrom("./fixtures/minify-default-export.js"), + ).toStrictEqual(await namesFrom("./fixtures/minify-default-property.js")); + }); }); From c557b38cb61542497129414ec5029016bb2a9824 Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 21 Sep 2026 00:32:43 +0000 Subject: [PATCH 6/7] fix: a minimizer's module tells builds apart without tying them to a checkout Its path is read against the compiler context, so two modules reporting no version are two identities while one module hashes the same wherever it was cloned. A `Map` or `Set` holding a function keeps the task on the source path, which `Object.values` never saw. --- src/implementation.js | 12 ++++ src/index.js | 39 ++++++------- .../MinimizerPlugin.test.js.snap | 4 +- test/__snapshots__/test-option.test.js.snap | 4 +- test/embedded-source.test.js | 56 +++++++++++++++++++ test/fixtures/embedded/css-says-one.js | 14 +++++ test/fixtures/embedded/css-says-two.js | 14 +++++ test/implementation.test.js | 33 +++++++++++ test/minify-option.test.js | 32 ++++++++--- 9 files changed, 172 insertions(+), 36 deletions(-) create mode 100644 test/fixtures/embedded/css-says-one.js create mode 100644 test/fixtures/embedded/css-says-two.js diff --git a/src/implementation.js b/src/implementation.js index 4f6dac7f..7ef1dff1 100644 --- a/src/implementation.js +++ b/src/implementation.js @@ -87,6 +87,18 @@ function holdsFunction(value, seen = new Set()) { seen.add(value); + // A structured clone carries a `Map` or a `Set` but not what it holds, and + // neither answers to `Object.values`. + if (value instanceof Map) { + return [...value].some( + ([key, one]) => holdsFunction(key, seen) || holdsFunction(one, seen), + ); + } + + if (value instanceof Set) { + return [...value].some((one) => holdsFunction(one, seen)); + } + return Object.values(/** @type {Record} */ (value)).some( (one) => holdsFunction(one, seen), ); diff --git a/src/index.js b/src/index.js index 83ac172c..dcbed818 100644 --- a/src/index.js +++ b/src/index.js @@ -2424,18 +2424,23 @@ class MinimizerPlugin { : ref ? loadImplementation(impl) : undefined; - return fn && typeof fn.getMinimizerVersion !== "undefined" - ? fn.getMinimizerVersion() || "0.0.0" - : "0.0.0"; - }; - /** - * @param {MinimizerImplementationValue} impl implementation - * @returns {string} which module it is, where one names it - */ - const getModuleRef = (impl) => { - const ref = getImplementationModuleRef(impl); + const version = + fn && typeof fn.getMinimizerVersion !== "undefined" + ? fn.getMinimizerVersion() || "0.0.0" + : "0.0.0"; - return ref ? `${ref.path}|${ref.export || ""}` : ""; + if (!ref) { + return version; + } + + // Which module it is, read against the build rather than the disk: two + // paths reporting no version are otherwise one identity, and an + // absolute one would answer differently in another checkout. + const where = path + .relative(compiler.context, ref.path) + .replace(/\\/g, "/"); + + return `${version}|${where}|${ref.export || ""}`; }; const data = getSerializeJavascript()({ minimizer: Array.isArray(this.options.minimizer.implementation) @@ -2446,21 +2451,9 @@ class MinimizerPlugin { ), options: this.options.minimizer.options, }); - // What an asset is cached under, never what it hashes to: where a - // module sits is where the checkout is, not what the build emits. const identity = crypto .createHash("sha256") .update(data) - .update( - getSerializeJavascript()( - Array.isArray(this.options.minimizer.implementation) - ? this.options.minimizer.implementation.map(getModuleRef) - : getModuleRef( - /** @type {MinimizerImplementationValue} */ - (this.options.minimizer.implementation), - ), - ), - ) .digest("hex") .slice(0, 16); diff --git a/test/__snapshots__/MinimizerPlugin.test.js.snap b/test/__snapshots__/MinimizerPlugin.test.js.snap index 00303652..5161f710 100644 --- a/test/__snapshots__/MinimizerPlugin.test.js.snap +++ b/test/__snapshots__/MinimizerPlugin.test.js.snap @@ -99,7 +99,7 @@ exports[`MinimizerPlugin should not fail when only a js minimizer is set up but exports[`MinimizerPlugin should regenerate hash: assets 1`] = ` { "389.389.__hash4__.js": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.__hash3__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".__hash4__.js",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.__hash3__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".__hash4__.js",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.__hash2__.js": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.__hash0__.js": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.__hash1__.js": "(()=>{"use strict";function o(){console.log(11)}o()})();", @@ -182,7 +182,7 @@ exports[`MinimizerPlugin should work and do not use memory cache when the "cache exports[`MinimizerPlugin should work and generate real content hash: assets 1`] = ` { "389.__hash3__.__hash4__.__hash2__.js": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,a)=>{if(e[o])return void e[o].push(n);let c,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{c.onerror=c.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],c.parentNode?.removeChild(c),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=u.bind(null,c.onerror),c.onload=u.bind(null,c.onload),l&&document.head.appendChild(c)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const a=r.p+r.u(t),c=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;c.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",c.name="ChunkLoadError",c.type=e,c.request=r,c.event=o,n[1](c)}};r.l(a,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,a]=o;var c,l,s=0;if(n.some(t=>0!==e[t])){for(c in i)r.o(i,c)&&(r.m[c]=i[c]);if(a)a(r)}for(t&&t(o);s{console.log("Good")})})();", + "app.__hash0__.__hash1__.__hash2__.js": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+".__hash3__.__hash4__."+r.h()+".js",r.h=()=>"__hash2__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={524:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", } `; diff --git a/test/__snapshots__/test-option.test.js.snap b/test/__snapshots__/test-option.test.js.snap index f191d6cc..8fc192a0 100644 --- a/test/__snapshots__/test-option.test.js.snap +++ b/test/__snapshots__/test-option.test.js.snap @@ -715,7 +715,7 @@ __webpack_require__.r(__webpack_exports__); /***/ } }]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", "importExport.js?var=__hash0__": "/******/ (() => { // webpackBootstrap /******/ "use strict"; @@ -817,7 +817,7 @@ exports[`test option should match snapshot for multiple "test" values ({String}) exports[`test option should match snapshot with empty value: assets 1`] = ` { "389.389.js?ver=__hash0__": ""use strict";(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[389],{389(e,i,p){p.r(i);p.d(i,["default",0,"async-dep"])}}]);", - "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let a,l;if(void 0!==i){const e=document.getElementsByTagName("script");for(var s=0;s{a.onerror=a.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],a.parentNode?.removeChild(a),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=u.bind(null,a.onerror),a.onload=u.bind(null,a.onload),l&&document.head.appendChild(a)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),a=new Error,l=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;a.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",a.name="ChunkLoadError",a.type=e,a.request=r,a.event=o,n[1](a)}};r.l(c,l,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var a,l,s=0;if(n.some(t=>0!==e[t])){for(a in i)r.o(i,a)&&(r.m[a]=i[a]);if(c)c(r)}for(t&&t(o);s{console.log("Good")})})();", + "AsyncImportExport.js?var=__hash0__": "(()=>{"use strict";var e={};const t={};function r(o){const n=t[o];if(void 0!==n)return n.exports;const i=t[o]={exports:{}};return e[o](i,i.exports,r),i.exports}r.m=e,r.d=(e,t)=>{if(Array.isArray(t))for(var o=0;oPromise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>e+"."+e+".js?ver="+r.h(),r.h=()=>"__hash0__",r.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{const e={},t="minimizer-webpack-plugin:";r.l=(o,n,i,c)=>{if(e[o])return void e[o].push(n);let l,s;if(void 0!==i){const e=document.getElementsByTagName("script");for(var a=0;a{l.onerror=l.onload=null,clearTimeout(p);const n=e[o];if(delete e[o],l.parentNode?.removeChild(l),n?.forEach(e=>e(r)),t)return t(r)},p=setTimeout(u.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=u.bind(null,l.onerror),l.onload=u.bind(null,l.onload),s&&document.head.appendChild(l)}})(),r.r=e=>{Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{let e;r.g.importScripts&&(e=r.g.location+"");const t=r.g.document;if(!e&&t&&("SCRIPT"===t.currentScript?.tagName.toUpperCase()&&(e=t.currentScript.src),!e)){const r=t.getElementsByTagName("script");if(r.length){let t=r.length-1;for(;t>-1&&(!e||!/^http(s?):/.test(e));)e=r[t--].src}}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/^blob:/,"").replace(/#.*$/,"").replace(/\\?.*$/,"").replace(/\\/[^\\/]+$/,"/"),r.p=e})(),(()=>{const e={988:0};r.f.j=(t,o)=>{let n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else{const i=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=i);const c=r.p+r.u(t),l=new Error,s=o=>{if(r.o(e,t)&&(n=e[t],0!==n&&(e[t]=void 0),n)){const e=o&&("load"===o.type?"missing":o.type),r=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\\n("+e+": "+r+")",l.name="ChunkLoadError",l.type=e,l.request=r,l.event=o,n[1](l)}};r.l(c,s,"chunk-"+t,t)}};const t=(t,o)=>{let[n,i,c]=o;var l,s,a=0;if(n.some(t=>0!==e[t])){for(l in i)r.o(i,l)&&(r.m[l]=i[l]);if(c)c(r)}for(t&&t(o);a{console.log("Good")})})();", "importExport.js?var=__hash0__": "(()=>{"use strict";function o(){const o=\`baz\${Math.random()}\`;return()=>({a:"foobar"+o,b:"foo",baz:o})}console.log(o())})();", "js.js?var=__hash0__": "(()=>{var o={921(o){o.exports=function(){console.log(7)}}};const t={};(function n(r){const s=t[r];if(void 0!==s)return s.exports;const e=t[r]={exports:{}};return o[r](e,e.exports,n),e.exports})(921)})();", "mjs.js?var=__hash0__": "(()=>{"use strict";function o(){console.log(11)}o()})();", diff --git a/test/embedded-source.test.js b/test/embedded-source.test.js index ca87e3c9..cd2b15ac 100644 --- a/test/embedded-source.test.js +++ b/test/embedded-source.test.js @@ -448,6 +448,62 @@ describe("embedded source", () => { await del(cacheDirectory); }); + it("does not answer an embedded source from a cache another module filled", async () => { + const cacheDirectory = path.resolve( + __dirname, + "helpers/dist/embedded-module-cache", + ); + + await del(cacheDirectory); + + /** + * @param {string} name the CSS minimizer's module, under `fixtures/embedded` + * @returns {Promise} the stylesheet as it was embedded + */ + const buildWith = async (name) => { + const compiler = getCompiler({ + entry: fixture("entry-length.js"), + target: "node", + cache: { type: "filesystem", cacheDirectory }, + experiments: { css: true }, + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + parser: { exportType: "text" }, + }, + ], + }, + }); + + defaultPlugin({ + minify: [MinimizerPlugin.terserMinify, fixture(name)], + minimizerOptions: [{}, {}], + }).apply(compiler); + + const stats = await compile(compiler); + + expect(getErrors(stats)).toEqual([]); + + const embedded = exported(compiler, stats); + + await new Promise((resolve) => { + compiler.close(() => resolve()); + }); + + return embedded; + }; + + // Neither module reports a version and their options match, so where each + // one sits is all that tells the two entries apart. + expect(await buildWith("css-says-one.js")).toBe(".a{--said:one}"); + expect(await buildWith("css-says-two.js")).toBe(".a{--said:two}"); + expect(await buildWith("css-says-one.js")).toBe(".a{--said:one}"); + + await del(cacheDirectory); + }); + it("keeps the map a source carried into what it is embedded as", async () => { const compiler = getCompiler({ entry: fixture("entry-mapped.js"), diff --git a/test/fixtures/embedded/css-says-one.js b/test/fixtures/embedded/css-says-one.js new file mode 100644 index 00000000..da63c4c3 --- /dev/null +++ b/test/fixtures/embedded/css-says-one.js @@ -0,0 +1,14 @@ +/** + * Stands in for a CSS minifier that says one thing, next to a second module + * saying another: what each returns is how a cache mix-up shows. + * @param {{ [file: string]: string }} input a single `{ filename: code }` entry + * @returns {{ code: string }} what it made of the stylesheet + */ +function cssSaysOne() { + return { code: ".a{--said:one}" }; +} + +cssSaysOne.getTypes = () => ["css"]; +cssSaysOne.filter = (asset) => /\.css(\?.*)?$/i.test(asset); + +module.exports = cssSaysOne; diff --git a/test/fixtures/embedded/css-says-two.js b/test/fixtures/embedded/css-says-two.js new file mode 100644 index 00000000..d5e668d7 --- /dev/null +++ b/test/fixtures/embedded/css-says-two.js @@ -0,0 +1,14 @@ +/** + * Stands in for a CSS minifier that says another thing, next to a first module + * saying one: what each returns is how a cache mix-up shows. + * @param {{ [file: string]: string }} input a single `{ filename: code }` entry + * @returns {{ code: string }} what it made of the stylesheet + */ +function cssSaysTwo() { + return { code: ".a{--said:two}" }; +} + +cssSaysTwo.getTypes = () => ["css"]; +cssSaysTwo.filter = (asset) => /\.css(\?.*)?$/i.test(asset); + +module.exports = cssSaysTwo; diff --git a/test/implementation.test.js b/test/implementation.test.js index 26bf6195..e8ce00f2 100644 --- a/test/implementation.test.js +++ b/test/implementation.test.js @@ -131,6 +131,39 @@ describe("canMinifyByPath", () => { ).toBe(false); }); + it("should reject a function a `Map` option holds", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: pathImpl, + options: { rules: new Map([["one", () => true]]) }, + }, + }), + ).toBe(false); + }); + + it("should reject a function a `Set` option holds", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: pathImpl, + options: { rules: new Set([() => true]) }, + }, + }), + ).toBe(false); + }); + + it("should allow a `Map` option holding no function", () => { + expect( + canMinifyByPath({ + minimizer: { + implementation: pathImpl, + options: { rules: new Map([["one", "two"]]) }, + }, + }), + ).toBe(true); + }); + it("should allow embedded when every implementation is a path", () => { expect( canMinifyByPath({ diff --git a/test/minify-option.test.js b/test/minify-option.test.js index 76188e80..7db995b8 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1,6 +1,8 @@ import fs from "fs"; import path from "path"; +import del from "del"; + import MinimizerPlugin from "../src"; import { cleanCssMinify, @@ -1866,13 +1868,25 @@ describe("minify option written as an object", () => { }); it("should emit the same file wherever the minimizer's module sits", async () => { + const roots = path.resolve(__dirname, "./helpers/dist/checkouts"); + const minimizer = + "module.exports = (input) => ({ code: Object.values(input)[0] });\n"; + const entry = 'export default "one";\n'; + /** - * @param {string} fixture the minimizer's module + * @param {string} root a checkout of the same two files * @returns {Promise} the names it emitted */ - const namesFrom = async (fixture) => { + const namesFrom = async (root) => { + const context = path.join(roots, root, "src"); + + fs.mkdirSync(context, { recursive: true }); + fs.writeFileSync(path.join(roots, root, "mini.js"), minimizer); + fs.writeFileSync(path.join(context, "entry.js"), entry); + const compiler = getCompiler({ - entry: path.resolve(__dirname, "./fixtures/minify/es6.js"), + context, + entry: "./entry.js", output: { path: path.resolve(__dirname, "./dist-terser"), filename: "[name].[fullhash].js", @@ -1880,16 +1894,16 @@ describe("minify option written as an object", () => { }); new MinimizerPlugin({ - minify: path.resolve(__dirname, fixture), + minify: path.join(roots, root, "mini.js"), }).apply(compiler); return Object.keys((await compile(compiler)).compilation.assets); }; - // Both modules minify identically, so only where they sit differs — and - // where a module sits is where the checkout is, not what the build emits. - expect( - await namesFrom("./fixtures/minify-default-export.js"), - ).toStrictEqual(await namesFrom("./fixtures/minify-default-property.js")); + // The same minimizer, in the same place relative to the build, under two + // different roots: what is emitted cannot vary with where the checkout is. + expect(await namesFrom("one")).toStrictEqual(await namesFrom("two")); + + await del(roots); }); }); From 86c83d3b7edbfe2ac9afd36264c6be673f72ce5c Mon Sep 17 00:00:00 2001 From: alexander-akait Date: Mon, 21 Sep 2026 01:02:03 +0000 Subject: [PATCH 7/7] fix: hash the module a minimizer reference resolves to A bare specifier and a file of that name contextify alike while `require` reaches two different modules, so the identity is taken from what would be loaded. --- src/implementation.js | 13 +++++++++++++ src/index.js | 6 +++++- test/minify-option.test.js | 11 +++++++++-- types/implementation.d.ts | 9 +++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/implementation.js b/src/implementation.js index 7ef1dff1..ad8d731e 100644 --- a/src/implementation.js +++ b/src/implementation.js @@ -154,8 +154,21 @@ function canMinifyByPath(options) { return embedded.every(hasPath); } +/** + * The file `loadImplementation` would `require`, which is what tells two + * references apart: a bare specifier and a file of that name are not one module. + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {string | undefined} its resolved module, or nothing where no module is named + */ +function resolveImplementationModule(implementation) { + const ref = getImplementationModuleRef(implementation); + + return ref ? require.resolve(ref.path) : undefined; +} + module.exports = { canMinifyByPath, getImplementationModuleRef, loadImplementation, + resolveImplementationModule, }; diff --git a/src/index.js b/src/index.js index dcbed818..80da7302 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ const { canMinifyByPath, getImplementationModuleRef, loadImplementation, + resolveImplementationModule, } = require("./implementation"); const { minify } = require("./minify"); const { @@ -2437,7 +2438,10 @@ class MinimizerPlugin { // paths reporting no version are otherwise one identity, and an // absolute one would answer differently in another checkout. const where = path - .relative(compiler.context, ref.path) + .relative( + compiler.context, + resolveImplementationModule(impl) || ref.path, + ) .replace(/\\/g, "/"); return `${version}|${where}|${ref.export || ""}`; diff --git a/test/minify-option.test.js b/test/minify-option.test.js index 7db995b8..147f251d 100644 --- a/test/minify-option.test.js +++ b/test/minify-option.test.js @@ -1875,9 +1875,10 @@ describe("minify option written as an object", () => { /** * @param {string} root a checkout of the same two files + * @param {string} named how the minimizer's module is spelled * @returns {Promise} the names it emitted */ - const namesFrom = async (root) => { + const namesFrom = async (root, named = "mini.js") => { const context = path.join(roots, root, "src"); fs.mkdirSync(context, { recursive: true }); @@ -1894,7 +1895,7 @@ describe("minify option written as an object", () => { }); new MinimizerPlugin({ - minify: path.join(roots, root, "mini.js"), + minify: path.join(roots, root, named), }).apply(compiler); return Object.keys((await compile(compiler)).compilation.assets); @@ -1904,6 +1905,12 @@ describe("minify option written as an object", () => { // different roots: what is emitted cannot vary with where the checkout is. expect(await namesFrom("one")).toStrictEqual(await namesFrom("two")); + // And one module named two ways is still one module, which only the file + // `require` would reach says. + expect(await namesFrom("one", "mini")).toStrictEqual( + await namesFrom("one", "mini.js"), + ); + await del(roots); }); }); diff --git a/types/implementation.d.ts b/types/implementation.d.ts index eed3b9bb..b2499287 100644 --- a/types/implementation.d.ts +++ b/types/implementation.d.ts @@ -39,3 +39,12 @@ export function getImplementationModuleRef( * @returns {MinimizerFn} the minify function */ export function loadImplementation(implementation: unknown): MinimizerFn; +/** + * The file `loadImplementation` would `require`, which is what tells two + * references apart: a bare specifier and a file of that name are not one module. + * @param {unknown} implementation a minify function, module path, or path ref + * @returns {string | undefined} its resolved module, or nothing where no module is named + */ +export function resolveImplementationModule( + implementation: unknown, +): string | undefined;