Skip to content

fix: skip terser-webpack-plugin versions that leave bundles unminified - #1444

Open
giaBaoJS wants to merge 3 commits into
callstack:mainfrom
giaBaoJS:fix/terser-plugin-bundle-assets
Open

fix: skip terser-webpack-plugin versions that leave bundles unminified#1444
giaBaoJS wants to merge 3 commits into
callstack:mainfrom
giaBaoJS:fix/terser-plugin-bundle-assets

Conversation

@giaBaoJS

Copy link
Copy Markdown

Summary

  • read the version of the resolved terser-webpack-plugin and only prefer the copy installed in the project root while it can still minify Re.Pack's .bundle assets
  • otherwise fall back to the copy shipped with Re.Pack, and warn about the version that was skipped
  • add getMinimizerConfig unit tests covering the selection

Why

terser-webpack-plugin 5.6.0 added per-minimizer asset filters, and its terser implementation declares filter = (name) => /\.[cm]?js(\?.*)?$/i.test(name). Re.Pack emits index.bundle and [name].chunk.bundle, so every asset is rejected by the filter and dropped before minification runs. Nothing is reported: no error, no warning, and the asset is not flagged [minimized] in stats. Production bundles simply ship unminified.

The report in #1390 attributes this to webpack internals missing under Rspack, but that is not the cause. Reproducing with the exact options Re.Pack passes, on a trivial entry, with only the plugin version changed:

bundler terser-webpack-plugin output
Rspack 1.6.0 5.5.0 210 bytes, minimized
Rspack 1.6.0 5.6.1 1347 bytes, not minimized, 0 errors, 0 warnings
webpack 5.105.4 5.5.0 16 bytes, minimized
webpack 5.105.4 5.6.1 351 bytes, not minimized, 0 errors, 0 warnings

So webpack users are affected too, and the fix should not be scoped to Rspack.

Pinning terser-webpack-plugin to 5.5.0 in packages/repack/package.json covers the fallback branch of getTerserPlugin, but not the root-first branch: a project that resolves 5.6.0 or newer at its own root still gets the silent no-op. That is the common case on pnpm and on hoisted layouts where another dependency pulls in a newer release.

Implementation notes

The version gate is expressed against terser-webpack-plugin, not against a bundler release, so it does not interact with the ongoing Rspack 2 work. When the version cannot be determined the plugin is assumed usable, which keeps the previous behaviour rather than failing a build over an unreadable manifest.

Closes #1390.

