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 }, },