diff --git a/.changeset/skip-incompatible-terser-plugin.md b/.changeset/skip-incompatible-terser-plugin.md new file mode 100644 index 000000000..88eb383ec --- /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 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 new file mode 100644 index 000000000..836c705c1 --- /dev/null +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -0,0 +1,159 @@ +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'; + +// 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(options?: ProjectOptions) { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-minimizer-')); + rootDirs.push(rootDir); + + 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; +} + +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 has no asset filter', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + createProject({ version: '5.5.0' }) + ); + + expect(isProjectPlugin(minimizer)).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + + it('should use the terser-webpack-plugin from the project when its filter accepts .bundle assets', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + 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); + expect(minimizer.constructor.name).toBe('TerserPlugin'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('terser-webpack-plugin@5.6.1') + ); + }); + + it('should skip a plugin whose filter rejects .bundle assets even when its version cannot be read', async () => { + const [minimizer] = await getMinimizerConfig( + 'webpack', + 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 () => { + 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..5d3adf3ec 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -1,19 +1,119 @@ +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 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; +} + +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( + 'terser-webpack-plugin/package.json', + options + ); + return (require(manifestPath) as { version: string }).version; + } catch { + return undefined; + } +} + +function describeTerserPlugin({ version }: TerserPluginCandidate) { + return version ? `terser-webpack-plugin@${version}` : 'terser-webpack-plugin'; +} + +async function resolveTerserPluginCandidate( + paths?: string[] +): Promise { + const options = paths ? { paths } : undefined; try { - terserPluginPath = require.resolve('terser-webpack-plugin', { - paths: [rootDir], - }); + const pluginPath = require.resolve('terser-webpack-plugin', options); + const plugin = await importDefaultESM(pluginPath); + return { plugin, pluginPath, version: resolveTerserPluginVersion(options) }; } catch { - terserPluginPath = require.resolve('terser-webpack-plugin'); + 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 +async function selectTerserPluginCandidate(rootDir: string) { + const projectPlugin = await resolveTerserPluginCandidate([rootDir]); + + if (projectPlugin && isSupportedTerserPlugin(projectPlugin)) { + return projectPlugin; + } + + const bundledPlugin = await resolveTerserPluginCandidate(); + + if ( + projectPlugin && + bundledPlugin && + projectPlugin.pluginPath !== bundledPlugin.pluginPath && + isSupportedTerserPlugin(bundledPlugin) + ) { + console.warn( + `${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; + } + + const fallbackPlugin = projectPlugin ?? bundledPlugin; + + if (fallbackPlugin && !isSupportedTerserPlugin(fallbackPlugin)) { + console.warn( + `${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)}.` + ); + } + + return fallbackPlugin; +} + +async function getTerserPlugin(rootDir: string) { + const candidate = await 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 candidate.plugin; } async function getTerserConfig(rootDir: string) {