Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/skip-incompatible-terser-plugin.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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();
});
});
118 changes: 109 additions & 9 deletions packages/repack/src/commands/common/config/getMinimizerConfig.ts
Original file line number Diff line number Diff line change
@@ -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<TerserPluginCandidate | undefined> {
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<typeof TerserPlugin>(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<typeof TerserPlugin>(terserPluginPath);
return plugin;
return candidate.plugin;
}

async function getTerserConfig(rootDir: string) {
Expand Down