Validation

  • pnpm --filter @callstack/repack test: 34 suites, 303 tests passed
  • pnpm test: 10 tasks successful
  • pnpm typecheck, pnpm lint: clean
  • reverting only getMinimizerConfig.ts while keeping the new tests turns the two selection tests red on the assertion (the project's incompatible plugin is chosen), not on an import or compile error
  • end to end against the built package, Rspack, project root holding terser-webpack-plugin@5.6.1: before the change the asset is 1347 bytes and not minimized, after it is 210 bytes and minimized, with the fallback warning printed

terser-webpack-plugin 5.6.0 added per-minimizer asset filters and its terser
implementation only accepts `.js`, `.cjs` and `.mjs` files. Re.Pack emits
`.bundle` files, so every asset is filtered out before minification runs. No
error or warning is reported and production bundles ship unminified. This
affects both Rspack and webpack.

Read the version of the resolved plugin and keep preferring the copy installed
in the project root only while it can still minify Re.Pack's assets. Otherwise
fall back to the copy shipped with Re.Pack and warn about the version that was
skipped.
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

@giaBaoJS is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 878f75c

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

This PR includes changesets to release 6 packages
Name Type
@callstack/repack Patch
@callstack/repack-plugin-expo-modules Patch
@callstack/repack-plugin-nativewind Patch
@callstack/repack-plugin-reanimated Patch
@callstack/repack-dev-server Patch
@callstack/repack-init Patch

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

Comment thread packages/repack/src/commands/common/config/getMinimizerConfig.ts Outdated
The version check misses two cases. A plugin whose `package.json` is hidden
behind an `exports` map reports no version and gets accepted even though it
filters out `.bundle` assets, and a future release that starts accepting them
would still be rejected because it is newer than 5.6.0.

Load the resolved plugin and ask it directly: `terserMinify.filter` is what the
plugin consults before minifying an asset, so a plugin is usable when it has no
such filter or when the filter does not reject a `.bundle` name. Keep reading
the version for the warning text only, and omit it from the message when it
cannot be read.
@dannyhw

dannyhw commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

I understand the desire to fix this but honestly my personal preference will be to fix the rspack minifier rather than continuing to default to terser

@MikitasK

Copy link
Copy Markdown
Collaborator

I understand the desire to fix this but honestly my personal preference will be to fix the rspack minifier rather than continuing to default to terser

makes sense 👍 since webpack is affected too, wdyt about keeping this fallback for webpack, but using/fixing native minimizer for rspack?

@dannyhw

dannyhw commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

but i believe terser still works for webpack, don't we already prefer the users version for that?

@giaBaoJS

Copy link
Copy Markdown
Author

I measured it before answering, and terser does not still work for webpack. Same root cause as Rspack.

terser-webpack-plugin 5.6.0 added terserMinify.filter = (name) => /\.[cm]?js(\?.*)?$/i.test(name) (dist/utils.js:16 and :310). The webpack asset Re.Pack emits is literally index.bundle (plus *.chunk.bundle), so the filter returns false and the plugin drops the asset from assetsForMinify with no warning and no error.

Real builds in apps/tester-app, webpack 5.105.4, react-native webpack-bundle --platform ios --dev=false, config with no optimization.minimizer so Re.Pack's default runs (which is also the shape of templates/webpack.config.mjs):

terser-webpack-plugin in the project main.jsbundle
5.5.0 1,969,176 bytes, mangled
5.6.1 4,846,598 bytes, `/******/ var self = self

And preferring the user's version is what triggers this rather than what protects against it. webpack itself depends on terser-webpack-plugin: ^5.3.17, so a plain npm i webpack@5.105.4 puts 5.6.1 at the project root today, and resolveTerserPluginCandidate([rootDir]) looks there first. Under pnpm nothing is hoisted, Re.Pack's own pinned 5.5.0 wins, and it works. An rspack-only npm project has no terser at the root at all, so webpack is the more exposed of the two.

Two things you may want before deciding on this PR:

  1. I forced SwcJsMinimizerRspackPlugin on Rspack 1.6.0 with Re.Pack's test regex and it minified fine: 1,972,343 bytes in 2.51 s vs terser's 1,969,836 in 6.37 s, async chunk minified too. The comment in getMinimizerConfig.ts still points at the 1.5.0 regression from fix: use Terser for Rspack 1.5.0 and above temporarily #1273. That may already be unblocked, which would be your preferred direction. I did not run the app, so this is "produces minified output", not "verified at runtime".

  2. There is a smaller fix than what this PR currently does. A plain function has no .filter property, so any version accepts the asset:

new TerserPlugin({
  test: /\.(js)?bundle(\?.*)?$/i,
  extractComments: false,
  minify: function repackTerserMinify(input, sourceMap, minimizerOptions, extractComments) {
    return require('terser-webpack-plugin').terserMinify(
      input, sourceMap, minimizerOptions, extractComments
    );
  },
  terserOptions: { format: { comments: false } },
})

I ran that with 5.6.1 in both the project and Re.Pack's own node_modules, so the default terser was a confirmed no-op and only the wrapper could do work: 1,969,176 bytes, byte identical to the 5.5.0 output. terserMinify is exported by 5.5.0 as well, and the wrapper is self contained so it survives worker serialization. It drops the capability probe, the fallback, and the warning.

Happy to rewrite #1444 as that wrapper, or to close it if you would rather fix the Rspack minimizer first and handle webpack separately.

@dannyhw

dannyhw commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

I see, seems like i may have misunderstood the issue then. What do you propose as the right solution? I.e do you think what you mentioned in your comment (wrapper) is better or the solution presented in the pr already?

@giaBaoJS

Copy link
Copy Markdown
Author

The wrapper, and I would rather rewrite this PR as that than merge what is here now.

What is here reacts to the symptom. It probes whether the installed plugin will refuse .bundle, then falls back to Re.Pack's own pinned copy and warns. It works, but it needs a capability probe, a fallback path and a warning string to do it, and the user ends up minifying with a different terser than the one in their lockfile.

The wrapper removes the condition instead. In 5.6.1 the dispatch is index.js:357:

if (typeof impl.filter !== "function" || ... impl.filter(name, info) !== false) {

so the filter is only consulted when the configured minify has one. Every built-in gets .filter assigned in utils.js, but a plain function does not, so passing our own named function makes every version from 5.5.0 up minify the asset, and the version that runs is the user's own. That is what "we prefer the user's version" was supposed to give them in the first place.

The judgement call worth saying out loud: we would be deliberately stepping around a filter the plugin author added. I think it is defensible here, since the doc comment on that option describes it as "return true when the minimizer supports the asset" and terser genuinely does support this asset. .bundle is JavaScript, and JS_FILE_RE is an extension heuristic rather than a capability test. But it is a call, not a fact, so it is yours to make rather than mine.

Say the word and I will rewrite it. If you would rather land the Rspack minimizer switch first and treat webpack on its own, I am equally happy to close this and open the wrapper as a separate small PR whenever it suits you.

@dannyhw

dannyhw commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

@giaBaoJS ok that makes sense, sounds like the wrapper would be better. If you have the capacity that would be really great if you add those changes

terser-webpack-plugin 5.6.0 attached a filter to its built-in minifiers that
only accepts .js, .cjs and .mjs, so Re.Pack's .bundle assets were dropped
silently. The plugin only consults that filter when the configured minify
implementation has one, so pass a wrapper instead of the capability probe and
fallback. Minification now works on every version, with the user's own terser.
@giaBaoJS

Copy link
Copy Markdown
Author

Rewritten as the wrapper, pushed as a separate commit (878f75c) so the delta from the probe version is visible.

Against main the source change is now +20/-2 in getMinimizerConfig.ts: getTerserPlugin also returns the path it resolved, and getTerserConfig passes a minify. The probe, the fallback and the warning are gone.

apps/tester-app, --platform ios --dev=false, cache cleared between runs, terser-webpack-plugin swapped at the app root. Re-measured from scratch, not carried over from my earlier comment:

bundler plugin before after
webpack 5.6.1 4,846,598 1,969,176
webpack 5.5.0 1,969,176 1,969,176
rspack 5.6.1 4,822,812 1,969,836
rspack 5.5.0 1,969,836 1,969,836

All four "after" bundles are byte-identical to their 5.5.0 baseline. __webpack_require__ occurrences go 6157 -> 50 on webpack and 6144 -> 46 on rspack and the /******/ var self = self || ... runtime banner is gone, so it is mangling and not just whitespace. The async chunk goes 728 -> 455 bytes, so *.chunk.bundle is covered by the test regex as well. 5.5.0 has no filter machinery at all (no impl.filter in dist/index.js, no JS_FILE_RE in dist/utils.js) and does export terserMinify, so the wrapper is a no-op there, which is what the identical 5.5.0 rows show.

Two things came out differently from the sketch in my earlier comment.

The wrapper cannot say require('terser-webpack-plugin') by name. It is serialized with serialize-javascript and re-evaluated in a worker (index.js:528 into minify.js transform), where require resolves from the plugin's own dist/, which is the correct copy. But with parallel off, or when getAvailableNumberOfCores returns 0, the same function runs in-process and require resolves from Re.Pack's dist/ instead. Under pnpm with a direct terser-webpack-plugin dependency those are two different copies, which is the same "not the terser in your lockfile" problem I was arguing against. So the resolved path is baked into the body:

new Function(
  `return function repackTerserMinify(input, sourceMap, minimizerOptions, extractComments) {
  return require(${JSON.stringify(pluginPath)}).terserMinify(input, sourceMap, minimizerOptions, extractComments);
}`
)()

new Function is there for the serialization constraint rather than for style: anything the wrapper closes over is gone by the time it runs, and it fails at runtime, not at build time. Checked both directions on a standalone webpack 5.105.4 + plugin 5.6.1 build. The wrapper logs isMainThread=false, so it really is going through a worker, and the closure-capturing variant fails with:

index.bundle from Terser plugin
TerserPlugin is not defined
ReferenceError: TerserPlugin is not defined
    at repackTerserMinify (eval at transform (node_modules/terser-webpack-plugin/dist/minify.js:328:3), <anonymous>:5:5)

getMinimizerVersion has to be carried across. The plugin folds it into the chunk hash (index.js:724) and Re.Pack builds with cache: { type: 'filesystem' } (getCommandConfig.ts:19), so a bare wrapper pins it to "0.0.0" and bumping terser stops invalidating the cache. One line copies it onto the wrapper; it does not bring filter along.

Tests rewritten. The old ones asserted which copy of the plugin got selected, which is machinery that no longer exists. The new ones put a fake project plugin through the same two steps the real one does, filter and then a toString() round trip through new Function, and assert the .bundle asset comes back minified. Reverting only getMinimizerConfig.ts to main and keeping the tests gives:

● getMinimizerConfig › should minify .bundle assets with a project plugin that only accepts .js
    - project(const answer = 40 + 2;
    + const answer = 40 + 2;

so it goes red because the asset comes back untouched, not because of a missing import.

The warning has nothing left to report, since every version now minifies. Changeset description updated to match.

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.

Default minimizer silently no-ops with terser-webpack-plugin >= 5.6.0 under Rspack — production bundles ship unminified

3 participants