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..1afcc8ae1 --- /dev/null +++ b/packages/repack/src/commands/common/config/__tests__/getMinimizerConfig.test.ts @@ -0,0 +1,97 @@ +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, + // 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}`)( + workerRequire + ); + const { code } = await deserialized( + { 'index.bundle': 'const answer = 40 + 2;' }, + undefined, + options, + false + ); + + 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 () => { + 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..10ed6fc8d 100644 --- a/packages/repack/src/commands/common/config/getMinimizerConfig.ts +++ b/packages/repack/src/commands/common/config/getMinimizerConfig.ts @@ -13,16 +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. 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: RepackTerserOptions, + extractComments: TerserMinifyArgs[3] +) { + const { repackTerserPluginPath, ...terserOptions } = minimizerOptions; + const plugin: typeof TerserPlugin = require(repackTerserPluginPath); + return plugin.terserMinify(input, sourceMap, terserOptions, extractComments); } 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, terserOptions: { format: { comments: false }, + repackTerserPluginPath: terserPluginPath, }, }); }