diff --git a/.changeset/wrap-terser-minify.md b/.changeset/wrap-terser-minify.md new file mode 100644 index 000000000..eeab2ed54 --- /dev/null +++ b/.changeset/wrap-terser-minify.md @@ -0,0 +1,9 @@ +--- +"@callstack/repack": patch +--- + +Minify `.bundle` assets with every version of `terser-webpack-plugin`. Since 5.6.0 +the plugin only hands `.js`, `.cjs` and `.mjs` assets to its built-in terser +implementation, so it silently left Re.Pack's output unminified on both Rspack and +webpack. Re.Pack now configures its own `minify` wrapper, which carries no such +restriction and still runs the terser installed in the project. 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..2af542a00 --- /dev/null +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -0,0 +1,127 @@ +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 ASSET_NAME = 'index.bundle'; +const ASSET_SOURCE = 'const answer = 40 + 2;\n'; + +// the filter `terser-webpack-plugin` attaches to its built-in minifiers since 5.6.0 +const JS_ONLY_FILTER = '(name) => /\\.[cm]?js(\\?.*)?$/i.test(name)'; + +const rootDirs: string[] = []; + +function createRootDir() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repack-minimizer-')); + rootDirs.push(rootDir); + return rootDir; +} + +// installs a stand-in for `terser-webpack-plugin` in a project, so `getMinimizerConfig` +// resolves it the way it would in a real one. It normalizes options like the real plugin. +function createProjectWithPlugin(filter?: string) { + const rootDir = createRootDir(); + 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', main: './index.js' }) + ); + fs.writeFileSync( + path.join(pluginDir, 'index.js'), + 'class ProjectTerserPlugin {\n' + + ' constructor(options) {\n' + + ' this.options = {\n' + + ' minimizer: {\n' + + ' implementation: options.minify || ProjectTerserPlugin.terserMinify,\n' + + ' options: options.terserOptions,\n' + + ' },\n' + + ' };\n' + + ' }\n' + + '}\n' + + 'ProjectTerserPlugin.terserMinify = function terserMinify(input) {\n' + + " return { code: 'project(' + Object.values(input)[0] + ')' };\n" + + '};\n' + + "ProjectTerserPlugin.terserMinify.getMinimizerVersion = () => '5.99.0';\n" + + (filter ? `ProjectTerserPlugin.terserMinify.filter = ${filter};\n` : '') + + 'module.exports = ProjectTerserPlugin;\n' + ); + + return rootDir; +} + +// the options `terser-webpack-plugin` normalizes a `minify` implementation into +function getMinimizer(minimizer: unknown) { + return ( + minimizer as { + options: { minimizer: { implementation: any; options: unknown } }; + } + ).options.minimizer; +} + +// mirrors how the plugin handles one asset: `filter` decides whether it is minified +// at all, and the implementation is re-evaluated from its source inside a worker +async function minifyAsset(minimizer: unknown, assetName = ASSET_NAME) { + const { implementation, options } = getMinimizer(minimizer); + if (implementation.filter?.(assetName, {}) === false) { + return ASSET_SOURCE; + } + const inWorker = new Function('require', `return ${implementation}`)(require); + const { code } = await inWorker( + { [assetName]: ASSET_SOURCE }, + undefined, + options, + false + ); + return code; +} + +describe('getMinimizerConfig', () => { + afterAll(() => { + for (const rootDir of rootDirs) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it('should minify .bundle assets with a project plugin that only accepts .js', async () => { + const rootDir = createProjectWithPlugin(JS_ONLY_FILTER); + const [minimizer] = await getMinimizerConfig('webpack', rootDir); + + await expect(minifyAsset(minimizer)).resolves.toBe( + `project(${ASSET_SOURCE})` + ); + }); + + it('should minify .bundle assets with a project plugin that has no filter', async () => { + const rootDir = createProjectWithPlugin(); + const [minimizer] = await getMinimizerConfig('webpack', rootDir); + + await expect(minifyAsset(minimizer)).resolves.toBe( + `project(${ASSET_SOURCE})` + ); + }); + + it('should minify .bundle assets with the plugin shipped with Re.Pack', async () => { + const [minimizer] = await getMinimizerConfig('webpack', createRootDir()); + + await expect(minifyAsset(minimizer)).resolves.toBe('const answer=42;'); + }); + + it('should keep reporting the version of the wrapped implementation', async () => { + const rootDir = createProjectWithPlugin(JS_ONLY_FILTER); + const [minimizer] = await getMinimizerConfig('webpack', rootDir); + + expect(getMinimizer(minimizer).implementation.getMinimizerVersion()).toBe( + '5.99.0' + ); + }); +}); diff --git a/packages/repack/src/commands/common/config/getMinimizerConfig.ts b/packages/repack/src/commands/common/config/getMinimizerConfig.ts index 3b7d0e5c5..c1fa528cd 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -13,14 +13,30 @@ async function getTerserPlugin(rootDir: string) { terserPluginPath = require.resolve('terser-webpack-plugin'); } const plugin = await importDefaultESM(terserPluginPath); - return plugin; + return { plugin, path: terserPluginPath }; +} + +// since 5.6.0 `terserMinify.filter` rejects anything but `.js`, `.cjs` and `.mjs`, +// and the plugin only consults `filter` when the configured `minify` has one. +// The wrapper is serialized into a worker, so it must not close over anything. +function createTerserMinify(plugin: typeof TerserPlugin, pluginPath: string) { + const minify = new Function( + `return function repackTerserMinify(input, sourceMap, minimizerOptions, extractComments) { + return require(${JSON.stringify(pluginPath)}).terserMinify(input, sourceMap, minimizerOptions, extractComments); +}` + )(); + // keeps the terser version in the chunk hash, like the unwrapped implementation + minify.getMinimizerVersion = plugin.terserMinify.getMinimizerVersion; + return minify as (typeof TerserPlugin)['terserMinify']; } async function getTerserConfig(rootDir: string) { - const TerserPlugin = await getTerserPlugin(rootDir); + const { plugin: TerserPlugin, path: pluginPath } = + await getTerserPlugin(rootDir); return new TerserPlugin({ test: /\.(js)?bundle(\?.*)?$/i, extractComments: false, + minify: createTerserMinify(TerserPlugin, pluginPath), terserOptions: { format: { comments: false }, },