Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/webpack5-module-minifier-plugin",
"comment": "Add support for webpack's ECMAScript method shorthand format. The plugin now detects when modules are emitted using method shorthand syntax (without 'function' keyword or arrow syntax) and wraps them appropriately for minification.",
"type": "minor"
}
],
"packageName": "@rushstack/webpack5-module-minifier-plugin"
}
6 changes: 6 additions & 0 deletions common/reviews/api/webpack5-module-minifier-plugin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export interface IRenderedModulePosition {
// @public
export const MODULE_WRAPPER_PREFIX: '__MINIFY_MODULE__(';

// @public
export const MODULE_WRAPPER_SHORTHAND_PREFIX: '__MINIFY_MODULE__({__DEFAULT_ID__';

// @public
export const MODULE_WRAPPER_SHORTHAND_SUFFIX: '});';

// @public
export const MODULE_WRAPPER_SUFFIX: ');';

Expand Down
21 changes: 20 additions & 1 deletion webpack/webpack5-module-minifier-plugin/src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ export const MODULE_WRAPPER_PREFIX: '__MINIFY_MODULE__(' = '__MINIFY_MODULE__(';
*/
export const MODULE_WRAPPER_SUFFIX: ');' = ');';

/**
* Prefix to wrap ECMAScript method shorthand `(module, __webpack_exports__, __webpack_require__) { ... }` so that the minifier doesn't delete it.
* Used when webpack emits modules using shorthand syntax.
* Combined with the suffix, creates: `__MINIFY_MODULE__({__DEFAULT_ID__(params){body}});`
* Public because alternate Minifier implementations may wish to know about it.
* @public
*/
export const MODULE_WRAPPER_SHORTHAND_PREFIX: '__MINIFY_MODULE__({__DEFAULT_ID__' =
'__MINIFY_MODULE__({__DEFAULT_ID__';
/**
* Suffix to wrap ECMAScript method shorthand `(module, __webpack_exports__, __webpack_require__) { ... }` so that the minifier doesn't delete it.
* Used when webpack emits modules using shorthand syntax.
* Combined with the prefix, creates: `__MINIFY_MODULE__({__DEFAULT_ID__(params){body}});`
* Public because alternate Minifier implementations may wish to know about it.
* @public
*/
export const MODULE_WRAPPER_SHORTHAND_SUFFIX: '});' = '});';
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated

/**
* Token preceding a module id in the emitted asset so the minifier can operate on the Webpack runtime or chunk boilerplate in isolation
* @public
Expand All @@ -22,9 +40,10 @@ export const CHUNK_MODULE_TOKEN: '__WEBPACK_CHUNK_MODULE__' = '__WEBPACK_CHUNK_M

/**
* RegExp for replacing chunk module placeholders
* Matches the void expression format
* @public
*/
export const CHUNK_MODULE_REGEX: RegExp = /__WEBPACK_CHUNK_MODULE__([A-Za-z0-9$_]+)/g;
export const CHUNK_MODULE_REGEX: RegExp = /\(\)\{void "__WEBPACK_CHUNK_MODULE__([A-Za-z0-9$_]+)"\}/g;
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated

/**
* Stage # to use when this should be the first tap in the hook
Expand Down
224 changes: 161 additions & 63 deletions webpack/webpack5-module-minifier-plugin/src/ModuleMinifierPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
CHUNK_MODULE_TOKEN,
MODULE_WRAPPER_PREFIX,
MODULE_WRAPPER_SUFFIX,
MODULE_WRAPPER_SHORTHAND_PREFIX,
MODULE_WRAPPER_SHORTHAND_SUFFIX,
STAGE_BEFORE,
STAGE_AFTER
} from './Constants';
Expand Down Expand Up @@ -74,6 +76,7 @@ interface IOptionsForHash extends Omit<IModuleMinifierPluginOptions, 'minifier'>
interface ISourceCacheEntry {
source: sources.Source;
hash: string;
isShorthand: boolean;
}

const compilationMetadataMap: WeakMap<Compilation, IModuleMinifierPluginStats> = new WeakMap();
Expand Down Expand Up @@ -125,6 +128,41 @@ function isLicenseComment(comment: Comment): boolean {
return LICENSE_COMMENT_REGEX.test(comment.value);
}

/**
* Detects if the module code uses ECMAScript method shorthand format.
* Shorthand format would appear when webpack emits object methods without function keyword
* For example: `id(params) { body }` instead of `id: function(params) { body }`
*
* Following the problem statement's recommendation: inspect the rendered code prior to the first `{`
* and look for either a `=>` or `function(`. If neither are encountered, assume object shorthand format.
*
* @param code - The module source code to check
* @returns true if the code is in method shorthand format
*/
function isMethodShorthandFormat(code: string): boolean {
// Find the position of the first opening brace
const firstBraceIndex: number = code.indexOf('{');
if (firstBraceIndex === -1) {
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated
// No brace found, not a function format
return false;
}

// Get the code before the first brace
const beforeBrace: string = code.slice(0, firstBraceIndex);

// Check if it contains '=>' or 'function('
// If it does, it's a regular arrow function or function expression, not shorthand
// Use a simple check that handles common whitespace variations
if (beforeBrace.includes('=>') || /function\s*\(/.test(beforeBrace)) {
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated
return false;
}

// If neither '=>' nor 'function(' are found, assume object method shorthand format
// ECMAScript method shorthand is used in object literals: { methodName(params){body} }
// Webpack emits this as just (params){body} which only works in the object literal context
return true;
}

/**
* Webpack plugin that minifies code on a per-module basis rather than per-asset. The actual minification is handled by the input `minifier` object.
* @public
Expand Down Expand Up @@ -220,6 +258,11 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
*/
const submittedModules: Set<string | number> = new Set();

/**
* Set of module hashes that use ECMAScript method shorthand format.
*/
const moduleShorthandFormat: Set<string> = new Set();

/**
* The text and comments of all minified modules.
*/
Expand Down Expand Up @@ -333,12 +376,16 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
return cachedResult.source;
}

// Get the source code to check its format
const sourceCode: string = source.source().toString();

// Detect if this is ECMAScript method shorthand format
const isShorthand: boolean = isMethodShorthandFormat(sourceCode);

// If this module is wrapped in a factory, need to add boilerplate so that the minifier keeps the function
const wrapped: sources.Source = new ConcatSource(
MODULE_WRAPPER_PREFIX + '\n',
source,
'\n' + MODULE_WRAPPER_SUFFIX
);
const wrapped: sources.Source = isShorthand
? new ConcatSource(MODULE_WRAPPER_SHORTHAND_PREFIX, source, MODULE_WRAPPER_SHORTHAND_SUFFIX)
: new ConcatSource(MODULE_WRAPPER_PREFIX + '\n', source, '\n' + MODULE_WRAPPER_SUFFIX);

const nameForMap: string = `(modules)/${id}`;

Expand All @@ -355,6 +402,11 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
if (!submittedModules.has(hash)) {
submittedModules.add(hash);

// Track whether this module uses shorthand format
if (isShorthand) {
moduleShorthandFormat.add(hash);
}

++pendingMinificationRequests;

minifier.minify(
Expand Down Expand Up @@ -386,8 +438,18 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
const len: number = minified.length;

// Trim off the boilerplate used to preserve the factory
unwrapped.replace(0, MODULE_WRAPPER_PREFIX.length - 1, '');
unwrapped.replace(len - MODULE_WRAPPER_SUFFIX.length, len - 1, '');
// Use different prefix/suffix lengths for shorthand vs regular format
const isShorthandModule: boolean = moduleShorthandFormat.has(hash);
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated
if (isShorthandModule) {
// For shorthand format: __MINIFY_MODULE__({__DEFAULT_ID__(args){...}});
// Remove prefix and suffix by their lengths
unwrapped.replace(0, MODULE_WRAPPER_SHORTHAND_PREFIX.length - 1, '');
unwrapped.replace(len - MODULE_WRAPPER_SHORTHAND_SUFFIX.length, len - 1, '');
} else {
// Regular format: __MINIFY_MODULE__((args){...});
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated
unwrapped.replace(0, MODULE_WRAPPER_PREFIX.length - 1, '');
unwrapped.replace(len - MODULE_WRAPPER_SUFFIX.length, len - 1, '');
}

const withIds: sources.Source = postProcessCode(unwrapped, {
compilation,
Expand All @@ -414,10 +476,14 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
);
}

const result: sources.Source = new RawSource(`${CHUNK_MODULE_TOKEN}${hash}`);
// Create a minimal valid token using void operator with string literal
// The void operator prevents minifiers from optimizing away the expression
// while keeping the token string intact for regex matching during rehydration
const result: sources.Source = new RawSource(`(){void "${CHUNK_MODULE_TOKEN}${hash}"}`);
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated
sourceCache.set(source, {
hash,
source: result
source: result,
isShorthand
});

// Return an expression to replace later
Expand Down Expand Up @@ -452,68 +518,100 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {

// Verify that this is a JS asset
if (isJSAsset.test(assetName)) {
++pendingMinificationRequests;

const { source: wrappedCodeRaw, map } = useSourceMaps
? asset.sourceAndMap()
: {
source: asset.source(),
map: undefined
};

const rawCode: string = wrappedCodeRaw.toString();
const nameForMap: string = `(chunks)/${assetName}`;

const hash: string = hashCodeFragment(rawCode);

minifier.minify(
{
hash,
code: rawCode,
nameForMap: useSourceMaps ? nameForMap : undefined,
externals: undefined
},
(result: IModuleMinificationResult) => {
if (isMinificationResultError(result)) {
compilation.errors.push(result.error as WebpackError);
// eslint-disable-next-line no-console
console.error(result.error);
} else {
try {
const { code: minified, map: minifierMap } = result;

const rawOutput: sources.Source = useSourceMaps
? new SourceMapSource(
minified, // Code
nameForMap, // File
minifierMap ?? undefined, // Base source map
rawCode, // Source from before transform
map ?? undefined, // Source Map from before transform
true // Remove original source
)
: new RawSource(minified);

const withIds: sources.Source = postProcessCode(new ReplaceSource(rawOutput), {
compilation,
module: undefined,
loggingName: assetName
});

// Check if asset contains module tokens (which would make it invalid for minification)
Comment thread
dmichon-msft marked this conversation as resolved.
Outdated
const assetSource: string = asset.source().toString();
const hasTokens: boolean = assetSource.includes(CHUNK_MODULE_TOKEN);

if (hasTokens) {
// Asset contains tokens - don't try to minify it, just store for rehydration
minifiedAssets.set(assetName, {
source: postProcessCode(new ReplaceSource(asset), {
compilation,
module: undefined,
loggingName: assetName
}),
chunk,
fileName: assetName,
renderInfo: new Map(),
type: 'javascript'
});
} else {
// Asset doesn't have tokens - safe to minify
++pendingMinificationRequests;

const { source: wrappedCodeRaw, map } = useSourceMaps
? asset.sourceAndMap()
: {
source: asset.source(),
map: undefined
};

const rawCode: string = wrappedCodeRaw.toString();
const nameForMap: string = `(chunks)/${assetName}`;

const hash: string = hashCodeFragment(rawCode);

minifier.minify(
{
hash,
code: rawCode,
nameForMap: useSourceMaps ? nameForMap : undefined,
externals: undefined
},
(result: IModuleMinificationResult) => {
if (isMinificationResultError(result)) {
compilation.errors.push(result.error as WebpackError);
// eslint-disable-next-line no-console
console.error(result.error);
// Store unminified asset as fallback
minifiedAssets.set(assetName, {
source: new CachedSource(withIds),
source: postProcessCode(new ReplaceSource(asset), {
compilation,
module: undefined,
loggingName: assetName
}),
chunk,
fileName: assetName,
renderInfo: new Map(),
type: 'javascript'
});
} catch (err) {
compilation.errors.push(err);
} else {
try {
const { code: minified, map: minifierMap } = result;

const rawOutput: sources.Source = useSourceMaps
? new SourceMapSource(
minified, // Code
nameForMap, // File
minifierMap ?? undefined, // Base source map
rawCode, // Source from before transform
map ?? undefined, // Source Map from before transform
true // Remove original source
)
: new RawSource(minified);

const withIds: sources.Source = postProcessCode(new ReplaceSource(rawOutput), {
compilation,
module: undefined,
loggingName: assetName
});

minifiedAssets.set(assetName, {
source: new CachedSource(withIds),
chunk,
fileName: assetName,
renderInfo: new Map(),
type: 'javascript'
});
} catch (err) {
compilation.errors.push(err);
}
}
}

onFileMinified();
}
);
onFileMinified();
}
);
}
} else {
// This isn't a JS asset. Don't try to minify the asset wrapper, though if it contains modules, those might still get replaced with minified versions.
minifiedAssets.set(assetName, {
Expand Down
2 changes: 2 additions & 0 deletions webpack/webpack5-module-minifier-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
export {
MODULE_WRAPPER_PREFIX,
MODULE_WRAPPER_SUFFIX,
MODULE_WRAPPER_SHORTHAND_PREFIX,
MODULE_WRAPPER_SHORTHAND_SUFFIX,
CHUNK_MODULE_TOKEN,
CHUNK_MODULE_REGEX,
STAGE_BEFORE,
Expand Down
Loading