Skip to content

feat: let a generator say what is worth writing, and minify nothing - #742

Merged
alexander-akait merged 17 commits into
mainfrom
feat/generator-compression-options
Sep 18, 2026
Merged

alexander-akait merged 17 commits into
mainfrom
feat/generator-compression-options

Conversation

@alexander-akait

@alexander-akait alexander-akait commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

Writing a file beside another is not always worth it, so an asset generator now takes the three things that decide. threshold skips an asset too small to bother with, before the generator is even asked. minRatio drops a result that is not enough smaller than what it read, since a file that saves nothing still costs a request. relatedName records the new file under that key on the one it came from — which is how a server asked for the original finds it — and declines an asset already carrying it.

minify takes false, for an instance whose whole job is its generator. That also settles what test defaults to: the .js default belongs to minifying, and applying it with nothing to minify hid every image from the generator, which was the point of the instance. Nothing minifying is represented as an empty list of minimizers rather than a missing one, so every pass over them simply does nothing and no call site needs a guard.

Two more follow from the same work. The generated file inherits the original's immutable only where the name it was given still derives from the original's, since that is what carried the hash the promise rests on. And the generator tap asks for additionalAssets, reading what the hook hands it rather than the whole compilation, so a file another plugin emits late still gets the one that belongs beside it.

Together with getStage and getAssetFlag this is everything compression-webpack-plugin needs from a generator, which is what it is for.

What kind of change does this PR introduce?

feat.

Did you add tests for your changes?

Yes, ten cases in test/generate-option.test.js: threshold above and below, minRatio keeping and dropping, relatedName recorded and declined, minify: false with and without a test, that nothing is minified, and an asset emitted at PROCESS_ASSETS_STAGE_REPORT still getting its twin. 667 green.

Does this PR introduce a breaking change?

No. All three generator fields and minify: false are opt-in, and test keeps its .js default wherever anything minifies. The immutable change is a narrowing — a generated file that inherited the flag without carrying the original's name no longer does, which was a wrong promise.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

The three generator fields and minify: false want a line each in the generate and minify sections of the README.

Use of AI

Claude Code was used for the implementation, tests and this description. The additionalAssets case was checked by reverting the option and confirming the test fails — worth noting because the first attempt at that check patched the minimizer tap instead, the option text being identical in both.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QzYJtFXxR2SJRGUWehnRGu


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added asset-generation controls for minimum source size, compression ratio, and related-file metadata.
    • Generated assets can now be processed alongside assets added later in the compilation.
    • Asset immutability and hash handling now reflect generated filename patterns and configured minimizers.
  • Bug Fixes

    • Prevented duplicate related assets and unnecessary generation when thresholds are not met.
    • Improved coordination between asset generation and minification during builds.
  • Validation

    • Restricted asset-only generation options to supported generator types.
    • minify: false is no longer accepted.

Compressing an asset is not always worth a second file, so an `asset`
generator now takes the three things that decide it. `threshold` skips an
asset too small to bother with, before the generator is even asked.
`minRatio` drops a result that is not enough smaller than what it read, since
a file that saves nothing still costs a request. `relatedName` records the new
file under that key on the one it came from — which is how a server asked for
the original finds it — and declines an asset already carrying it.

`minify` takes `false`, for an instance whose whole job is its generator. That
also settles what `test` defaults to: the `.js` default belongs to minifying,
and applying it with nothing to minify hid every image from the generator, so
it is not applied then.

Two more that follow from the same work. The generated file inherits the
original's `immutable` only where the name it was given still derives from the
original's, since that is what carried the hash the promise rests on. And the
generator tap asks for `additionalAssets`, reading what the hook hands it
rather than the whole compilation, so a file another plugin emits late still
gets the one that belongs beside it.
@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 214b37e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
minimizer-webpack-plugin Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

`minify: false` stopped the minimizing but not the tap that varies every
chunk's hash on what the minimizers are — so adding a generator-only instance
renamed every file a user serves, which is the one thing a plugin that
minifies nothing has no business doing. The salt and the embedded-source hooks
now go up only where something minifies.

