From 2259ebddc41dddc4053628f54705d0bde7be7515 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Mon, 24 Aug 2026 16:41:46 +0700 Subject: [PATCH 1/2] fix: skip terser-webpack-plugin versions that leave bundles unminified 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. --- .changeset/skip-incompatible-terser-plugin.md | 5 + .../__tests__/getMinimizerConfig.test.ts | 111 ++++++++++++++++++ .../common/config/getMinimizerConfig.ts | 92 +++++++++++++-- 3 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 .changeset/skip-incompatible-terser-plugin.md create mode 100644 packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts diff --git a/.changeset/skip-incompatible-terser-plugin.md b/.changeset/skip-incompatible-terser-plugin.md new file mode 100644 index 000000000..61d0fa722 --- /dev/null +++ b/.changeset/skip-incompatible-terser-plugin.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Skip `terser-webpack-plugin` versions that leave production bundles unminified. Since 5.6.0 the plugin only minifies `.js`, `.cjs` and `.mjs` assets, so it silently ignored Re.Pack's `.bundle` output on both Rspack and webpack. Re.Pack now checks the resolved version, falls back to the copy it ships with, and warns when it does. 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..a6288b7b8 --- /dev/null +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getMinimizerConfig } from '../getMinimizerConfig.js'; + +// `importDefaultESM` uses a native dynamic import, which is unavailable inside +// the Jest VM, so load the resolved plugin with `require` instead +jest.mock('../../../../helpers/index.js', () => ({ + ...jest.requireActual('../../../../helpers/index.js'), + importDefaultESM: (absolutePath: string) => + Promise.resolve(require(absolutePath)), +})); + +const PROJECT_PLUGIN_MARKER = 'project-terser-webpack-plugin'; + +const rootDirs: string[] = []; + +// creates a project directory with `terser-webpack-plugin` installed in it, so +// `getMinimizerConfig` resolves it the same way it would in a real project +function createProject(version?: string) { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-minimizer-')); + rootDirs.push(rootDir); + + if (version) { + const pluginDir = path.join( + rootDir, + 'node_modules', + 'terser-webpack-plugin' + ); + + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'terser-webpack-plugin', + version, + main: 'index.js', + }) + ); + fs.writeFileSync( + path.join(pluginDir, 'index.js'), + 'module.exports = class ProjectTerserPlugin {\n' + + ' constructor(options) {\n' + + ' this.options = options;\n' + + ` this.marker = '${PROJECT_PLUGIN_MARKER}';\n` + + ' }\n' + + '};\n' + ); + } + + return rootDir; +} + +function isProjectPlugin(minimizer: unknown) { + return (minimizer as { marker?: string }).marker === PROJECT_PLUGIN_MARKER; +} + +describe('getMinimizerConfig', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterAll(() => { + for (const rootDir of rootDirs) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('should use the terser-webpack-plugin from the project when it minifies .bundle assets', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + createProject('5.5.0') + ); + + expect(isProjectPlugin(minimizer)).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should skip the terser-webpack-plugin from the project when it does not minify .bundle assets', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + createProject('5.6.1') + ); + + expect(isProjectPlugin(minimizer)).toBe(false); + expect(minimizer.constructor.name).toBe('TerserPlugin'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('terser-webpack-plugin@5.6.1') + ); + }); + + it('should keep skipping terser-webpack-plugin releases newer than the incompatible one', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + createProject('6.0.0') + ); + + expect(isProjectPlugin(minimizer)).toBe(false); + expect(minimizer.constructor.name).toBe('TerserPlugin'); + }); + + it('should use the terser-webpack-plugin shipped with Re.Pack when the project has none', async () => { + const [minimizer] = await getMinimizerConfig('webpack', createProject()); + + expect(isProjectPlugin(minimizer)).toBe(false); + expect(minimizer.constructor.name).toBe('TerserPlugin'); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/repack/src/commands/common/config/getMinimizerConfig.ts b/packages/repack/src/commands/common/config/getMinimizerConfig.ts index 3b7d0e5c5..c6dfe1c67 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -1,19 +1,93 @@ +import { bold } from 'colorette'; import semver from 'semver'; import type TerserPlugin from 'terser-webpack-plugin'; import { importDefaultESM } from '../../../helpers/index.js'; -// prefer `terser-webpack-plugin` installed in the project root to the one shipped with Re.Pack -async function getTerserPlugin(rootDir: string) { - let terserPluginPath: string; +// `terser-webpack-plugin` 5.6.0 started filtering assets by extension and only +// accepts `.js`, `.cjs` and `.mjs` files. Re.Pack emits `.bundle` files, so such +// versions skip every asset without reporting an error and leave the output +// unminified. This affects both Rspack and webpack. +const FIRST_UNSUPPORTED_TERSER_PLUGIN_VERSION = '5.6.0'; + +interface TerserPluginCandidate { + pluginPath: string; + version: string | undefined; +} + +function isSupportedTerserPlugin({ version }: TerserPluginCandidate): boolean { + const coerced = version ? semver.coerce(version) : null; + // when the version cannot be determined, assume the plugin works + if (!coerced) return true; + return semver.lt(coerced, FIRST_UNSUPPORTED_TERSER_PLUGIN_VERSION); +} + +function resolveTerserPluginVersion(options?: { paths: string[] }) { try { - terserPluginPath = require.resolve('terser-webpack-plugin', { - paths: [rootDir], - }); + const manifestPath = require.resolve( + 'terser-webpack-plugin/package.json', + options + ); + return (require(manifestPath) as { version: string }).version; } catch { - terserPluginPath = require.resolve('terser-webpack-plugin'); + return undefined; + } +} + +function resolveTerserPluginCandidate( + paths?: string[] +): TerserPluginCandidate | undefined { + const options = paths ? { paths } : undefined; + try { + const pluginPath = require.resolve('terser-webpack-plugin', options); + return { pluginPath, version: resolveTerserPluginVersion(options) }; + } catch { + return undefined; + } +} + +// prefer `terser-webpack-plugin` installed in the project root to the one shipped +// with Re.Pack, as long as it can minify Re.Pack's `.bundle` assets +function selectTerserPluginCandidate(rootDir: string) { + const projectPlugin = resolveTerserPluginCandidate([rootDir]); + const bundledPlugin = resolveTerserPluginCandidate(); + + if (projectPlugin && isSupportedTerserPlugin(projectPlugin)) { + return projectPlugin; + } + + if ( + projectPlugin && + bundledPlugin && + projectPlugin.pluginPath !== bundledPlugin.pluginPath && + isSupportedTerserPlugin(bundledPlugin) + ) { + console.warn( + `${bold('WARNING:')} Detected ${bold(`terser-webpack-plugin@${projectPlugin.version}`)} in your project, which does not minify Re.Pack's ${bold('.bundle')} assets. ` + + `Falling back to ${bold(`terser-webpack-plugin@${bundledPlugin.version}`)} shipped with Re.Pack.` + ); + return bundledPlugin; + } + + const fallbackPlugin = projectPlugin ?? bundledPlugin; + + if (fallbackPlugin && !isSupportedTerserPlugin(fallbackPlugin)) { + console.warn( + `${bold('WARNING:')} Detected ${bold(`terser-webpack-plugin@${fallbackPlugin.version}`)}, which does not minify Re.Pack's ${bold('.bundle')} assets. ` + + `Your production bundles will not be minified. Please install a version below ${bold(FIRST_UNSUPPORTED_TERSER_PLUGIN_VERSION)}.` + ); + } + + return fallbackPlugin; +} + +async function getTerserPlugin(rootDir: string) { + const candidate = selectTerserPluginCandidate(rootDir); + if (!candidate) { + throw new Error( + "Cannot resolve 'terser-webpack-plugin'. Please install it in your project." + ); } - const plugin = await importDefaultESM(terserPluginPath); - return plugin; + return importDefaultESM(candidate.pluginPath); } async function getTerserConfig(rootDir: string) { From ce4ccabd0d9f0c84574d7f68dda21f2ebb3bfd65 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Tue, 25 Aug 2026 09:05:16 +0700 Subject: [PATCH 2/2] fix: detect unsupported terser-webpack-plugin by capability, not version 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. --- .changeset/skip-incompatible-terser-plugin.md | 2 +- .../__tests__/getMinimizerConfig.test.ts | 116 +++++++++++++----- .../common/config/getMinimizerConfig.ts | 66 +++++++--- 3 files changed, 129 insertions(+), 55 deletions(-) diff --git a/.changeset/skip-incompatible-terser-plugin.md b/.changeset/skip-incompatible-terser-plugin.md index 61d0fa722..88eb383ec 100644 --- a/.changeset/skip-incompatible-terser-plugin.md +++ b/.changeset/skip-incompatible-terser-plugin.md @@ -2,4 +2,4 @@ "@callstack/repack": patch --- -Skip `terser-webpack-plugin` versions that leave production bundles unminified. Since 5.6.0 the plugin only minifies `.js`, `.cjs` and `.mjs` assets, so it silently ignored Re.Pack's `.bundle` output on both Rspack and webpack. Re.Pack now checks the resolved version, falls back to the copy it ships with, and warns when it does. +Skip `terser-webpack-plugin` versions that leave production bundles unminified. Since 5.6.0 the plugin only minifies `.js`, `.cjs` and `.mjs` assets, so it silently ignored Re.Pack's `.bundle` output on both Rspack and webpack. Re.Pack now asks the resolved plugin whether it accepts a `.bundle` asset, falls back to the copy it ships with, and warns when it does. 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 a6288b7b8..836c705c1 100644 --- a/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -13,44 +13,62 @@ jest.mock('../../../../helpers/index.js', () => ({ const PROJECT_PLUGIN_MARKER = 'project-terser-webpack-plugin'; -const rootDirs: string[] = []; +// the filter shipped by `terser-webpack-plugin` since 5.6.0, it rejects `.bundle` +const JS_ONLY_FILTER = '(name) => /\\.[cm]?js(\\?.*)?$/i.test(name)'; +// a hypothetical future filter that also accepts Re.Pack's assets +const BUNDLE_AWARE_FILTER = + '(name) => /\\.((js)?bundle|[cm]?js)(\\?.*)?$/i.test(name)'; +// the plugin calls `filter(name, info)`, a filter may rely on the second argument +const STRICT_ARITY_FILTER = + '(name, info) => { if (!info) throw new TypeError("info is required"); return true; }'; + +interface ProjectOptions { + version?: string; + filter?: string; + hideManifest?: boolean; +} // creates a project directory with `terser-webpack-plugin` installed in it, so // `getMinimizerConfig` resolves it the same way it would in a real project -function createProject(version?: string) { +function createProject(options?: ProjectOptions) { const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-minimizer-')); rootDirs.push(rootDir); - if (version) { - const pluginDir = path.join( - rootDir, - 'node_modules', - 'terser-webpack-plugin' - ); - - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, 'package.json'), - JSON.stringify({ - name: 'terser-webpack-plugin', - version, - main: 'index.js', - }) - ); - fs.writeFileSync( - path.join(pluginDir, 'index.js'), - 'module.exports = class ProjectTerserPlugin {\n' + - ' constructor(options) {\n' + - ' this.options = options;\n' + - ` this.marker = '${PROJECT_PLUGIN_MARKER}';\n` + - ' }\n' + - '};\n' - ); - } + if (!options) return rootDir; + + const { version, filter, hideManifest } = options; + const pluginDir = path.join(rootDir, 'node_modules', 'terser-webpack-plugin'); + + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'terser-webpack-plugin', + version, + main: './index.js', + // an `exports` map without a `./package.json` entry makes the manifest + // unresolvable, so no version can be read from it + ...(hideManifest ? { exports: { '.': './index.js' } } : null), + }) + ); + fs.writeFileSync( + path.join(pluginDir, 'index.js'), + 'class ProjectTerserPlugin {\n' + + ' constructor(options) {\n' + + ' this.options = options;\n' + + ` this.marker = '${PROJECT_PLUGIN_MARKER}';\n` + + ' }\n' + + '}\n' + + 'ProjectTerserPlugin.terserMinify = function terserMinify() {};\n' + + (filter ? `ProjectTerserPlugin.terserMinify.filter = ${filter};\n` : '') + + 'module.exports = ProjectTerserPlugin;\n' + ); return rootDir; } +const rootDirs: string[] = []; + function isProjectPlugin(minimizer: unknown) { return (minimizer as { marker?: string }).marker === PROJECT_PLUGIN_MARKER; } @@ -68,20 +86,30 @@ describe('getMinimizerConfig', () => { } }); - it('should use the terser-webpack-plugin from the project when it minifies .bundle assets', async () => { + it('should use the terser-webpack-plugin from the project when it has no asset filter', async () => { const [minimizer] = await getMinimizerConfig( 'webpack', - createProject('5.5.0') + createProject({ version: '5.5.0' }) ); expect(isProjectPlugin(minimizer)).toBe(true); expect(warn).not.toHaveBeenCalled(); }); - it('should skip the terser-webpack-plugin from the project when it does not minify .bundle assets', async () => { + it('should use the terser-webpack-plugin from the project when its filter accepts .bundle assets', async () => { const [minimizer] = await getMinimizerConfig( 'webpack', - createProject('5.6.1') + createProject({ version: '6.0.0', filter: BUNDLE_AWARE_FILTER }) + ); + + expect(isProjectPlugin(minimizer)).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should skip the terser-webpack-plugin from the project when its filter rejects .bundle assets', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + createProject({ version: '5.6.1', filter: JS_ONLY_FILTER }) ); expect(isProjectPlugin(minimizer)).toBe(false); @@ -91,14 +119,34 @@ describe('getMinimizerConfig', () => { ); }); - it('should keep skipping terser-webpack-plugin releases newer than the incompatible one', async () => { + it('should skip a plugin whose filter rejects .bundle assets even when its version cannot be read', async () => { const [minimizer] = await getMinimizerConfig( 'webpack', - createProject('6.0.0') + createProject({ + version: '5.6.1', + filter: JS_ONLY_FILTER, + hideManifest: true, + }) ); expect(isProjectPlugin(minimizer)).toBe(false); expect(minimizer.constructor.name).toBe('TerserPlugin'); + + const [message] = warn.mock.calls[0] as [string]; + expect(message).toContain('terser-webpack-plugin'); + // the manifest is unreachable, so the version is left out of the message + expect(message).not.toContain('5.6.1'); + expect(message).not.toContain('undefined'); + }); + + it('should keep the plugin when its filter cannot be called with a single argument', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + createProject({ version: '7.0.0', filter: STRICT_ARITY_FILTER }) + ); + + expect(isProjectPlugin(minimizer)).toBe(true); + expect(warn).not.toHaveBeenCalled(); }); it('should use the terser-webpack-plugin shipped with Re.Pack when the project has none', async () => { diff --git a/packages/repack/src/commands/common/config/getMinimizerConfig.ts b/packages/repack/src/commands/common/config/getMinimizerConfig.ts index c6dfe1c67..5d3adf3ec 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -3,24 +3,44 @@ import semver from 'semver'; import type TerserPlugin from 'terser-webpack-plugin'; import { importDefaultESM } from '../../../helpers/index.js'; -// `terser-webpack-plugin` 5.6.0 started filtering assets by extension and only -// accepts `.js`, `.cjs` and `.mjs` files. Re.Pack emits `.bundle` files, so such -// versions skip every asset without reporting an error and leave the output -// unminified. This affects both Rspack and webpack. +// `terser-webpack-plugin` 5.6.0 started filtering assets by extension and its +// terser implementation only accepts `.js`, `.cjs` and `.mjs` files. Re.Pack emits +// `.bundle` files, so such versions skip every asset without reporting an error +// and leave the output unminified. This affects both Rspack and webpack. const FIRST_UNSUPPORTED_TERSER_PLUGIN_VERSION = '5.6.0'; +// stands in for the assets Re.Pack asks the plugin to minify +const REPACK_ASSET_NAME = 'index.bundle'; + interface TerserPluginCandidate { + plugin: typeof TerserPlugin; pluginPath: string; version: string | undefined; } -function isSupportedTerserPlugin({ version }: TerserPluginCandidate): boolean { - const coerced = version ? semver.coerce(version) : null; - // when the version cannot be determined, assume the plugin works - if (!coerced) return true; - return semver.lt(coerced, FIRST_UNSUPPORTED_TERSER_PLUGIN_VERSION); +interface TerserPluginInternals { + terserMinify?: { filter?: unknown }; } +// the plugin asks its minify implementation whether an asset should be processed +// and skips the asset when `filter` returns `false`. `filter` is absent before +// 5.6.0 and missing from the plugin's typings, so probe it structurally. +function isSupportedTerserPlugin({ plugin }: TerserPluginCandidate): boolean { + const internals = plugin as unknown as TerserPluginInternals; + const { filter } = internals.terserMinify ?? {}; + + // without a `filter` every asset is accepted + if (typeof filter !== 'function') return true; + + try { + return filter(REPACK_ASSET_NAME) !== false; + } catch { + // an unexpected `filter` signature is not a reason to reject the plugin + return true; + } +} + +// used for warning messages only, the plugin's capabilities are checked directly function resolveTerserPluginVersion(options?: { paths: string[] }) { try { const manifestPath = require.resolve( @@ -33,13 +53,18 @@ function resolveTerserPluginVersion(options?: { paths: string[] }) { } } -function resolveTerserPluginCandidate( +function describeTerserPlugin({ version }: TerserPluginCandidate) { + return version ? `terser-webpack-plugin@${version}` : 'terser-webpack-plugin'; +} + +async function resolveTerserPluginCandidate( paths?: string[] -): TerserPluginCandidate | undefined { +): Promise { const options = paths ? { paths } : undefined; try { const pluginPath = require.resolve('terser-webpack-plugin', options); - return { pluginPath, version: resolveTerserPluginVersion(options) }; + const plugin = await importDefaultESM(pluginPath); + return { plugin, pluginPath, version: resolveTerserPluginVersion(options) }; } catch { return undefined; } @@ -47,14 +72,15 @@ function resolveTerserPluginCandidate( // prefer `terser-webpack-plugin` installed in the project root to the one shipped // with Re.Pack, as long as it can minify Re.Pack's `.bundle` assets -function selectTerserPluginCandidate(rootDir: string) { - const projectPlugin = resolveTerserPluginCandidate([rootDir]); - const bundledPlugin = resolveTerserPluginCandidate(); +async function selectTerserPluginCandidate(rootDir: string) { + const projectPlugin = await resolveTerserPluginCandidate([rootDir]); if (projectPlugin && isSupportedTerserPlugin(projectPlugin)) { return projectPlugin; } + const bundledPlugin = await resolveTerserPluginCandidate(); + if ( projectPlugin && bundledPlugin && @@ -62,8 +88,8 @@ function selectTerserPluginCandidate(rootDir: string) { isSupportedTerserPlugin(bundledPlugin) ) { console.warn( - `${bold('WARNING:')} Detected ${bold(`terser-webpack-plugin@${projectPlugin.version}`)} in your project, which does not minify Re.Pack's ${bold('.bundle')} assets. ` + - `Falling back to ${bold(`terser-webpack-plugin@${bundledPlugin.version}`)} shipped with Re.Pack.` + `${bold('WARNING:')} Detected ${bold(describeTerserPlugin(projectPlugin))} in your project, which does not minify Re.Pack's ${bold('.bundle')} assets. ` + + `Falling back to ${bold(describeTerserPlugin(bundledPlugin))} shipped with Re.Pack.` ); return bundledPlugin; } @@ -72,7 +98,7 @@ function selectTerserPluginCandidate(rootDir: string) { if (fallbackPlugin && !isSupportedTerserPlugin(fallbackPlugin)) { console.warn( - `${bold('WARNING:')} Detected ${bold(`terser-webpack-plugin@${fallbackPlugin.version}`)}, which does not minify Re.Pack's ${bold('.bundle')} assets. ` + + `${bold('WARNING:')} Detected ${bold(describeTerserPlugin(fallbackPlugin))}, which does not minify Re.Pack's ${bold('.bundle')} assets. ` + `Your production bundles will not be minified. Please install a version below ${bold(FIRST_UNSUPPORTED_TERSER_PLUGIN_VERSION)}.` ); } @@ -81,13 +107,13 @@ function selectTerserPluginCandidate(rootDir: string) { } async function getTerserPlugin(rootDir: string) { - const candidate = selectTerserPluginCandidate(rootDir); + const candidate = await selectTerserPluginCandidate(rootDir); if (!candidate) { throw new Error( "Cannot resolve 'terser-webpack-plugin'. Please install it in your project." ); } - return importDefaultESM(candidate.pluginPath); + return candidate.plugin; } async function getTerserConfig(rootDir: string) {