Skip to content

Commit 4bf096c

Browse files
fix: four things an automated review was right about
Deleting an asset takes everything its `related` names with it, so recording the generated file there and then deleting the original deleted the file just written — `relatedName` and `deleteOriginalAssets` together emitted nothing at all. The one is now recorded only where the other leaves the original standing, which is what `compression-webpack-plugin` has always done. A generated name that already existed was written over and then returned from, before either of those was reached, so a rebuild owed what a first build paid. The chunk-hash guard asked a `filter` for its answer while handing it an empty object for the asset info it reads, which could decline a chunk it then took: only `test`/`include`/`exclude` decide there, being the part that reads a name. And the descriptor an `asset` generator is written as had no type, so none of `type`, `filename`, `threshold`, `minRatio` or `relatedName` could be reached from TypeScript at all; `minify` no longer offers the `false` the schema rejects.
1 parent 47578e5 commit 4bf096c

3 files changed

Lines changed: 277 additions & 52 deletions

File tree

‎src/index.js‎

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -205,20 +205,40 @@ const {
205205
* @typedef {undefined | boolean | number} Parallel
206206
*/
207207

208+
/**
209+
* One generator, written as an object stating how to run it.
210+
* @typedef {object} GeneratorDescriptor
211+
* @property {MinimizerImplementation<EXPECTED_ANY>} implementation the generator itself
212+
* @property {MinimizerOptions<EXPECTED_ANY>=} options options for this generator, preferred over the deprecated `generatorOptions`
213+
* @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
214+
* @property {string=} filename name for the generated asset, as a webpack filename template. `asset` generators only
215+
* @property {((name: string) => boolean)=} filter decides per asset whether to generate from it, on top of `test`/`include`/`exclude`
216+
* @property {boolean=} deleteOriginalAssets removes the asset generated from. `asset` generators only
217+
* @property {number=} threshold generate only from assets larger than this, in bytes. `asset` generators only
218+
* @property {number=} minRatio keep the generated asset only when it is this much smaller than the one it was read from. `asset` generators only
219+
* @property {(string | false)=} relatedName the key the generated asset is recorded under in the original's `related` info. `asset` generators only
220+
*/
221+
222+
/**
223+
* What `generate` may be written as: one generator, a list of them, a
224+
* descriptor, or an object naming descriptors an asset asks for with `?as=`.
225+
* @typedef {MinimizerImplementation<EXPECTED_ANY> | MinimizerImplementation<EXPECTED_ANY>[] | GeneratorDescriptor | { [preset: string]: MinimizerImplementation<EXPECTED_ANY> | MinimizerImplementation<EXPECTED_ANY>[] | GeneratorDescriptor }} Generate
226+
*/
227+
208228
/**
209229
* @typedef {object} BasePluginOptions
210230
* @property {Rules=} test test rule
211231
* @property {Rules=} include include rile
212232
* @property {Rules=} exclude exclude rule
213233
* @property {ExtractCommentsOptions=} extractComments extract comments options
214234
* @property {Parallel=} parallel parallel option
215-
* @property {MinimizerImplementation<EXPECTED_ANY>=} generate rewrites a module's own bytes as it is built, so a re-encoding can rename the asset
235+
* @property {Generate=} generate rewrites a module's own bytes as it is built, so a re-encoding can rename the asset, or writes a new file beside one already emitted
216236
* @property {MinimizerOptions<EXPECTED_ANY>=} generatorOptions options for `generate`
217237
*/
218238

219239
/**
220240
* @template T
221-
* @typedef {T extends import("terser").MinifyOptions ? { minify?: MinimizerImplementation<T> | false | undefined, minimizerOptions?: MinimizerOptions<T> | undefined, terserOptions?: MinimizerOptions<T> | undefined } : { minify: MinimizerImplementation<T> | false, minimizerOptions?: MinimizerOptions<T> | undefined, terserOptions?: MinimizerOptions<T> | undefined }} DefinedDefaultMinimizerAndOptions
241+
* @typedef {T extends import("terser").MinifyOptions ? { minify?: MinimizerImplementation<T> | undefined, minimizerOptions?: MinimizerOptions<T> | undefined, terserOptions?: MinimizerOptions<T> | undefined } : { minify: MinimizerImplementation<T>, minimizerOptions?: MinimizerOptions<T> | undefined, terserOptions?: MinimizerOptions<T> | undefined }} DefinedDefaultMinimizerAndOptions
222242
*/
223243

224244
/**
@@ -590,31 +610,6 @@ class MinimizerPlugin {
590610
return !(exclude && (matchPart(name, exclude) || matchPart(bare, exclude)));
591611
}
592612

593-
/**
594-
* Whether any configured minimizer would be handed an asset of this name,
595-
* by the plugin's own `test`/`include`/`exclude` and then by its own filter.
596-
* @private
597-
* @param {Compiler} compiler compiler
598-
* @param {string} name asset name
599-
* @returns {boolean} true when one of them would take it
600-
*/
601-
minifiesName(compiler, name) {
602-
if (!this.matchesName(compiler, name)) {
603-
return false;
604-
}
605-
606-
const { filters } = this.options.minimizer;
607-
608-
return this.minimizers().some((implementation, i) => {
609-
const decides =
610-
filters && typeof filters[i] === "function"
611-
? filters[i]
612-
: implementation.filter;
613-
614-
return typeof decides !== "function" || decides(name, {}) !== false;
615-
});
616-
}
617-
618613
/**
619614
* @private
620615
* @param {Compiler} compiler compiler
@@ -1720,25 +1715,31 @@ class MinimizerPlugin {
17201715
generatedInfo.immutable = true;
17211716
}
17221717

1718+
// A rebuild writes over the file it wrote last time rather than a new one,
1719+
// and what is recorded below is owed either way.
17231720
if (compilation.getAsset(generatedName)) {
17241721
compilation.updateAsset(generatedName, generatedSource, generatedInfo);
1722+
} else {
1723+
compilation.emitAsset(generatedName, generatedSource, generatedInfo);
1724+
}
1725+
1726+
if (generator.deleteOriginalAssets) {
1727+
// Deleting an asset takes everything its `related` names with it, so
1728+
// recording this file there first would delete the file just written.
1729+
if (compilation.getAsset(name)) {
1730+
compilation.deleteAsset(name);
1731+
}
17251732

17261733
return;
17271734
}
17281735

1729-
compilation.emitAsset(generatedName, generatedSource, generatedInfo);
1730-
17311736
// Recorded on the asset it was read from, which is how a server asked for
17321737
// that one finds this one.
17331738
if (generator.relatedName) {
17341739
compilation.updateAsset(name, source, {
17351740
related: { [generator.relatedName]: generatedName },
17361741
});
17371742
}
1738-
1739-
if (generator.deleteOriginalAssets && compilation.getAsset(name)) {
1740-
compilation.deleteAsset(name);
1741-
}
17421743
}
17431744

17441745
/**
@@ -2328,12 +2329,11 @@ class MinimizerPlugin {
23282329
hooks.chunkHash.tap(pluginName, (chunk, hash) => {
23292330
const willBe = chunkAssetName(compilation, chunk);
23302331

2331-
// A chunk no minimizer here would be handed cannot vary with them, so
2332-
// salting it would rename a file this instance never rewrites.
2333-
if (
2334-
typeof willBe === "string" &&
2335-
!this.minifiesName(compiler, willBe)
2336-
) {
2332+
// A chunk this instance was never pointed at cannot vary with its
2333+
// minimizers, so salting it would rename a file it never rewrites. A
2334+
// `filter` is not asked: it reads an asset's info, which no asset has
2335+
// yet, and guessing one could skip the salt for an asset it then takes.
2336+
if (typeof willBe === "string" && !this.matchesName(compiler, willBe)) {
23372337
return;
23382338
}
23392339

‎test/generate-option.test.js‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,6 +2119,183 @@ describe("generate beside the minifier", () => {
21192119
});
21202120
});
21212121

2122+
describe("generate over a file that is already there", () => {
2123+
it("should record `related` and delete the original even when the file exists", async () => {
2124+
const compiler = getCompiler({
2125+
entry: path.resolve(__dirname, "./fixtures/images.js"),
2126+
module: { rules: IMAGE_RULES },
2127+
});
2128+
2129+
/** Writes the name the generator is about to write, before it runs. */
2130+
class AlreadyThere {
2131+
/**
2132+
* @param {import("webpack").Compiler} instance compiler
2133+
* @returns {void}
2134+
*/
2135+
apply(instance) {
2136+
instance.hooks.compilation.tap("AlreadyThere", (compilation) => {
2137+
compilation.hooks.processAssets.tap(
2138+
{
2139+
name: "AlreadyThere",
2140+
stage:
2141+
compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
2142+
},
2143+
() => {
2144+
compilation.emitAsset(
2145+
"image.copy.png",
2146+
new compiler.webpack.sources.RawSource(Buffer.from("stale")),
2147+
);
2148+
},
2149+
);
2150+
});
2151+
}
2152+
}
2153+
2154+
new AlreadyThere().apply(compiler);
2155+
new MinimizerPlugin({
2156+
test: /^image\.png$/i,
2157+
generate: {
2158+
implementation: (input) => ({
2159+
code: Buffer.from(Object.values(input)[0]),
2160+
}),
2161+
type: "asset",
2162+
filename: "[path][name].copy[ext]",
2163+
relatedName: "copied",
2164+
deleteOriginalAssets: true,
2165+
},
2166+
}).apply(compiler);
2167+
2168+
const stats = await compile(compiler);
2169+
const names = Object.keys(stats.compilation.assets);
2170+
2171+
expect(getErrors(stats)).toEqual([]);
2172+
// Written over rather than emitted, and the original still gone with it.
2173+
expect(names).toContain("image.copy.png");
2174+
expect(names).not.toContain("image.png");
2175+
expect(readAsset("image.copy.png", compiler, stats)).not.toBe("stale");
2176+
});
2177+
2178+
it("should record `related` on the original where it is kept", async () => {
2179+
const compiler = getCompiler({
2180+
entry: path.resolve(__dirname, "./fixtures/images.js"),
2181+
module: { rules: IMAGE_RULES },
2182+
});
2183+
2184+
/** Writes the name the generator is about to write, before it runs. */
2185+
class AlreadyThere {
2186+
/**
2187+
* @param {import("webpack").Compiler} instance compiler
2188+
* @returns {void}
2189+
*/
2190+
apply(instance) {
2191+
instance.hooks.compilation.tap("AlreadyThere", (compilation) => {
2192+
compilation.hooks.processAssets.tap(
2193+
{
2194+
name: "AlreadyThere",
2195+
stage:
2196+
compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
2197+
},
2198+
() => {
2199+
compilation.emitAsset(
2200+
"image.copy.png",
2201+
new compiler.webpack.sources.RawSource(Buffer.from("stale")),
2202+
);
2203+
},
2204+
);
2205+
});
2206+
}
2207+
}
2208+
2209+
new AlreadyThere().apply(compiler);
2210+
new MinimizerPlugin({
2211+
test: /^image\.png$/i,
2212+
generate: {
2213+
implementation: (input) => ({
2214+
code: Buffer.from(Object.values(input)[0]),
2215+
}),
2216+
type: "asset",
2217+
filename: "[path][name].copy[ext]",
2218+
relatedName: "copied",
2219+
},
2220+
}).apply(compiler);
2221+
2222+
const stats = await compile(compiler);
2223+
const original = /** @type {import("webpack").Asset} */ (
2224+
stats.compilation.getAsset("image.png")
2225+
);
2226+
2227+
expect(getErrors(stats)).toEqual([]);
2228+
expect(
2229+
/** @type {{ [key: string]: string }} */ (original.info.related).copied,
2230+
).toBe("image.copy.png");
2231+
});
2232+
});
2233+
2234+
describe("deleting the asset a file was written beside", () => {
2235+
it("should keep the generated file when `relatedName` is set too", async () => {
2236+
const compiler = getCompiler({
2237+
entry: path.resolve(__dirname, "./fixtures/images.js"),
2238+
module: { rules: IMAGE_RULES },
2239+
});
2240+
2241+
new MinimizerPlugin({
2242+
test: /^image\.png$/i,
2243+
generate: {
2244+
implementation: (input) => ({
2245+
code: Buffer.from(Object.values(input)[0]),
2246+
}),
2247+
type: "asset",
2248+
filename: "[path][name].copy[ext]",
2249+
relatedName: "copied",
2250+
deleteOriginalAssets: true,
2251+
},
2252+
}).apply(compiler);
2253+
2254+
const stats = await compile(compiler);
2255+
const names = Object.keys(stats.compilation.assets);
2256+
2257+
// Deleting an asset takes everything its `related` names with it, so the
2258+
// two together must not delete the file that was just written.
2259+
expect(getErrors(stats)).toEqual([]);
2260+
expect(names).toContain("image.copy.png");
2261+
expect(names).not.toContain("image.png");
2262+
});
2263+
2264+
it("should not mind a second generator having deleted it already", async () => {
2265+
const compiler = getCompiler({
2266+
entry: path.resolve(__dirname, "./fixtures/images.js"),
2267+
module: { rules: IMAGE_RULES },
2268+
});
2269+
2270+
/**
2271+
* @param {string} suffix what to name what it writes
2272+
* @returns {EXPECTED_ANY} one generator
2273+
*/
2274+
const copyTo = (suffix) => ({
2275+
implementation: (input) => ({
2276+
code: Buffer.from(Object.values(input)[0]),
2277+
}),
2278+
type: "asset",
2279+
filename: `[path][name].${suffix}[ext]`,
2280+
deleteOriginalAssets: true,
2281+
});
2282+
2283+
new MinimizerPlugin({
2284+
test: /^image\.png$/i,
2285+
generate: { one: copyTo("one"), two: copyTo("two") },
2286+
}).apply(compiler);
2287+
2288+
const stats = await compile(compiler);
2289+
const names = Object.keys(stats.compilation.assets);
2290+
2291+
// Both wrote, and whichever deleted second found nothing left to delete.
2292+
expect(getErrors(stats)).toEqual([]);
2293+
expect(names).toContain("image.one.png");
2294+
expect(names).toContain("image.two.png");
2295+
expect(names).not.toContain("image.png");
2296+
});
2297+
});
2298+
21222299
describe("generate from an asset emitted late", () => {
21232300
it("should generate from an asset added after the generators ran", async () => {
21242301
const seen = [];

0 commit comments

Comments
 (0)