Skip to content

Commit 3e13d69

Browse files
Add ECMAScript method shorthand support to webpack5-module-minifier-plugin (#5620)
* Initial plan * Add infrastructure for ECMAScript method shorthand support - Added MODULE_WRAPPER_SHORTHAND_PREFIX and MODULE_WRAPPER_SHORTHAND_SUFFIX constants - Added isMethodShorthandFormat() detection function (currently returns false for backward compatibility) - Updated wrapping/unwrapping logic to handle both regular and shorthand formats - Updated MockMinifier to handle shorthand format - Exported new constants from index.ts - All tests passing Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Implement ECMAScript method shorthand detection and handling - Implemented isMethodShorthandFormat() to detect shorthand by checking for absence of '=>' or 'function(' before first '{' - Updated unwrapping logic to correctly extract shorthand format by finding __DEFAULT_ID__ marker and removing wrapper - Fixed MockMinifier to properly handle shorthand format without breaking the method syntax - All tests passing with updated snapshots Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Address code review feedback - Use regex pattern /function\s*\(/ for more robust function keyword detection with whitespace variations - Replace magic number 3 with explicit minifiedSuffix variable for better maintainability - Add documentation showing complete wrapper structure in constants comments - All tests still passing, no security issues found Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Fix whitespace and simplify unwrapping logic per review feedback - Remove whitespace from MODULE_WRAPPER_SHORTHAND_PREFIX/SUFFIX (minifier strips it) - Change moduleShorthandFormat from Map to Set (only true values matter) - Simplify unwrapping logic to just use prefix/suffix lengths instead of complex searching - All tests passing Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Fix syntax errors in chunks by using property access token - Changed module token from `__WEBPACK_CHUNK_MODULE__hash` to `(){this["__WEBPACK_CHUNK_MODULE__hash"]}` - This creates syntactically valid JavaScript that terser can parse and minify - After minification, terser converts bracket notation to dot notation: `this.__WEBPACK_CHUNK_MODULE__hash` - Updated CHUNK_MODULE_REGEX to match the minified format - All tests passing with no syntax errors in chunks - Terser can now successfully minify assets containing module placeholders Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Revert to simpler token and fix detection logic - Changed shorthand detection to return false (webpack doesn't emit true shorthand yet) - Using regular wrapper format for all modules - Token format: `(){void "__WEBPACK_CHUNK_MODULE__hash"}` - Regex matches complete token structure - Tests passing but terser still has syntax errors (need to investigate webpack output formats) Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Fix syntax errors by re-enabling shorthand detection and skipping token asset minification - Re-enabled isMethodShorthandFormat() detection (webpack emits modules without function keyword) - Modules wrapped as __MINIFY_MODULE__({__DEFAULT_ID__(__params__){body}}); for shorthand format - Assets with tokens skip minification to avoid syntax errors - Added WebpackOutputFormats tests to verify code sent to minifier - All tests passing with ZERO syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Address code review feedback - improve comments and remove redundant eslint-disable - Clarified shorthand detection comment to reference ECMAScript method shorthand in object literals - Added comment explaining why void operator is used for token format - Removed redundant eslint-disable comments (underscore prefix already signals intentionally unused) - All tests passing, no warnings, zero syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Address review feedback: improve tests and MockMinifier comments - MockMinifier: Added comment markers for shorthand format matching regular format style - WebpackOutputFormats: Deduplicated test code into shared runWebpackWithEnvironment function - Added methodShorthand=true test case to cover all three syntaxes - Test expectations now based on environment settings (methodShorthand, arrowFunction) - All 7 tests passing with zero syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Construct shorthand constants by concatenating to base constants - MODULE_WRAPPER_SHORTHAND_PREFIX now built as MODULE_WRAPPER_PREFIX + '{__DEFAULT_ID__' - MODULE_WRAPPER_SHORTHAND_SUFFIX now built as '}' + MODULE_WRAPPER_SUFFIX - Makes the relationship between base and shorthand constants explicit - API signature updated (types changed from literals to string) - All 7 tests still passing with zero syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Fix regex to handle all three token representations and hoist function regex - Updated CHUNK_MODULE_REGEX to capture optional leading ':' as first group - Regex now handles: "id":token (object, no shorthand), token (array), "id"token (object, shorthand) - Rehydration logic preserves ':' for non-shorthand, discards for shorthand - Added isShorthand field to IModuleInfo interface - Hoisted FUNCTION_KEYWORD_REGEX outside isMethodShorthandFormat function - All 7 tests passing with zero syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Fix module wrapping and token format per review feedback - Use isShorthand from closure instead of moduleShorthandFormat Set (removed Set entirely) - Token format: plain token for non-shorthand, ':' prefix for shorthand (':__WEBPACK_CHUNK_MODULE__hash') - Simplified CHUNK_MODULE_REGEX to match plain token with optional ':' prefix - Reverted asset token skip logic (was categorically wrong) - Updated comment: regular format is function(...) or (...)=>, not (...){} - All 7 tests passing with zero syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> * Final cleanup: strictNullChecks compliance, template types, and indexOf pattern - MockMinifier: Use local function with multiple returns for strictNullChecks compliance - Constants: Use template string types for MODULE_WRAPPER_SHORTHAND_PREFIX/SUFFIX to show exact types in API docs - ModuleMinifierPlugin: Change indexOf check from === -1 to < 0 (more concise, language-friendly) - All 7 tests passing with zero syntax errors Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dmichon-msft <26827560+dmichon-msft@users.noreply.github.com>
1 parent 2234ea4 commit 3e13d69

12 files changed

Lines changed: 1278 additions & 85 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/webpack5-module-minifier-plugin",
5+
"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.",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@rushstack/webpack5-module-minifier-plugin"
10+
}

common/reviews/api/webpack5-module-minifier-plugin.api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface IFactoryMeta {
5959
// @public
6060
export interface IModuleInfo {
6161
id: string | number;
62+
isShorthand?: boolean;
6263
module: Module;
6364
source: sources.Source;
6465
}
@@ -110,6 +111,12 @@ export interface IRenderedModulePosition {
110111
// @public
111112
export const MODULE_WRAPPER_PREFIX: '__MINIFY_MODULE__(';
112113

114+
// @public
115+
export const MODULE_WRAPPER_SHORTHAND_PREFIX: `${typeof MODULE_WRAPPER_PREFIX}{__DEFAULT_ID__`;
116+
117+
// @public
118+
export const MODULE_WRAPPER_SHORTHAND_SUFFIX: `}${typeof MODULE_WRAPPER_SUFFIX}`;
119+
113120
// @public
114121
export const MODULE_WRAPPER_SUFFIX: ');';
115122

webpack/webpack5-module-minifier-plugin/src/Constants.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@ export const MODULE_WRAPPER_PREFIX: '__MINIFY_MODULE__(' = '__MINIFY_MODULE__(';
1414
*/
1515
export const MODULE_WRAPPER_SUFFIX: ');' = ');';
1616

17+
/**
18+
* Prefix to wrap ECMAScript method shorthand `(module, __webpack_exports__, __webpack_require__) { ... }` so that the minifier doesn't delete it.
19+
* Used when webpack emits modules using shorthand syntax.
20+
* Combined with the suffix, creates: `__MINIFY_MODULE__({__DEFAULT_ID__(params){body}});`
21+
* Public because alternate Minifier implementations may wish to know about it.
22+
* @public
23+
*/
24+
export const MODULE_WRAPPER_SHORTHAND_PREFIX: `${typeof MODULE_WRAPPER_PREFIX}{__DEFAULT_ID__` = `${MODULE_WRAPPER_PREFIX}{__DEFAULT_ID__`;
25+
/**
26+
* Suffix to wrap ECMAScript method shorthand `(module, __webpack_exports__, __webpack_require__) { ... }` so that the minifier doesn't delete it.
27+
* Used when webpack emits modules using shorthand syntax.
28+
* Combined with the prefix, creates: `__MINIFY_MODULE__({__DEFAULT_ID__(params){body}});`
29+
* Public because alternate Minifier implementations may wish to know about it.
30+
* @public
31+
*/
32+
export const MODULE_WRAPPER_SHORTHAND_SUFFIX: `}${typeof MODULE_WRAPPER_SUFFIX}` = `}${MODULE_WRAPPER_SUFFIX}`;
33+
1734
/**
1835
* Token preceding a module id in the emitted asset so the minifier can operate on the Webpack runtime or chunk boilerplate in isolation
1936
* @public
@@ -22,9 +39,14 @@ export const CHUNK_MODULE_TOKEN: '__WEBPACK_CHUNK_MODULE__' = '__WEBPACK_CHUNK_M
2239

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

2951
/**
3052
* Stage # to use when this should be the first tap in the hook

webpack/webpack5-module-minifier-plugin/src/ModuleMinifierPlugin.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
CHUNK_MODULE_TOKEN,
3030
MODULE_WRAPPER_PREFIX,
3131
MODULE_WRAPPER_SUFFIX,
32+
MODULE_WRAPPER_SHORTHAND_PREFIX,
33+
MODULE_WRAPPER_SHORTHAND_SUFFIX,
3234
STAGE_BEFORE,
3335
STAGE_AFTER
3436
} from './Constants';
@@ -74,6 +76,7 @@ interface IOptionsForHash extends Omit<IModuleMinifierPluginOptions, 'minifier'>
7476
interface ISourceCacheEntry {
7577
source: sources.Source;
7678
hash: string;
79+
isShorthand: boolean;
7780
}
7881

7982
const compilationMetadataMap: WeakMap<Compilation, IModuleMinifierPluginStats> = new WeakMap();
@@ -125,6 +128,46 @@ function isLicenseComment(comment: Comment): boolean {
125128
return LICENSE_COMMENT_REGEX.test(comment.value);
126129
}
127130

131+
/**
132+
* RegExp for detecting function keyword with optional whitespace
133+
*/
134+
const FUNCTION_KEYWORD_REGEX: RegExp = /function\s*\(/;
135+
136+
/**
137+
* Detects if the module code uses ECMAScript method shorthand format.
138+
* Shorthand format would appear when webpack emits object methods without function keyword
139+
* For example: `id(params) { body }` instead of `id: function(params) { body }`
140+
*
141+
* Following the problem statement's recommendation: inspect the rendered code prior to the first `{`
142+
* and look for either a `=>` or `function(`. If neither are encountered, assume object shorthand format.
143+
*
144+
* @param code - The module source code to check
145+
* @returns true if the code is in method shorthand format
146+
*/
147+
function isMethodShorthandFormat(code: string): boolean {
148+
// Find the position of the first opening brace
149+
const firstBraceIndex: number = code.indexOf('{');
150+
if (firstBraceIndex < 0) {
151+
// No brace found, not a function format
152+
return false;
153+
}
154+
155+
// Get the code before the first brace
156+
const beforeBrace: string = code.slice(0, firstBraceIndex);
157+
158+
// Check if it contains '=>' or 'function('
159+
// If it does, it's a regular arrow function or function expression, not shorthand
160+
// Use a simple check that handles common whitespace variations
161+
if (beforeBrace.includes('=>') || FUNCTION_KEYWORD_REGEX.test(beforeBrace)) {
162+
return false;
163+
}
164+
165+
// If neither '=>' nor 'function(' are found, assume object method shorthand format
166+
// ECMAScript method shorthand is used in object literals: { methodName(params){body} }
167+
// Webpack emits this as just (params){body} which only works in the object literal context
168+
return true;
169+
}
170+
128171
/**
129172
* Webpack plugin that minifies code on a per-module basis rather than per-asset. The actual minification is handled by the input `minifier` object.
130173
* @public
@@ -333,12 +376,16 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
333376
return cachedResult.source;
334377
}
335378

379+
// Get the source code to check its format
380+
const sourceCode: string = source.source().toString();
381+
382+
// Detect if this is ECMAScript method shorthand format
383+
const isShorthand: boolean = isMethodShorthandFormat(sourceCode);
384+
336385
// If this module is wrapped in a factory, need to add boilerplate so that the minifier keeps the function
337-
const wrapped: sources.Source = new ConcatSource(
338-
MODULE_WRAPPER_PREFIX + '\n',
339-
source,
340-
'\n' + MODULE_WRAPPER_SUFFIX
341-
);
386+
const wrapped: sources.Source = isShorthand
387+
? new ConcatSource(MODULE_WRAPPER_SHORTHAND_PREFIX, source, MODULE_WRAPPER_SHORTHAND_SUFFIX)
388+
: new ConcatSource(MODULE_WRAPPER_PREFIX + '\n', source, '\n' + MODULE_WRAPPER_SUFFIX);
342389

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

@@ -386,8 +433,18 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
386433
const len: number = minified.length;
387434

388435
// Trim off the boilerplate used to preserve the factory
389-
unwrapped.replace(0, MODULE_WRAPPER_PREFIX.length - 1, '');
390-
unwrapped.replace(len - MODULE_WRAPPER_SUFFIX.length, len - 1, '');
436+
// Use different prefix/suffix lengths for shorthand vs regular format
437+
// Capture isShorthand from closure instead of looking it up
438+
if (isShorthand) {
439+
// For shorthand format: __MINIFY_MODULE__({__DEFAULT_ID__(args){...}});
440+
// Remove prefix and suffix by their lengths
441+
unwrapped.replace(0, MODULE_WRAPPER_SHORTHAND_PREFIX.length - 1, '');
442+
unwrapped.replace(len - MODULE_WRAPPER_SHORTHAND_SUFFIX.length, len - 1, '');
443+
} else {
444+
// Regular format: __MINIFY_MODULE__(function(args){...}); or __MINIFY_MODULE__((args)=>{...});
445+
unwrapped.replace(0, MODULE_WRAPPER_PREFIX.length - 1, '');
446+
unwrapped.replace(len - MODULE_WRAPPER_SUFFIX.length, len - 1, '');
447+
}
391448

392449
const withIds: sources.Source = postProcessCode(unwrapped, {
393450
compilation,
@@ -402,7 +459,8 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
402459
minifiedModules.set(hash, {
403460
source: cached,
404461
module: mod,
405-
id
462+
id,
463+
isShorthand
406464
});
407465
} catch (err) {
408466
compilation.errors.push(err);
@@ -414,10 +472,15 @@ export class ModuleMinifierPlugin implements WebpackPluginInstance {
414472
);
415473
}
416474

417-
const result: sources.Source = new RawSource(`${CHUNK_MODULE_TOKEN}${hash}`);
475+
// Create token with optional ':' prefix for shorthand modules
476+
// For non-shorthand: __WEBPACK_CHUNK_MODULE__hash (becomes "id":__WEBPACK_CHUNK_MODULE__hash in object)
477+
// For shorthand: :__WEBPACK_CHUNK_MODULE__hash (becomes "id"__WEBPACK_CHUNK_MODULE__hash, ':' makes it valid property assignment)
478+
const tokenPrefix: string = isShorthand ? ':' : '';
479+
const result: sources.Source = new RawSource(`${tokenPrefix}${CHUNK_MODULE_TOKEN}${hash}`);
418480
sourceCache.set(source, {
419481
hash,
420-
source: result
482+
source: result,
483+
isShorthand
421484
});
422485

423486
// Return an expression to replace later

webpack/webpack5-module-minifier-plugin/src/ModuleMinifierPlugin.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ export interface IModuleInfo {
7474
* The id of the module, from the chunk graph.
7575
*/
7676
id: string | number;
77+
78+
/**
79+
* Whether this module was in method shorthand format
80+
*/
81+
isShorthand?: boolean;
7782
}
7883

7984
/**

webpack/webpack5-module-minifier-plugin/src/RehydrateAsset.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export function rehydrateAsset(
4646
// RegExp.exec uses null or an array as the return type, explicitly
4747
let match: RegExpExecArray | null = null;
4848
while ((match = CHUNK_MODULE_REGEX.exec(assetCode))) {
49-
const hash: string = match[1];
49+
const leadingColon: string = match[1]; // Captured ':' or empty string
50+
const hash: string = match[2]; // The module hash
5051

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

6869
if (moduleSource) {
70+
// Check if this module was in shorthand format
71+
const isShorthand: boolean = moduleSource.isShorthand === true;
72+
73+
// For shorthand format, omit the colon. For regular format, keep it.
74+
if (!isShorthand && leadingColon) {
75+
source.add(leadingColon);
76+
charOffset += leadingColon.length;
77+
}
78+
6979
const charLength: number = moduleSource.source.source().length;
7080

7181
if (emitRenderInfo) {
@@ -78,6 +88,12 @@ export function rehydrateAsset(
7888
source.add(moduleSource.source);
7989
charOffset += charLength;
8090
} else {
91+
// Keep the colon if present for error module
92+
if (leadingColon) {
93+
source.add(leadingColon);
94+
charOffset += leadingColon.length;
95+
}
96+
8197
const errorModule: string = `()=>{throw new Error(\`Missing module with hash "${hash}"\`)}`;
8298

8399
source.add(errorModule);

webpack/webpack5-module-minifier-plugin/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
export {
55
MODULE_WRAPPER_PREFIX,
66
MODULE_WRAPPER_SUFFIX,
7+
MODULE_WRAPPER_SHORTHAND_PREFIX,
8+
MODULE_WRAPPER_SHORTHAND_SUFFIX,
79
CHUNK_MODULE_TOKEN,
810
CHUNK_MODULE_REGEX,
911
STAGE_BEFORE,

webpack/webpack5-module-minifier-plugin/src/test/MockMinifier.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import type {
88
IMinifierConnection
99
} from '@rushstack/module-minifier';
1010

11-
import { MODULE_WRAPPER_PREFIX, MODULE_WRAPPER_SUFFIX } from '../Constants';
11+
import {
12+
MODULE_WRAPPER_PREFIX,
13+
MODULE_WRAPPER_SUFFIX,
14+
MODULE_WRAPPER_SHORTHAND_PREFIX,
15+
MODULE_WRAPPER_SHORTHAND_SUFFIX
16+
} from '../Constants';
1217

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

2631
const isModule: boolean = code.startsWith(MODULE_WRAPPER_PREFIX);
27-
const processedCode: string = isModule
28-
? `${MODULE_WRAPPER_PREFIX}\n// Begin Module Hash=${hash}\n${code.slice(
32+
const isShorthandModule: boolean = code.startsWith(MODULE_WRAPPER_SHORTHAND_PREFIX);
33+
34+
// Use local function to ensure processedCode is always initialized (strictNullChecks compliant)
35+
const getProcessedCode = (): string => {
36+
if (isShorthandModule) {
37+
// Handle shorthand format
38+
// Add comment markers similar to regular format
39+
const innerCode: string = code.slice(
40+
MODULE_WRAPPER_SHORTHAND_PREFIX.length,
41+
-MODULE_WRAPPER_SHORTHAND_SUFFIX.length
42+
);
43+
return `${MODULE_WRAPPER_SHORTHAND_PREFIX}\n// Begin Module Hash=${hash}\n${innerCode}\n// End Module\n${MODULE_WRAPPER_SHORTHAND_SUFFIX}`;
44+
} else if (isModule) {
45+
// Handle regular format
46+
return `${MODULE_WRAPPER_PREFIX}\n// Begin Module Hash=${hash}\n${code.slice(
2947
MODULE_WRAPPER_PREFIX.length,
3048
-MODULE_WRAPPER_SUFFIX.length
31-
)}\n// End Module${MODULE_WRAPPER_SUFFIX}`
32-
: `// Begin Asset Hash=${hash}\n${code}\n// End Asset`;
49+
)}\n// End Module${MODULE_WRAPPER_SUFFIX}`;
50+
} else {
51+
// Handle asset format
52+
return `// Begin Asset Hash=${hash}\n${code}\n// End Asset`;
53+
}
54+
};
55+
56+
const processedCode: string = getProcessedCode();
3357

3458
callback({
3559
hash,

0 commit comments

Comments
 (0)