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
16 changes: 15 additions & 1 deletion crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,21 @@ pub fn extract_top_level_class_decls(source: &str) -> (String, Vec<String>, Stri
// `Undefined variable in update expression`. The conservative rule
// is to leave the class inside the IIFE when *any* of its referenced
// identifiers would lose their binding to the IIFE-local state.
let iife_locals = collect_top_level_let_const_var_names(source);
let mut iife_locals = collect_top_level_let_const_var_names(source);
// Issue #5251 — the cjs_wrap preamble injects `var exports`, `var module`,
// and a `require` function as IIFE-local bindings (see `wrap.rs`'s
// `cjs_preamble`). They are NOT declared in the original source, so the
// textual top-level scan above never sees them. A class whose body reads
// `exports.X` / `module.exports` / `require(...)` must therefore ALSO stay
// inside the IIFE — hoisting it out severs the closure over the injected
// `var exports`, and `exports` then resolves as an unknown global
// (`exports.X` lowers to the numeric `0` sentinel → `(number).test is not a
// function` inside class methods/constructors of CJS packages like ajv).
for injected in ["exports", "module", "require"] {
if !iife_locals.iter().any(|n| n == injected) {
iife_locals.push(injected.to_string());
}
}

let mut i = 0usize;
while i < bytes.len() {
Expand Down
49 changes: 49 additions & 0 deletions crates/perry/src/commands/compile/cjs_wrap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,55 @@ module.exports = SafeBuffer;"#;
);
}

#[test]
fn issue_5251_class_reading_exports_stays_in_iife() {
// #5251: a top-level class whose body reads the cjs_wrap-injected
// `exports` binding (`exports.X` inside a method/ctor) must NOT be
// hoisted out of the IIFE — hoisting severs its closure over the
// injected `var exports`, so `exports.X` resolves as an unknown
// global and lowers to the numeric `0` sentinel inside class methods.
let src = "\"use strict\";\nexports.TAG = \"hi\";\nclass C { greet() { return exports.TAG + \"!\"; } }\nexports.mk = function () { return new C(); };\n";
let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/app/node_modules/re/index.js"));
let iife_start = wrapped
.find("const _cjs = (function() {")
.expect("expected an IIFE wrap (no flat default class), got:\n");
let class_pos = wrapped
.find("class C ")
.expect("class C must survive in the wrapped output");
assert!(
class_pos > iife_start,
"class reading `exports` must stay INSIDE the IIFE (after its \
opener), not hoisted above it, got:\n{}",
wrapped
);
assert!(
perry_parser::parse_typescript(&wrapped, "re/index.js").is_ok(),
"wrapped module must parse, got:\n{}",
wrapped
);
}

#[test]
fn issue_5251_class_without_exports_still_hoists() {
// Regression guard: a top-level class that does NOT touch the injected
// `exports`/`module`/`require` bindings must still hoist above the
// IIFE (so `import { D } from "pkg"` resolves to the real class).
let src = "\"use strict\";\nclass D { val() { return 42; } }\nexports.mkD = function () { return new D(); };\n";
let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/app/node_modules/re/index.js"));
let iife_start = wrapped
.find("const _cjs = (function() {")
.expect("expected an IIFE wrap, got:\n");
let class_pos = wrapped
.find("class D ")
.expect("class D must survive in the wrapped output");
assert!(
class_pos < iife_start,
"a class not referencing exports/module/require must still hoist \
above the IIFE, got:\n{}",
wrapped
);
}

#[test]
fn issue_1721_blanks_adopted_alias_require_in_body() {
// #1721: `const c = require('./common')` adopts `c` as the import
Expand Down
Loading