Skip to content
Merged
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
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"
}
7 changes: 7 additions & 0 deletions common/reviews/api/webpack5-module-minifier-plugin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface IFactoryMeta {
// @public
export interface IModuleInfo {
id: string | number;
isShorthand?: boolean;
module: Module;
source: sources.Source;
}
Expand Down Expand Up @@ -110,6 +111,12 @@ export interface IRenderedModulePosition {
// @public
export const MODULE_WRAPPER_PREFIX: '__MINIFY_MODULE__(';

// @public
export const MODULE_WRAPPER_SHORTHAND_PREFIX: `${typeof MODULE_WRAPPER_PREFIX}{__DEFAULT_ID__`;

// @public
export const MODULE_WRAPPER_SHORTHAND_SUFFIX: `}${typeof MODULE_WRAPPER_SUFFIX}`;

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

Expand Down
24 changes: 23 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,23 @@ 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: `${typeof MODULE_WRAPPER_PREFIX}{__DEFAULT_ID__` = `${MODULE_WRAPPER_PREFIX}{__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: `}${typeof MODULE_WRAPPER_SUFFIX}` = `}${MODULE_WRAPPER_SUFFIX}`;

/**
* 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 +39,14 @@ export const CHUNK_MODULE_TOKEN: '__WEBPACK_CHUNK_MODULE__' = '__WEBPACK_CHUNK_M

/**
* RegExp for replacing chunk module placeholders
* Handles three possible representations:
* - `"id":__WEBPACK_CHUNK_MODULE__HASH__` (methodShorthand: false, object)
* - `__WEBPACK_CHUNK_MODULE__HASH__` (array syntax)
* - `"id":__WEBPACK_CHUNK_MODULE__HASH__` with leading ':' (methodShorthand: true, object)
* Captures optional leading `:` to handle shorthand format properly
* @public
*/
export const CHUNK_MODULE_REGEX: RegExp = /__WEBPACK_CHUNK_MODULE__([A-Za-z0-9$_]+)/g;
export const CHUNK_MODULE_REGEX: RegExp = /(:?)__WEBPACK_CHUNK_MODULE__([A-Za-z0-9$_]+)/g;

/**
* Stage # to use when this should be the first tap in the hook
Expand Down
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,46 @@ function isLicenseComment(comment: Comment): boolean {
return LICENSE_COMMENT_REGEX.test(comment.value);
}

/**
* RegExp for detecting function keyword with optional whitespace
*/
const FUNCTION_KEYWORD_REGEX: RegExp = /function\s*\(/;

/**
* 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 < 0) {
// 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_KEYWORD_REGEX.test(beforeBrace)) {
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 @@ -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 Down Expand Up @@ -386,8 +433,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
// Capture isShorthand from closure instead of looking it up
if (isShorthand) {
// 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__(function(args){...}); or __MINIFY_MODULE__((args)=>{...});
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 @@ -402,7 +459,8 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
minifiedModules.set(hash, {
source: cached,
module: mod,
id
id,
isShorthand
});
} catch (err) {
compilation.errors.push(err);
Expand All @@ -414,10 +472,15 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
);
}

const result: sources.Source = new RawSource(`${CHUNK_MODULE_TOKEN}${hash}`);
// Create token with optional ':' prefix for shorthand modules
// For non-shorthand: __WEBPACK_CHUNK_MODULE__hash (becomes "id":__WEBPACK_CHUNK_MODULE__hash in object)
// For shorthand: :__WEBPACK_CHUNK_MODULE__hash (becomes "id"__WEBPACK_CHUNK_MODULE__hash, ':' makes it valid property assignment)
const tokenPrefix: string = isShorthand ? ':' : '';
const result: sources.Source = new RawSource(`${tokenPrefix}${CHUNK_MODULE_TOKEN}${hash}`);
sourceCache.set(source, {
hash,
source: result
source: result,
isShorthand
});

// Return an expression to replace later
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export interface IModuleInfo {
* The id of the module, from the chunk graph.
*/
id: string | number;

/**
* Whether this module was in method shorthand format
*/
isShorthand?: boolean;
}

/**
Expand Down
18 changes: 17 additions & 1 deletion webpack/webpack5-module-minifier-plugin/src/RehydrateAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export function rehydrateAsset(
// RegExp.exec uses null or an array as the return type, explicitly
let match: RegExpExecArray | null = null;
while ((match = CHUNK_MODULE_REGEX.exec(assetCode))) {
const hash: string = match[1];
const leadingColon: string = match[1]; // Captured ':' or empty string
const hash: string = match[2]; // The module hash

const moduleSource: IModuleInfo | undefined = moduleMap.get(hash);
if (moduleSource === undefined) {
Expand All @@ -66,6 +67,15 @@ export function rehydrateAsset(
lastStart = CHUNK_MODULE_REGEX.lastIndex;

if (moduleSource) {
// Check if this module was in shorthand format
const isShorthand: boolean = moduleSource.isShorthand === true;

// For shorthand format, omit the colon. For regular format, keep it.
if (!isShorthand && leadingColon) {
source.add(leadingColon);
charOffset += leadingColon.length;
}

const charLength: number = moduleSource.source.source().length;

if (emitRenderInfo) {
Expand All @@ -78,6 +88,12 @@ export function rehydrateAsset(
source.add(moduleSource.source);
charOffset += charLength;
} else {
// Keep the colon if present for error module
if (leadingColon) {
source.add(leadingColon);
charOffset += leadingColon.length;
}

const errorModule: string = `()=>{throw new Error(\`Missing module with hash "${hash}"\`)}`;

source.add(errorModule);
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import type {
IMinifierConnection
} from '@rushstack/module-minifier';

import { MODULE_WRAPPER_PREFIX, MODULE_WRAPPER_SUFFIX } from '../Constants';
import {
MODULE_WRAPPER_PREFIX,
MODULE_WRAPPER_SUFFIX,
MODULE_WRAPPER_SHORTHAND_PREFIX,
MODULE_WRAPPER_SHORTHAND_SUFFIX
} from '../Constants';

export class MockMinifier implements IModuleMinifier {
public readonly requests: Map<string, string> = new Map();
Expand All @@ -24,12 +29,31 @@ export class MockMinifier implements IModuleMinifier {
this.requests.set(hash, code);

const isModule: boolean = code.startsWith(MODULE_WRAPPER_PREFIX);
const processedCode: string = isModule
? `${MODULE_WRAPPER_PREFIX}\n// Begin Module Hash=${hash}\n${code.slice(
const isShorthandModule: boolean = code.startsWith(MODULE_WRAPPER_SHORTHAND_PREFIX);

// Use local function to ensure processedCode is always initialized (strictNullChecks compliant)
const getProcessedCode = (): string => {
if (isShorthandModule) {
// Handle shorthand format
// Add comment markers similar to regular format
const innerCode: string = code.slice(
MODULE_WRAPPER_SHORTHAND_PREFIX.length,
-MODULE_WRAPPER_SHORTHAND_SUFFIX.length
);
return `${MODULE_WRAPPER_SHORTHAND_PREFIX}\n// Begin Module Hash=${hash}\n${innerCode}\n// End Module\n${MODULE_WRAPPER_SHORTHAND_SUFFIX}`;
} else if (isModule) {
// Handle regular format
return `${MODULE_WRAPPER_PREFIX}\n// Begin Module Hash=${hash}\n${code.slice(
MODULE_WRAPPER_PREFIX.length,
-MODULE_WRAPPER_SUFFIX.length
)}\n// End Module${MODULE_WRAPPER_SUFFIX}`
: `// Begin Asset Hash=${hash}\n${code}\n// End Asset`;
)}\n// End Module${MODULE_WRAPPER_SUFFIX}`;
} else {
// Handle asset format
return `// Begin Asset Hash=${hash}\n${code}\n// End Asset`;
}
};

const processedCode: string = getProcessedCode();

callback({
hash,
Expand Down
Loading