Skip to content

Commit eefaf44

Browse files
feat: keep export mangling for modules whose namespace object escapes (#21234)
* feat: keep export mangling for modules whose namespace object escapes When a module's namespace object is used as a whole value (for example re-exported and returned, spread, or passed around) export mangling was disabled for the whole module, bloating every reference in all other consumers. Such modules now keep their exports mangleable: the escaping reference is rendered as a decoupled namespace object that exposes the original export names via getters onto the mangled bindings, so access by the original name keeps working. Driven by optimization.mangleExports, for both non-concatenated and concatenated builds. Left conservative (unchanged) for CommonJS interop, dynamic imports and deferred imports. To preserve ES module namespace semantics on the escaping module, its exports stay non-inlinable and member access keeps a qualified property access: a missing export reads as `ns.x` (not a bare `undefined`) and `delete ns.x` hits a real non-configurable binding, so it stays valid and throws where the spec requires. Closes #19153. * docs: prefer Closes/Fixes over Refs when a PR resolves the issue Clarify the PR-body guidance to use the auto-closing `Closes #…`/`Fixes #…` form when the PR actually fixes the bug or implements the requested feature, reserving `Refs #…` for issues a PR only relates to.
1 parent e8f9334 commit eefaf44

34 files changed

Lines changed: 695 additions & 55 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": minor
3+
---
4+
5+
Keep export mangling enabled for modules whose namespace object is used as a whole value, by materializing a decoupled namespace object that keeps the original export names.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ Make sure to read our AI policy (https://github.com/webpack/governance/blob/main
267267

268268
Required answer per section — **one sentence each is the target, two or three the absolute maximum**:
269269

270-
- **Summary** — motivation and what problem is solved; link the related issue (`Closes #…` / `Fixes #…`).
270+
- **Summary** — motivation and what problem is solved; link the related issue. When the PR actually fixes the bug or implements the feature the issue asks for, use the auto-closing form `Closes #…` / `Fixes #…` (not `Refs #…`); reserve `Refs #…` for issues the PR only relates to but does not resolve.
271271
- **What kind of change does this PR introduce?** — one of: fix, feat, refactor, perf, test, chore, ci, build, style, revert, docs.
272272
- **Did you add tests for your changes?** — yes/no + which test files.
273273
- **Does this PR introduce a breaking change?** — yes/no + migration path if yes.

lib/ConcatenationScope.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const {
1717
/** @typedef {import("./optimize/ConcatenatedModule").ExportName} Ids */
1818

1919
const MODULE_REFERENCE_REGEXP =
20-
/^__WEBPACK_MODULE_REFERENCE__(\d+)_([\da-f]+|ns)(_call)?(_directImport)?(_deferredImport)?(?:_asiSafe(\d))?__$/;
20+
/^__WEBPACK_MODULE_REFERENCE__(\d+)_([\da-f]+|ns)(_call)?(_directImport)?(_deferredImport)?(_mangleableNs)?(?:_asiSafe(\d))?__$/;
2121

2222
/**
2323
* Encodes how a concatenated module reference should be interpreted when it is
@@ -27,6 +27,7 @@ const MODULE_REFERENCE_REGEXP =
2727
* @property {boolean} call true, when this referenced export is called
2828
* @property {boolean} directImport true, when this referenced export is directly imported (not via property access)
2929
* @property {boolean} deferredImport true, when this referenced export is deferred
30+
* @property {boolean} mangleableNamespace true, when a whole-namespace reference may use a decoupled namespace object that keeps the original export names
3031
* @property {boolean | undefined} asiSafe if the position is ASI safe or unknown
3132
*/
3233

@@ -147,13 +148,15 @@ class ConcatenationScope {
147148
call = false,
148149
directImport = false,
149150
deferredImport = false,
151+
mangleableNamespace = false,
150152
asiSafe = false
151153
}
152154
) {
153155
const info = /** @type {ModuleInfo} */ (this._modulesMap.get(module));
154156
const callFlag = call ? "_call" : "";
155157
const directImportFlag = directImport ? "_directImport" : "";
156158
const deferredImportFlag = deferredImport ? "_deferredImport" : "";
159+
const mangleableNamespaceFlag = mangleableNamespace ? "_mangleableNs" : "";
157160
const asiSafeFlag = asiSafe
158161
? "_asiSafe1"
159162
: asiSafe === false
@@ -163,7 +166,7 @@ class ConcatenationScope {
163166
? Buffer.from(JSON.stringify(ids), "utf8").toString("hex")
164167
: "ns";
165168
// a "._" is appended to allow "delete ...", which would cause a SyntaxError in strict mode
166-
return `__WEBPACK_MODULE_REFERENCE__${info.index}_${exportData}${callFlag}${directImportFlag}${deferredImportFlag}${asiSafeFlag}__._`;
169+
return `__WEBPACK_MODULE_REFERENCE__${info.index}_${exportData}${callFlag}${directImportFlag}${deferredImportFlag}${mangleableNamespaceFlag}${asiSafeFlag}__._`;
167170
}
168171

169172
/**
@@ -186,7 +189,7 @@ class ConcatenationScope {
186189
const match = MODULE_REFERENCE_REGEXP.exec(name);
187190
if (!match) return null;
188191
const index = Number(match[1]);
189-
const asiSafe = match[6];
192+
const asiSafe = match[7];
190193
return {
191194
index,
192195
ids:
@@ -196,6 +199,7 @@ class ConcatenationScope {
196199
call: Boolean(match[3]),
197200
directImport: Boolean(match[4]),
198201
deferredImport: Boolean(match[5]),
202+
mangleableNamespace: Boolean(match[6]),
199203
asiSafe: asiSafe ? asiSafe === "1" : undefined
200204
};
201205
}

lib/Dependency.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,11 @@ class Dependency {
463463
Dependency.NO_EXPORTS_REFERENCED = [];
464464
/** @type {RawReferencedExports} */
465465
Dependency.EXPORTS_OBJECT_REFERENCED = [[]];
466+
// Like EXPORTS_OBJECT_REFERENCED, but the reference can be rendered as a
467+
// decoupled namespace object, so the module's exports stay mangleable.
468+
// Same shape as EXPORTS_OBJECT_REFERENCED; only distinguished by identity.
469+
/** @type {RawReferencedExports} */
470+
Dependency.EXPORTS_OBJECT_REFERENCED_MANGLEABLE = [[]];
466471

467472
// TODO remove in webpack 6
468473
Object.defineProperty(Dependency.prototype, "module", {

lib/FlagDependencyUsagePlugin.js

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ const { getEntryRuntime, mergeRuntimeOwned } = require("./util/runtime");
2121
/** @typedef {import("./Module")} Module */
2222
/** @typedef {import("./util/runtime").RuntimeSpec} RuntimeSpec */
2323

24-
const { NO_EXPORTS_REFERENCED, EXPORTS_OBJECT_REFERENCED } = Dependency;
24+
const {
25+
NO_EXPORTS_REFERENCED,
26+
EXPORTS_OBJECT_REFERENCED,
27+
EXPORTS_OBJECT_REFERENCED_MANGLEABLE
28+
} = Dependency;
2529

2630
const PLUGIN_NAME = "FlagDependencyUsagePlugin";
2731
const PLUGIN_LOGGER_NAME = `webpack.${PLUGIN_NAME}`;
@@ -30,10 +34,13 @@ class FlagDependencyUsagePlugin {
3034
/**
3135
* Creates an instance of FlagDependencyUsagePlugin.
3236
* @param {boolean} global do a global analysis instead of per runtime
37+
* @param {boolean=} mangleEscapingNamespaces keep exports mangleable when a module's namespace object escapes
3338
*/
34-
constructor(global) {
39+
constructor(global, mangleEscapingNamespaces = false) {
3540
/** @type {boolean} */
3641
this.global = global;
42+
/** @type {boolean} */
43+
this.mangleEscapingNamespaces = mangleEscapingNamespaces;
3744
}
3845

3946
/**
@@ -75,6 +82,42 @@ class FlagDependencyUsagePlugin {
7582
forceSideEffects
7683
) => {
7784
const exportsInfo = moduleGraph.getExportsInfo(module);
85+
if (usedExports === EXPORTS_OBJECT_REFERENCED_MANGLEABLE) {
86+
// The whole namespace object escapes via a reference that codegen
87+
// can materialize as a decoupled namespace object. When all exports
88+
// are statically known we keep them mangleable instead of marking
89+
// them used-in-unknown-way.
90+
if (
91+
this.mangleEscapingNamespaces &&
92+
module.buildMeta &&
93+
module.buildMeta.exportsType === "namespace" &&
94+
exportsInfo.otherExportsInfo.provided === false
95+
) {
96+
let changed = exportsInfo.setAllKnownExportsUsed(runtime);
97+
// The whole namespace object is observed as a value, so the
98+
// module must stay a real ES module namespace at runtime
99+
// (keep __esModule / the namespace object, i.e. `r()`).
100+
if (
101+
exportsInfo
102+
.getExportInfo("__esModule")
103+
.setUsed(UsageState.Used, runtime)
104+
) {
105+
changed = true;
106+
}
107+
// Exports must keep a real binding (not be inlined) so member
108+
// access on the namespace has ES namespace semantics, e.g.
109+
// `delete ns.x` hits a non-configurable property and throws.
110+
for (const exportInfo of exportsInfo.ownedExports) {
111+
exportInfo.canInlineUse = false;
112+
}
113+
if (changed) {
114+
queue.enqueue(module, runtime);
115+
}
116+
} else if (exportsInfo.setUsedInUnknownWay(runtime)) {
117+
queue.enqueue(module, runtime);
118+
}
119+
return;
120+
}
78121
if (usedExports.length > 0) {
79122
if (!module.buildMeta || !module.buildMeta.exportsType) {
80123
if (exportsInfo.setUsedWithoutInfo(runtime)) {
@@ -183,6 +226,11 @@ class FlagDependencyUsagePlugin {
183226
/** @typedef {Map<string, string[] | ReferencedExport>} ExportMaps */
184227
/** @type {Map<Module, ReferencedExports | ExportMaps>} */
185228
const map = new Map();
229+
// Modules whose whole namespace object escapes in a mangleable way.
230+
// Tracked separately so specific member references are still merged
231+
// (and marked used) instead of being dropped by the escape marker.
232+
/** @type {Set<Module>} */
233+
const mangleableEscapeModules = new Set();
186234

187235
/** @type {ArrayQueue<DependenciesBlock>} */
188236
const queue = new ArrayQueue();
@@ -221,10 +269,27 @@ class FlagDependencyUsagePlugin {
221269
}
222270
const referencedExports =
223271
compilation.getDependencyReferencedExports(dep, runtime);
272+
// The non-mangleable whole-object reference is the most
273+
// conservative result and always wins.
274+
if (referencedExports === EXPORTS_OBJECT_REFERENCED) {
275+
map.set(module, EXPORTS_OBJECT_REFERENCED);
276+
mangleableEscapeModules.delete(module);
277+
continue;
278+
}
279+
// A mangleable whole-object escape keeps the module's exports
280+
// mangleable (applied after the merge). Unlike the conservative
281+
// marker it must not drop specific member references: those still
282+
// need their own (possibly non-existent) export marked used so
283+
// they render as a qualified access, not a bare `undefined`.
284+
if (
285+
referencedExports === EXPORTS_OBJECT_REFERENCED_MANGLEABLE
286+
) {
287+
mangleableEscapeModules.add(module);
288+
continue;
289+
}
224290
if (
225291
oldReferencedExports === undefined ||
226-
oldReferencedExports === NO_EXPORTS_REFERENCED ||
227-
referencedExports === EXPORTS_OBJECT_REFERENCED
292+
oldReferencedExports === NO_EXPORTS_REFERENCED
228293
) {
229294
map.set(module, referencedExports);
230295
} else if (
@@ -293,6 +358,14 @@ class FlagDependencyUsagePlugin {
293358
);
294359
}
295360
}
361+
for (const module of mangleableEscapeModules) {
362+
processReferencedModule(
363+
module,
364+
EXPORTS_OBJECT_REFERENCED_MANGLEABLE,
365+
runtime,
366+
forceSideEffects
367+
);
368+
}
296369
};
297370

298371
logger.time("initialize exports usage");

lib/RuntimeTemplate.js

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const {
2020
const { equals } = require("./util/ArrayHelpers");
2121
const compileBooleanMatcher = require("./util/compileBooleanMatcher");
2222
const memoize = require("./util/memoize");
23-
const { propertyAccess } = require("./util/property");
23+
const { propertyAccess, propertyName } = require("./util/property");
2424
const { forEachRuntime, subtractRuntime } = require("./util/runtime");
2525

2626
const getHarmonyImportDependency = memoize(() =>
@@ -1159,6 +1159,7 @@ class RuntimeTemplate {
11591159
* @param {RuntimeSpec} options.runtime runtime for which this code will be generated
11601160
* @param {RuntimeRequirements} options.runtimeRequirements if set, will be filled with runtime requirements
11611161
* @param {ModuleDependency} options.dependency module dependency
1162+
* @param {boolean=} options.mangleableNamespace true, when a whole-namespace value may use a decoupled namespace object that keeps the original export names
11621163
* @returns {string} expression
11631164
*/
11641165
exportFromImport({
@@ -1176,7 +1177,8 @@ class RuntimeTemplate {
11761177
initFragments,
11771178
runtime,
11781179
runtimeRequirements,
1179-
dependency
1180+
dependency,
1181+
mangleableNamespace = false
11801182
}) {
11811183
if (!module) {
11821184
return this.missingModule({
@@ -1342,12 +1344,101 @@ class RuntimeTemplate {
13421344
asiSafe ? "" : asiSafe === false ? ";" : "Object"
13431345
}(${importVar}_deferred_namespace_cache || (${importVar}_deferred_namespace_cache = ${init}))`;
13441346
}
1347+
// The whole namespace object is used as a value. If the module's exports
1348+
// were mangled, importVar's keys are the mangled names, so we materialize
1349+
// a decoupled namespace object that exposes the original names.
1350+
if (
1351+
exportsType === "namespace" &&
1352+
mangleableNamespace &&
1353+
this.compilation &&
1354+
this.compilation.options.optimization.mangleExports
1355+
) {
1356+
const materialized = this._materializedNamespaceObject({
1357+
moduleGraph,
1358+
module,
1359+
importVar,
1360+
initFragments,
1361+
runtime,
1362+
runtimeRequirements
1363+
});
1364+
if (materialized !== undefined) return materialized;
1365+
}
13451366
// if we hit here, the importVar is either
13461367
// - already a ES module namespace object
13471368
// - or imported by a way that does not need interop.
13481369
return importVar;
13491370
}
13501371

1372+
/**
1373+
* Materializes a namespace object that keeps the original export names while
1374+
* the module's own exports are mangled. Returns undefined when no export was
1375+
* mangled (then the raw namespace object can be used as-is).
1376+
* @template GenerateContext
1377+
* @param {object} options options
1378+
* @param {ModuleGraph} options.moduleGraph the module graph
1379+
* @param {Module} options.module the imported module
1380+
* @param {string} options.importVar the import variable referencing the module
1381+
* @param {InitFragment<GenerateContext>[]} options.initFragments target array for init fragments
1382+
* @param {RuntimeSpec} options.runtime the runtime
1383+
* @param {RuntimeRequirements} options.runtimeRequirements runtime requirements
1384+
* @returns {string | undefined} expression of the materialized namespace object, or undefined
1385+
*/
1386+
_materializedNamespaceObject({
1387+
moduleGraph,
1388+
module,
1389+
importVar,
1390+
initFragments,
1391+
runtime,
1392+
runtimeRequirements
1393+
}) {
1394+
const exportsInfo = moduleGraph.getExportsInfo(module);
1395+
/** @type {string[]} */
1396+
const definitions = [];
1397+
let mangled = false;
1398+
for (const exportInfo of exportsInfo.orderedExports) {
1399+
if (exportInfo.provided === false) continue;
1400+
const used = exportsInfo.getUsedName([exportInfo.name], runtime);
1401+
if (!used) continue;
1402+
if (used instanceof InlinedUsedName) {
1403+
// An inlined export isn't reachable by name on the raw exports object,
1404+
// so the decoupled object must expose the inlined value directly.
1405+
mangled = true;
1406+
definitions.push(
1407+
`${propertyName(exportInfo.name)}: ${this.returningFunction(
1408+
used.render(
1409+
Template.toNormalComment(
1410+
`inlined export ${propertyAccess([exportInfo.name])}`
1411+
)
1412+
)
1413+
)}`
1414+
);
1415+
continue;
1416+
}
1417+
if (used[used.length - 1] !== exportInfo.name) mangled = true;
1418+
definitions.push(
1419+
`${propertyName(exportInfo.name)}: ${this.returningFunction(
1420+
`${importVar}${propertyAccess(used)}`
1421+
)}`
1422+
);
1423+
}
1424+
if (!mangled) return;
1425+
const name = `${importVar}_namespace_object`;
1426+
runtimeRequirements.add(RuntimeGlobals.exports);
1427+
runtimeRequirements.add(RuntimeGlobals.makeNamespaceObject);
1428+
runtimeRequirements.add(RuntimeGlobals.definePropertyGetters);
1429+
initFragments.push(
1430+
new InitFragment(
1431+
`var ${name} = {};\n${RuntimeGlobals.makeNamespaceObject}(${name});\n${
1432+
RuntimeGlobals.definePropertyGetters
1433+
}(${name}, {\n\t${definitions.join(",\n\t")}\n});\n`,
1434+
InitFragment.STAGE_PROVIDES,
1435+
0,
1436+
name
1437+
)
1438+
);
1439+
return name;
1440+
}
1441+
13511442
/**
13521443
* Returns expression.
13531444
* @param {object} options options

lib/WebpackOptionsApply.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,9 @@ class WebpackOptionsApply extends OptionsApply {
640640
const FlagDependencyUsagePlugin = require("./FlagDependencyUsagePlugin");
641641

642642
new FlagDependencyUsagePlugin(
643-
options.optimization.usedExports === "global"
643+
options.optimization.usedExports === "global",
644+
// Keep an escaping module's exports mangleable when mangling is on.
645+
Boolean(options.optimization.mangleExports)
644646
).apply(compiler);
645647
}
646648
if (options.optimization.innerGraph) {

0 commit comments

Comments
 (0)