keep_fnames on both compress and mangle writes class initializer to an undeclared global (module mode)
Version
terser 5.50.0 (also reproduced on every 5.x tested from 5.10 through 5.51.2 — no version is unaffected)
Summary
With keep_fnames enabled on both compress and mangle, in module mode, terser miscompiles the ES5 IIFE-class pattern: the class initializer is written to an undeclared global while every read is mangled to a local binding that is never assigned. The result is a silent undefined at runtime rather than a crash at minify time.
This is what the top-level keep_fnames: true shorthand does — lib/minify.js fans it out to both compress.keep_fnames and mangle.keep_fnames — so the plain, documented way of asking to keep function names is enough to trigger it.
Input
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
var ResizeObserverSize, size;
var init_size = __esmMin(() => {
ResizeObserverSize = (function () {
function ResizeObserverSize(inlineSize, blockSize) {
this.inlineSize = inlineSize;
this.blockSize = blockSize;
Object.freeze(this);
}
return ResizeObserverSize;
})();
});
var init_calc = __esmMin(() => {
init_size();
size = function (a, b) {
return new ResizeObserverSize(a || 0, b || 0);
};
});
init_calc();
console.log(JSON.stringify(size(1, 2)));Command
terser repro.js --module --keep-fnames -c -mActual output
var e,i,__esmMin=(e,i)=>()=>(e&&(i=e(e=0)),i),n=__esmMin(()=>{ResizeObserverSize=function ResizeObserverSize(e,i){this.inlineSize=e,this.blockSize=i,Object.freeze(this)}});__esmMin(()=>{n(),i=function(i,n){return new e(i||0,n||0)}})(),console.log(JSON.stringify(i(1,2)));Note the inconsistency:
var eis declared, and read asnew e(i||0,n||0)- the only write is
ResizeObserverSize=function ResizeObserverSize(...)— the original, unmangled name, which is not declared anywhere in the output - so
eis never assigned
Piping to node: TypeError: e is not a constructor.
In a browser this is worse than it looks. ResizeObserverSize happens to be a real global, so the stray assignment silently overwrites window.ResizeObserverSize instead of throwing, even in module/strict mode — the corruption is completely invisible until the mangled binding is used.
Expected output
The write and the reads should refer to the same binding, e.g. e=function ResizeObserverSize(...).
Bisect
| Flags | Result |
|---|---|
--module --keep-fnames -c -m |
broken |
--keep-fnames -c -m (no --module) |
ok |
--module -c -m (no keep-fnames) |
ok |
--module -c keep_fnames=true -m (compress side only) |
ok — symbols stay unified, i=function i(...) |
--module -c -m keep_fnames=true (mangle side only) |
ok |
Both sides together are required. --module is also required, since toplevel names are only mangled in that mode.
Disabling individual compress passes (reduce_vars, inline, unused, collapse_vars, sequences, evaluate, side_effects) does not avoid it — the IIFE collapse that unifies the function-name symbol with the outer var symbol appears to be unconditional in compress.
Scope
Only the ES5 single-constructor IIFE-class shape is affected. Equivalent ES2015 class declarations are safe under every flag combination, and ES5 classes that also assign prototype methods appear to escape because the extra statements prevent the IIFE from collapsing. keep_classnames makes no difference, since the victims are plain functions.
Why this shape matters in practice
var X = (function(){ function X(){…} return X })() is standard TypeScript/Babel ES5 class output, and it is common inside the lazy module-init wrappers that bundlers emit (the __esmMin/__commonJSMin helpers above are rolldown's; esbuild's __esm/__commonJS are the same shape). Combined with a bundler that sets module: true for ES output, the trigger conditions are easy to meet without asking for anything unusual.
This caused a production outage for us: @juggle/resize-observer (reached via use-resize-observer/polyfilled) lost three constructors this way — ResizeObserverSize, ResizeObserverEntry and ResizeObserverDetail — which threw during the import-time init cascade and left a blank page. A full scope analysis of the bundle showed those were the only casualties, so the miscompile is narrow but entirely silent.
Related
#534 is the closest existing report (keep_fnames shadowed by an outer var, confirmed 2019), but its fix does not cover this closure-wrapped shape. Possibly adjacent: #1484, #1019, #1676.
Source: terser/terser