Found by rewiring `compression-webpack-plugin` onto this: its suite failed on
a `[fullhash]` that moved, and the hash a build is named by is not something
to notice from a diff.
`false` joins the list the schema renders when `minify` is given something
else, so the message that lists it moved. The Node 20+ rows run the whole
suite with snapshots enforced — the older rows pass `-u` and would never have
caught it, which is how it reached CI.
@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.73%. Comparing base (319e3c4) to head (214b37e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #742      +/-   ##
==========================================
+ Coverage   97.66%   97.73%   +0.07%     
==========================================
  Files           4        4              
  Lines        1497     1545      +48     
  Branches      545      580      +35     
==========================================
+ Hits         1462     1510      +48     
  Misses         35       35              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Patch coverage named both, and both are worth saying out loud rather than
reaching by accident. A generated file stops claiming `immutable` when the
name it was given carries none of the original's — the promise rested on a
hash that is no longer in the name — and the three compression fields are
refused on an `import` generator, which writes no second file for them to
describe.
Compressing an asset is minifying it into a second file, so it belongs in
`minify` rather than in a generator: a `minify` descriptor naming a
`filename` writes its result there instead of over the asset, with
`threshold`, `minRatio`, `relatedName` and `deleteOriginalAssets` deciding
whether the file is worth writing at all.

Nothing has to say `minify: false` any more, so it no longer takes it. The
default minimizer is what an instance given nothing else to do gets, and an
instance configured to generate reads whatever its generator takes — the
`.js` default belongs to that minifier, not to the plugin.

One that only writes beside replaces no asset, so it no longer salts the
chunk hash, and it runs where a generator does rather than in the pass that
would have replaced its asset.
What the original's info says of its hashes, its module and where its
source came from is true of that file and not of the one written beside it,
so the new file starts from nothing rather than from a copy. `immutable` is
the exception, and only where the name it was given still derives from the
original's: that is what carried the hash the promise rests on.

`flatMap` went with it — it is newer than the Node this plugin still runs
on, and reaching it failed every build that wrote beside an asset.
A file written beside an asset is a new file, not a rewritten one, and the engine already had a place for that. Putting it under `minify` meant excluding those entries from the in-place pass, from the stage grouping and from the chunk-hash salt, giving five options that mean nothing to a real minimizer, and routing them through the generator's own code anyway.

What that work was for is kept. `minify` still does not take `false`: the JavaScript minifier is the default only for an instance given nothing else to do, so one configured to generate minifies nothing without having to say so. And a generated file still inherits nothing from the asset it was read from beyond a conditional `immutable`.
…or does

Configuring a generator is not a reason to turn the JavaScript minifier off, nor to drop the `.js` default a user's `test` overrides. `terserMinify` declines anything that is not a `.js` file by name through its own `filter`, so an instance written for images is unharmed by a default that names JavaScript.

The chunk-hash salt goes back to being unconditional with it: something always rewrites an asset now, so the salt is always earned.
The salt makes a chunk's name vary with what its minimizer is and what it is run with, which is only true of a chunk one of them rewrites. Where `test` names something else, or every minimizer's own filter declines the name, the chunk is left alone and salting it renamed a file nothing here had touched.

The name is read off the template while the hash inside it is still being computed, so every hash placeholder stands for one character: what is asked of the name is its path and extension, which no hash carries. A template a function names is unknowable, and salts as before.
…ot be reached

A file a function names cannot be read ahead of being called, so the salt stands rather than being skipped on a guess — the one case the guard has to get right in the safe direction, and now a test. The empty-string fallbacks behind each placeholder went with it: a chunk carrying neither a name nor an id is not a chunk, and what those arms guarded against was a name this only reads a path and an extension from.
Its only caller was the guard that asked whether anything minified at all, and that question has no answer to give now that something always does. Its own description still said an empty list was `minify: false`, which is no longer a thing to say.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The plugin adds threshold, minRatio, and relatedName to asset generators. It validates these options, applies them during generation, records related assets, handles existing and late outputs, and updates deletion behavior. It preserves explicit minify and test values while applying defaults only to undefined. It updates chunk hash matching, public types, schemas, tests, and the changeset.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to b5006

A supported asset-generator configuration can discard its generated output or remove it entirely, so this should be fixed before merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: generator output controls and support for generator-only operation without minification. It is concise and specific.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Expose the asset-generator descriptor in BasePluginOptions.generate. · index.d.ts:666

types/index.d.ts:666
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Expose the asset-generator descriptor in BasePluginOptions.generate.

MinimizerImplementation<EXPECTED_ANY> is a callable function type (BasicMinimizerImplementation<T> & MinimizeFunctionHelpers). It does not accept object descriptors. TypeScript users therefore cannot configure type, filename, threshold, minRatio, or relatedName.

Add and export a public descriptor type for the object form, then include it in the generate union with MinimizerImplementation<EXPECTED_ANY>.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 79223c31-df37-470e-a2f7-ad3ea01f5a7d

📥 Commits

Reviewing files that changed from the base of the PR and between 319e3c4 and 47578e5.

⛔ Files ignored due to path filters (2)
  • test/__snapshots__/test-option.test.js.snap is excluded by !**/*.snap
  • test/__snapshots__/validate-options.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (6)
  • .changeset/generator-compression-options.md
  • src/index.js
  • src/options.json
  • test/generate-option.test.js
  • test/validate-options.test.js
  • types/index.d.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.

Comment thread src/index.js Outdated
Comment thread src/index.js Outdated
Comment thread src/index.js
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.
Reads the taps the plugin left on processAssets rather than only the order its work ran in, so collapsing several stages into one hook fails here.
Two taps where a minimizer and a generator both ask for it, since a generator reads back what a minimizer wrote and cannot run in the same pass.
`additionalAssets` hands each pass whatever was emitted after it ran, its own generated files included, so a broad `test` had the minimizers reading back a compressed one.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Replace metadata when updating an existing generated asset. · index.js:1751-1752

src/index.js:1751-1752
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Replace metadata when updating an existing generated asset.

Webpack v5.109.2 merges object-form metadata with the existing asset info. A pre-existing immutable: true value therefore survives when fixed-name generation passes generatedInfo, which omits immutable. Consumers may treat changing output as immutable and serve stale content.

A callback stores its return value as the new asset info. Return generatedInfo to remove stale metadata without losing metadata intended for the generated asset.

Proposed fix
 if (compilation.getAsset(generatedName)) {
-  compilation.updateAsset(generatedName, generatedSource, generatedInfo);
+  compilation.updateAsset(generatedName, generatedSource, () => generatedInfo);
 } else {
   compilation.emitAsset(generatedName, generatedSource, generatedInfo);
 }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: d614a483-0a79-4025-9480-da7d6d39222e

📥 Commits

Reviewing files that changed from the base of the PR and between df25a57 and a6047ae.

📒 Files selected for processing (3)
  • src/index.js
  • test/generate-option.test.js
  • types/index.d.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • types/index.d.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 2 remain after this review.

webpack merges an info object into the old one, so writing over an existing name kept its immutable and sourceFilename; a function replaces instead.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟠 Major · Preserve the generated source when recording relatedName. · index.js:1774-1776

src/index.js:1774-1776
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the generated source when recording relatedName.

When neither generator.filename nor output.filename is set, generatedName falls back to name. The preceding updateAsset therefore writes generatedSource to the original asset, but this branch replaces it with the original source. If deleteOriginalAssets is set, the deletion branch also deletes that same-name generated asset.

Update only the asset info when the names differ. Preserve existing related entries.

Proposed fix
-    if (generator.deleteOriginalAssets) {
+    if (generator.deleteOriginalAssets && generatedName !== name) {
       // Deleting an asset takes everything its `related` names with it, so
       // recording this file there first would delete the file just written.
       if (compilation.getAsset(name)) {
         compilation.deleteAsset(name);
       }

       return;
     }

-    if (generator.relatedName) {
-      compilation.updateAsset(name, source, {
-        related: { [generator.relatedName]: generatedName },
-      });
+    if (generator.relatedName && generatedName !== name) {
+      compilation.updateAsset(
+        name,
+        (currentSource) => currentSource,
+        (currentInfo) => ({
+          ...currentInfo,
+          related: {
+            ...currentInfo.related,
+            [generator.relatedName]: generatedName,
+          },
+        }),
+      );
     }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: ff8599b5-df12-4443-adee-5eb23e4444f6

📥 Commits

Reviewing files that changed from the base of the PR and between a6047ae and b50063e.

📒 Files selected for processing (2)
  • src/index.js
  • test/generate-option.test.js

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.

A generator re-encoding an asset in place names it what it was called, and deleting the original then deleted the file just written, emitting nothing.
@alexander-akait
alexander-akait merged commit 816e5b8 into main Sep 18, 2026
31 checks passed
@alexander-akait
alexander-akait deleted the feat/generator-compression-options branch September 18, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant