From f2caf847a9a0a1a9dddd8834abeefd6f67a43304 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Wed, 2 Sep 2026 10:55:36 +0700 Subject: [PATCH 1/3] fix: minify .bundle assets with terser-webpack-plugin 5.6.0 and newer Since 5.6.0 the plugin only passes assets accepted by `terserMinify.filter` (`.js`, `.cjs`, `.mjs`) to terser, so Re.Pack's `index.bundle` and `*.chunk.bundle` are dropped before minification with no error or warning. Configure a `minify` wrapper instead. A plain function carries no filter, so every asset reaches terser on old and new plugin versions alike. The wrapper forwards `getMinimizerVersion` so terser's version stays in the chunk hash. --- .changeset/wrap-terser-minify.md | 5 + .../__tests__/getMinimizerConfig.test.ts | 91 +++++++++++++++++++ .../common/config/getMinimizerConfig.ts | 23 +++++ 3 files changed, 119 insertions(+) create mode 100644 .changeset/wrap-terser-minify.md create mode 100644 packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts diff --git a/.changeset/wrap-terser-minify.md b/.changeset/wrap-terser-minify.md new file mode 100644 index 000000000..700953741 --- /dev/null +++ b/.changeset/wrap-terser-minify.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Fix production bundles shipping unminified with `terser-webpack-plugin` 5.6.0 and newer, which only minifies `.js` assets by default and silently skipped Re.Pack's `.bundle` output. diff --git a/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts new file mode 100644 index 000000000..07c39cc37 --- /dev/null +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -0,0 +1,91 @@ +import { importDefaultESM } from '../../../../helpers/index.js'; +import { getMinimizerConfig } from '../getMinimizerConfig.js'; + +jest.mock('../../../../helpers/index.js', () => ({ + ...jest.requireActual('../../../../helpers/index.js'), + importDefaultESM: jest.fn(), +})); + +const importDefaultESMMock = jest.mocked(importDefaultESM); + +// the filter `terser-webpack-plugin` puts on its own minifiers since 5.6.0 +const acceptsJsOnly = (name: string) => /\.[cm]?js(\?.*)?$/i.test(name); + +// the shape `terser-webpack-plugin` normalizes `minify` and `terserOptions` into +type NormalizedPlugin = { + options: { + minimizer: { + implementation: (( + input: Record, + sourceMap: undefined, + minimizerOptions: unknown, + extractComments: boolean + ) => Promise<{ code: string }>) & { + filter?: (name: string) => boolean; + getMinimizerVersion: () => string | undefined; + }; + options: unknown; + }; + }; +}; + +// an asset is skipped when the configured minifier declares a `filter` rejecting its name +function isMinified(minifier: { filter?: (name: string) => boolean }) { + return ( + typeof minifier.filter !== 'function' || minifier.filter('index.bundle') + ); +} + +describe('getMinimizerConfig', () => { + it('should minify .bundle assets with a plugin that only accepts .js', async () => { + const PluginMock = Object.assign(jest.fn(), { + terserMinify: Object.assign(jest.fn(), { filter: acceptsJsOnly }), + }); + importDefaultESMMock.mockResolvedValue(PluginMock); + + await getMinimizerConfig('webpack', '/project'); + + const { minify } = PluginMock.mock.calls[0][0]; + const implementation = minify ?? PluginMock.terserMinify; + + expect(implementation.filter).toBeUndefined(); + expect(isMinified(implementation)).toBe(true); + }); + + it('should run terser on a .bundle asset after worker serialization', async () => { + importDefaultESMMock.mockImplementation(async (path) => require(path)); + + const [minimizer] = await getMinimizerConfig('webpack', process.cwd()); + const { implementation, options } = ( + minimizer as unknown as NormalizedPlugin + ).options.minimizer; + + // the plugin re-evaluates the minifier from its source inside a worker + const deserialized = new Function('require', `return ${implementation}`)( + require + ); + const { code } = await deserialized( + { 'index.bundle': 'const answer = 40 + 2;' }, + undefined, + options, + false + ); + + expect(code).toBe('const answer=42;'); + }); + + it('should report the terser version the built-in minifier reports', async () => { + importDefaultESMMock.mockImplementation(async (path) => require(path)); + + const [minimizer] = await getMinimizerConfig('webpack', process.cwd()); + const { implementation } = (minimizer as unknown as NormalizedPlugin) + .options.minimizer; + const { terserMinify } = require('terser-webpack-plugin'); + + // the plugin puts this in the chunk hash, so losing it would stale the cache + expect(implementation.getMinimizerVersion()).toEqual(expect.any(String)); + expect(implementation.getMinimizerVersion()).toBe( + terserMinify.getMinimizerVersion() + ); + }); +}); diff --git a/packages/repack/src/commands/common/config/getMinimizerConfig.ts b/packages/repack/src/commands/common/config/getMinimizerConfig.ts index 3b7d0e5c5..195d79080 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -16,11 +16,34 @@ async function getTerserPlugin(rootDir: string) { return plugin; } +type TerserMinifyArgs = Parameters<(typeof TerserPlugin)['terserMinify']>; + +// since 5.6.0 the plugin's own `terserMinify` carries a `.filter` that rejects `.bundle` +// assets; a wrapper carries none. It runs in a worker, so it must stay self-contained. +function repackTerserMinify( + input: TerserMinifyArgs[0], + sourceMap: TerserMinifyArgs[1], + minimizerOptions: TerserMinifyArgs[2], + extractComments: TerserMinifyArgs[3] +) { + return require('terser-webpack-plugin').terserMinify( + input, + sourceMap, + minimizerOptions, + extractComments + ); +} + +// read on the main thread only, to keep terser's version in the chunk hash +repackTerserMinify.getMinimizerVersion = () => + require('terser-webpack-plugin').terserMinify.getMinimizerVersion?.(); + async function getTerserConfig(rootDir: string) { const TerserPlugin = await getTerserPlugin(rootDir); return new TerserPlugin({ test: /\.(js)?bundle(\?.*)?$/i, extractComments: false, + minify: repackTerserMinify, terserOptions: { format: { comments: false }, }, From 3b1b9fd7c3ac8186f4e9ee8a96b253471eea4470 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 13 Sep 2026 17:51:39 +0100 Subject: [PATCH 2/3] refactor: type the terser-webpack-plugin module inside the minify wrapper Annotate the required module as the plugin's default export so the wrapper and its getMinimizerVersion forward no longer return any. --- .../src/commands/common/config/getMinimizerConfig.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/repack/src/commands/common/config/getMinimizerConfig.ts b/packages/repack/src/commands/common/config/getMinimizerConfig.ts index 195d79080..475e4e71d 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -26,7 +26,8 @@ function repackTerserMinify( minimizerOptions: TerserMinifyArgs[2], extractComments: TerserMinifyArgs[3] ) { - return require('terser-webpack-plugin').terserMinify( + const plugin: typeof TerserPlugin = require('terser-webpack-plugin'); + return plugin.terserMinify( input, sourceMap, minimizerOptions, @@ -35,8 +36,10 @@ function repackTerserMinify( } // read on the main thread only, to keep terser's version in the chunk hash -repackTerserMinify.getMinimizerVersion = () => - require('terser-webpack-plugin').terserMinify.getMinimizerVersion?.(); +repackTerserMinify.getMinimizerVersion = () => { + const plugin: typeof TerserPlugin = require('terser-webpack-plugin'); + return plugin.terserMinify.getMinimizerVersion?.(); +}; async function getTerserConfig(rootDir: string) { const TerserPlugin = await getTerserPlugin(rootDir); From d6a0509bbe9fef1f7b5601f9f8b1fc90b1812490 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Sun, 13 Sep 2026 18:18:48 +0100 Subject: [PATCH 3/3] fix: load the resolved terser-webpack-plugin copy on every minify path The wrapper resolved the plugin by name, which inside a worker meant the copy running the worker but in-process meant Re.Pack's own pinned copy. Carry the resolved path in the serialized terser options so the wrapper loads the same copy the config chose, and let the version getter read the loaded class so the chunk hash reflects the terser that actually runs. --- .../__tests__/getMinimizerConfig.test.ts | 10 ++++- .../common/config/getMinimizerConfig.ts | 42 ++++++++++--------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts index 07c39cc37..1afcc8ae1 100644 --- a/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -60,9 +60,11 @@ describe('getMinimizerConfig', () => { minimizer as unknown as NormalizedPlugin ).options.minimizer; - // the plugin re-evaluates the minifier from its source inside a worker + // the plugin re-evaluates the minifier from its source inside a worker, + // where `require` belongs to the worker and not to Re.Pack + const workerRequire = jest.fn((id: string) => require(id)); const deserialized = new Function('require', `return ${implementation}`)( - require + workerRequire ); const { code } = await deserialized( { 'index.bundle': 'const answer = 40 + 2;' }, @@ -72,6 +74,10 @@ describe('getMinimizerConfig', () => { ); expect(code).toBe('const answer=42;'); + // it loads the copy the config resolved, not whatever `terser-webpack-plugin` means in the worker + expect(workerRequire).toHaveBeenCalledWith( + require.resolve('terser-webpack-plugin') + ); }); it('should report the terser version the built-in minifier reports', async () => { diff --git a/packages/repack/src/commands/common/config/getMinimizerConfig.ts b/packages/repack/src/commands/common/config/getMinimizerConfig.ts index 475e4e71d..10ed6fc8d 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -13,42 +13,46 @@ async function getTerserPlugin(rootDir: string) { terserPluginPath = require.resolve('terser-webpack-plugin'); } const plugin = await importDefaultESM(terserPluginPath); - return plugin; + return { plugin, terserPluginPath }; } type TerserMinifyArgs = Parameters<(typeof TerserPlugin)['terserMinify']>; +// the resolved plugin path rides along in the serialized `terserOptions` so the +// wrapper can load the same copy from inside a worker +type RepackTerserOptions = TerserMinifyArgs[2] & { + repackTerserPluginPath: string; +}; + // since 5.6.0 the plugin's own `terserMinify` carries a `.filter` that rejects `.bundle` -// assets; a wrapper carries none. It runs in a worker, so it must stay self-contained. +// assets; a wrapper carries none. The plugin re-evaluates this from source in a +// worker, so it must not reference anything outside its own scope. function repackTerserMinify( input: TerserMinifyArgs[0], sourceMap: TerserMinifyArgs[1], - minimizerOptions: TerserMinifyArgs[2], + minimizerOptions: RepackTerserOptions, extractComments: TerserMinifyArgs[3] ) { - const plugin: typeof TerserPlugin = require('terser-webpack-plugin'); - return plugin.terserMinify( - input, - sourceMap, - minimizerOptions, - extractComments - ); + const { repackTerserPluginPath, ...terserOptions } = minimizerOptions; + const plugin: typeof TerserPlugin = require(repackTerserPluginPath); + return plugin.terserMinify(input, sourceMap, terserOptions, extractComments); } -// read on the main thread only, to keep terser's version in the chunk hash -repackTerserMinify.getMinimizerVersion = () => { - const plugin: typeof TerserPlugin = require('terser-webpack-plugin'); - return plugin.terserMinify.getMinimizerVersion?.(); -}; - async function getTerserConfig(rootDir: string) { - const TerserPlugin = await getTerserPlugin(rootDir); - return new TerserPlugin({ + const { plugin: Plugin, terserPluginPath } = await getTerserPlugin(rootDir); + + // read on the main thread only, to keep terser's version in the chunk hash + const minify = Object.assign(repackTerserMinify, { + getMinimizerVersion: () => Plugin.terserMinify.getMinimizerVersion?.(), + }); + + return new Plugin({ test: /\.(js)?bundle(\?.*)?$/i, extractComments: false, - minify: repackTerserMinify, + minify, terserOptions: { format: { comments: false }, + repackTerserPluginPath: terserPluginPath, }, }); }