From 47a31e21831fc4e93dccba526b1c82fc62b517f9 Mon Sep 17 00:00:00 2001 From: Thomas Laurie Date: Tue, 8 Sep 2026 13:49:33 -0700 Subject: [PATCH] fix(extract-comments): decode Buffer sources before merging An existing comments file emitted by another plugin can be backed by a Buffer, which has no split method. Decode it as UTF-8 before merging. --- .../decode-buffer-sources-before-merging.md | 5 ++ src/index.js | 13 +++- .../extractComments-option.test.js.snap | 59 +++++++++++++++++++ test/extractComments-option.test.js | 20 +++++++ test/helpers/ExistingCommentsFile.js | 8 ++- 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 .changeset/decode-buffer-sources-before-merging.md diff --git a/.changeset/decode-buffer-sources-before-merging.md b/.changeset/decode-buffer-sources-before-merging.md new file mode 100644 index 00000000..0c6700c5 --- /dev/null +++ b/.changeset/decode-buffer-sources-before-merging.md @@ -0,0 +1,5 @@ +--- +"minimizer-webpack-plugin": patch +--- + +fix(extract-comments): decode Buffer sources before merging diff --git a/src/index.js b/src/index.js index 362e0622..de9c7350 100644 --- a/src/index.js +++ b/src/index.js @@ -1032,12 +1032,19 @@ class TerserPlugin { let source = await cache.getPromise(name, eTag); if (!source) { + const prevValue = prevSource.source(); + const extractedValue = extractedCommentsSource.source(); + source = new ConcatSource( [ ...new Set([ - .../** @type {string} */ (prevSource.source()).split("\n\n"), - .../** @type {string} */ ( - extractedCommentsSource.source() + ...(typeof prevValue === "string" + ? prevValue + : prevValue.toString("utf8") + ).split("\n\n"), + ...(typeof extractedValue === "string" + ? extractedValue + : extractedValue.toString("utf8") ).split("\n\n"), ]), ].join("\n\n"), diff --git a/test/__snapshots__/extractComments-option.test.js.snap b/test/__snapshots__/extractComments-option.test.js.snap index ce88f19d..bbb5c484 100644 --- a/test/__snapshots__/extractComments-option.test.js.snap +++ b/test/__snapshots__/extractComments-option.test.js.snap @@ -6088,6 +6088,65 @@ exports[`extractComments option should match snapshot when no condition, preserv exports[`extractComments option should match snapshot when no condition, preserve only \`/@license/i\` comments and extract "some" comments: warnings 1`] = `[]`; +exports[`extractComments option should work with the existing licenses file, when it is a Buffer: assets 1`] = ` +{ + "chunks/203.203.js": "/*! For license information please see ../licenses.txt */ +(self.webpackChunkminimizer_webpack_plugin=self.webpackChunkminimizer_webpack_plugin||[]).push([[203],{203(e){e.exports=Math.random()}}]);", + "filename/four.js": "/*! For license information please see ../licenses.txt */ +(()=>{var t={250(t){t.exports=Math.random()}};const o={};(function r(n){const s=o[n];if(void 0!==s)return s.exports;const e=o[n]={exports:{}};return t[n](e,e.exports,r),e.exports})(250)})();", + "filename/one.js": "/*! For license information please see ../licenses.txt */ +(()=>{var e={855(e,t,r){r.e(203).then(r.t.bind(r,203,23)),e.exports=Math.random()}};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,(()=>{const e=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;let t;r.t=function(o,n){if(1&n&&(o=this(o)),8&n)return o;if("object"==typeof o&&o){if(4&n&&o.__esModule)return o;if(16&n&&"function"==typeof o.then)return o}const i=Object.create(null);r.r(i);const c={};t=t||[null,e({}),e([]),e(e)];for(var a=2&n&&o;("object"==typeof a||"function"==typeof a)&&!~t.indexOf(a);a=e(a))Object.getOwnPropertyNames(a).forEach(e=>c[e]=()=>o[e]);return c.default=()=>o,r.d(i,c),i}})(),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=>"chunks/"+e+"."+e+".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={101: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{var t={35(t){t.exports=Math.random()}};const o={};(function r(n){const s=o[n];if(void 0!==s)return s.exports;const e=o[n]={exports:{}};return t[n](e,e.exports,r),e.exports})(35)})();", + "filename/two.js": "/*! For license information please see ../licenses.txt */ +(()=>{var t={12(t){t.exports=Math.random()}};const o={};(function r(n){const s=o[n];if(void 0!==s)return s.exports;const e=o[n]={exports:{}};return t[n](e,e.exports,r),e.exports})(12)})();", + "licenses.txt": "// Existing Comment + +/*! Legal Comment */ + +/** @license Copyright 2112 Moon. **/ + + +/** + * Duplicate comment in difference files. + * @license MIT + */ + + +/*! Legal Foo */ + +/** + * @preserve Copyright 2009 SomeThirdParty. + * Here is the full license text and copyright + * notice for this file. Note that the notice can span several + * lines and is only terminated by the closing star and slash: + */ + +/** + * Utility functions for the foo package. + * @license Apache-2.0 + */ + +// @lic + +/** + * Duplicate comment in same file. + * @license MIT + */ + + +/** + * Information. + * @license MIT + */ +", +} +`; + +exports[`extractComments option should work with the existing licenses file, when it is a Buffer: errors 1`] = `[]`; + +exports[`extractComments option should work with the existing licenses file, when it is a Buffer: warnings 1`] = `[]`; + exports[`extractComments option should work with the existing licenses file: assets 1`] = ` { "chunks/203.203.js": "/*! For license information please see ../licenses.txt */ diff --git a/test/extractComments-option.test.js b/test/extractComments-option.test.js index 528960b2..a951b3a7 100644 --- a/test/extractComments-option.test.js +++ b/test/extractComments-option.test.js @@ -631,6 +631,26 @@ describe("extractComments option", () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); }); + it("should work with the existing licenses file, when it is a Buffer", async () => { + new ExistingCommentsFile({ asBuffer: true }).apply(compiler); + new MinimizerPlugin({ + extractComments: { + filename: "licenses.txt", + }, + }).apply(compiler); + + const stats = await compile(compiler); + + const licenses = readAsset("licenses.txt", compiler, stats); + + expect(licenses).toContain("// Existing Comment"); + expect(licenses).toContain("/*! Legal Comment */"); + + expect(readsAssets(compiler, stats)).toMatchSnapshot("assets"); + expect(getErrors(stats)).toMatchSnapshot("errors"); + expect(getWarnings(stats)).toMatchSnapshot("warnings"); + }); + it("should keep the comments of every asset sharing a file, when they are not adjacent", async () => { // Assets reach the comments file in name order, so `b` sits between the two // that share `shared.txt`. diff --git a/test/helpers/ExistingCommentsFile.js b/test/helpers/ExistingCommentsFile.js index 37619458..60f67e59 100644 --- a/test/helpers/ExistingCommentsFile.js +++ b/test/helpers/ExistingCommentsFile.js @@ -1,13 +1,19 @@ import webpack from "webpack"; export default class ExistingCommentsFile { + constructor({ asBuffer = false } = {}) { + this.asBuffer = asBuffer; + } + apply(compiler) { const plugin = { name: this.constructor.name }; compiler.hooks.thisCompilation.tap(plugin, (compilation) => { compilation.hooks.additionalAssets.tap(plugin, () => { + const contents = "// Existing Comment"; + compilation.assets["licenses.txt"] = new webpack.sources.RawSource( - "// Existing Comment", + this.asBuffer ? Buffer.from(contents) : contents, ); }); });