Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a4306c6
module loader: evaluate ESM-imported CommonJS at its post-order slot
robobun Jul 26, 2026
41e8e9a
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
b7a8405
extract record_runtime_commonjs_export_name; trim comments
robobun Jul 26, 2026
d430450
address review: deref commonjs_export_names in ~SourceProvider; read …
robobun Jul 26, 2026
4c06fc0
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
669cb78
scope VMInquiry PropertySlot; keep runtime classifier off the bundler…
robobun Jul 26, 2026
9314f7b
detect module.exports = {X, Y}; fall back when reassigned; thread exp…
robobun Jul 26, 2026
132c838
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
1eb1590
match cjs-module-lexer's lexical exports scan; gate value_for_this co…
robobun Jul 26, 2026
4d7b541
avoid lint-flagged literal in mock/6874 fixtures
robobun Jul 26, 2026
a90c4e1
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 26, 2026
a94db37
address review nits: spell out exception check in readExport lambda; …
robobun Jul 26, 2026
f4a274a
follow __exportStar/Object.keys re-exports; fall back to eager eval f…
robobun Jul 27, 2026
f46e183
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
b1570d3
trim inline comments
robobun Jul 27, 2026
5448888
verify the forEach callback writes to exports before recording a re-e…
robobun Jul 27, 2026
08ada58
drop export*from; __exportStar/Object.keys().forEach deopt to eager i…
robobun Jul 27, 2026
3cb8171
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
800142d
gate the bare-exports-assign setter on the unwrap path
robobun Jul 27, 2026
ff50b38
address review nits: lexical module.exports match; own-only default p…
robobun Jul 27, 2026
fcca760
apply review: align gate predicate; assert putResult; strengthen two …
robobun Jul 27, 2026
ff14668
gate the bare-exports-assign setter on !commonjs_named_exports_deopti…
robobun Jul 27, 2026
837230c
deref commonjs_export_names in evaluateWithPotentiallyOverriddenCompile
robobun Jul 27, 2026
0cc7e3f
clarify why zero-names keeps the eager path (lodash UMD-via-alias)
robobun Jul 27, 2026
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
5 changes: 5 additions & 0 deletions src/ast/transpiler_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ pub struct RuntimeTranspilerCache {
pub input_byte_length: Option<u64>,
pub features_hash: Option<u64>,
pub exports_kind: ExportsKind,
/// NUL-joined static CommonJS export names. Stored as the `esm_record`
/// blob for Cjs entries so a cache hit takes the same ESM-imports-CJS
/// evaluation path as a fresh transpile.
Comment thread
robobun marked this conversation as resolved.
pub cjs_export_names: Vec<u8>,
/// Set by `put()` / `get()` when a cache hit returns transpiled output.
/// Bundler/parser only store/read the bytes; T6 owns the string wrapper
/// when surfacing to JS.
Expand All @@ -38,6 +42,7 @@ impl Default for RuntimeTranspilerCache {
input_byte_length: None,
features_hash: None,
exports_kind: ExportsKind::None,
cjs_export_names: Vec::new(),
output_code: None,
entry: None,
r#impl: None,
Expand Down
94 changes: 94 additions & 0 deletions src/js_parser/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,65 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
// inline module.path
p.ignore_usage(p.module_ref);
return Some(p.new_expr(e_string_init(p.source.path.pretty), name_loc));
} else if p.options.features.commonjs_at_runtime
&& !p.should_unwrap_common_js_to_esm()
&& name == b"exports"
&& identifier_opts.assign_target() != js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
{
let mut handled = false;
if identifier_opts.assign_target() == js_ast::AssignTarget::Replace {
if let js_ast::ExprData::EBinary(bin) = p.stmt_expr_value {
if bin.op == js_ast::OpCode::BinAssign
&& matches!(
&bin.left.data,
js_ast::ExprData::EDot(d)
if d.name == b"exports"
&& matches!(
d.target.data,
js_ast::ExprData::EIdentifier(inner)
if inner.ref_.eql(p.module_ref)
)
)
{
// `module.exports = { a, b, ... }` (cjs-module-lexer MODULE_EXPORTS_ASSIGN)
if let js_ast::ExprData::EObject(obj) = &bin.right.data {
handled = true;
for prop in obj.properties.slice() {
Comment thread
robobun marked this conversation as resolved.
if prop.kind != G::PropertyKind::Normal
|| prop
.flags
.contains(Flags::Property::IsComputed)
|| prop
.flags
.contains(Flags::Property::IsSpread)
|| prop
.flags
.contains(Flags::Property::IsMethod)
{
continue;
}
if let Some(key) = prop.key {
if let js_ast::ExprData::EString(key_str) =
&key.data
{
if !key_str.is_utf16 {
p.record_runtime_commonjs_export_name(
&key_str.data,
key.loc,
);
}
}
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
if !handled {
// `module.exports = <non-object>`: export set is dynamic, keep the eager path.
p.commonjs_module_exports_assigned_deoptimized = true;
}
Comment thread
robobun marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -430,6 +489,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
p.has_commonjs_export_names = true;
}
}
} else if p.options.features.commonjs_at_runtime
&& !p.is_control_flow_dead
&& identifier_opts.assign_target() != js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
&& (id.ref_.eql(p.exports_ref)
|| p.symbols.as_slice()[id.ref_.inner_index() as usize]
.original_name
.slice()
== b"exports")
{
// Match cjs-module-lexer's lexical scan: any identifier spelled
// `exports` counts, including UMD-factory parameters.
Comment thread
robobun marked this conversation as resolved.
p.record_runtime_commonjs_export_name(name, name_loc);
}

// Handle references to namespaces or namespace members
Expand Down Expand Up @@ -580,6 +652,28 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
// EDot and EIndex are handled with structurally identical arms.
js_ast::ExprData::EDot(data) => {
// `module.exports.<name>` — at runtime `module.exports` is not
// rewritten to `ESpecial::ModuleExports`, so it arrives here as
// a nested EDot.
Comment thread
robobun marked this conversation as resolved.
if p.options.features.commonjs_at_runtime
&& !p.should_unwrap_common_js_to_esm()
&& !p.is_control_flow_dead
&& identifier_opts.assign_target() != js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
&& data.name == b"exports"
&& matches!(
data.target.data,
js_ast::ExprData::EIdentifier(inner)
if inner.ref_.eql(p.module_ref)
|| p.symbols.as_slice()[inner.ref_.inner_index() as usize]
.original_name
.slice()
== b"module"
)
{
p.record_runtime_commonjs_export_name(name, name_loc);
}
Comment thread
robobun marked this conversation as resolved.

Comment thread
robobun marked this conversation as resolved.
if matches!(p.ts_namespace.expr, js_ast::ExprData::EDot(ns_data) if data.as_ptr() == ns_data.as_ptr())
&& identifier_opts.assign_target() == js_ast::AssignTarget::None
&& !identifier_opts.is_delete_target()
Expand Down
27 changes: 26 additions & 1 deletion src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5219,6 +5219,28 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
self.commonjs_named_exports_deoptimized = true;
}

/// Runtime path (cjs-module-lexer analogue): record a statically visible
/// CommonJS export name for the ESM-imports-CJS wrapper. The expression is
/// not rewritten.
Comment thread
robobun marked this conversation as resolved.
pub fn record_runtime_commonjs_export_name(&mut self, name: &[u8], loc: bun_ast::Loc) {
// Names cross to C++ NUL-joined in a single BunString.
if name.is_empty() || name.contains(&0) || self.commonjs_named_exports.contains(name) {
return;
}
self.commonjs_named_exports
.put(
name,
bun_ast::ast_result::CommonJSNamedExport {
loc_ref: bun_ast::LocRef {
loc,
ref_: bun_ast::Ref::NONE,
},
needs_decl: false,
},
)
.expect("unreachable");
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.

pub fn maybe_keep_expr_symbol_name(
&mut self,
expr: Expr,
Expand Down Expand Up @@ -5264,7 +5286,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
if self.options.repl_mode {
return None;
}
if self.has_es_module_syntax && self.commonjs_named_exports.count() == 0 {
if self.has_es_module_syntax
&& (!self.should_unwrap_common_js_to_esm()
|| self.commonjs_named_exports.count() == 0)
{
// In an ES6 module, "this" is supposed to be undefined. Instead of
// doing this at runtime using "fn.call(undefined)", we do it at
// compile time using expression substitution here.
Expand Down
7 changes: 6 additions & 1 deletion src/js_parser/parse/parse_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1505,7 +1505,11 @@ impl<'a> Parser<'a> {

let mut wrap_mode: WrapMode = WrapMode::None;

if p.is_deoptimized_commonjs() {
// `is_deoptimized_commonjs()` is only meaningful on the bundler's
// unwrap-CommonJS-to-ESM path; on the runtime path the recorders
// populate `commonjs_named_exports` without enabling that optimisation,
// and classification falls through to the `uses_exports_ref` branch.
Comment thread
robobun marked this conversation as resolved.
if p.should_unwrap_common_js_to_esm() && p.is_deoptimized_commonjs() {
exports_kind = js_ast::ExportsKind::Cjs;
} else if p.esm_export_keyword.len > 0 || p.top_level_await_keyword.len > 0 {
exports_kind = js_ast::ExportsKind::Esm;
Expand Down Expand Up @@ -1762,6 +1766,7 @@ impl<'a> Parser<'a> {
}

if exports_kind == js_ast::ExportsKind::Esm
&& p.should_unwrap_common_js_to_esm()
&& p.commonjs_named_exports.count() > 0
&& !p.unwrap_all_requires
&& !force_esm
Expand Down
126 changes: 125 additions & 1 deletion src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
),
),
}
} else if p.exports_ref.eql(e_.ref_) {
} else if p.exports_ref.eql(e_.ref_) && !p.commonjs_named_exports_deoptimized {
// Assigning to `exports` in a CommonJS module must be tracked to undo the
// `module.exports` -> `exports` optimization.
p.commonjs_module_exports_assigned_deoptimized = true;
Expand Down Expand Up @@ -1983,6 +1983,130 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
p.visit_expr(arg);
}

// cjs-module-lexer shapes: `Object.defineProperty(exports, "X", ...)` records X;
// `__exportStar(_, exports)` / `Object.keys(_).forEach(k => exports[k] = ...)` deopt.
Comment thread
robobun marked this conversation as resolved.
if p.options.features.commonjs_at_runtime
&& !p.should_unwrap_common_js_to_esm()
&& !p.is_control_flow_dead
{
Comment thread
coderabbitai[bot] marked this conversation as resolved.
enum Action<'s> {
None,
Record(&'s [u8], bun_ast::Loc),
Deopt,
}
let action = {
let exports_ref = p.exports_ref;
let module_ref = p.module_ref;
let symbols = p.symbols.as_slice();
let is_identifier_named = |ex: &Expr, want: &[u8]| -> bool {
matches!(
&ex.data,
Data::EIdentifier(id)
if symbols[id.ref_.inner_index() as usize].original_name.slice() == want
)
};
let is_exports_expr = |ex: &Expr| -> bool {
match &ex.data {
Data::EIdentifier(id) => {
id.ref_.eql(exports_ref)
|| symbols[id.ref_.inner_index() as usize]
.original_name
.slice()
== b"exports"
}
Data::EDot(d) => {
d.name == b"exports"
&& matches!(
d.target.data,
Data::EIdentifier(inner)
if inner.ref_.eql(module_ref)
|| symbols[inner.ref_.inner_index() as usize]
.original_name
.slice()
== b"module"
)
}
_ => false,
}
};
let is_object_define_property = matches!(
&e_.target.data,
Data::EDot(dot)
if dot.name == b"defineProperty" && is_identifier_named(&dot.target, b"Object")
);
let args = e_.args.slice();
if is_object_define_property && args.len() >= 2 && is_exports_expr(&args[0]) {
match &args[1].data {
Data::EString(name_str) if !name_str.is_utf16 => {
Action::Record(&name_str.data, args[1].loc)
}
_ => Action::None,
}
Comment thread
robobun marked this conversation as resolved.
} else if args.len() == 2 && is_exports_expr(&args[1]) {
let target_name: &[u8] = match &e_.target.data {
Data::EDot(d) => &d.name,
Data::EIdentifier(id) => symbols[id.ref_.inner_index() as usize]
.original_name
.slice(),
_ => b"",
};
if matches!(target_name, b"__exportStar" | b"__export" | b"_exportStar") {
Action::Deopt
} else {
Action::None
}
} else if let Data::EDot(dot) = &e_.target.data {
let body_stmts: &[Stmt] = match (dot.name == b"forEach", args) {
(true, [a]) => match &a.data {
Data::EArrow(a) => a.body.stmts.slice(),
Data::EFunction(f) => f.func.body.stmts.slice(),
_ => &[],
},
_ => &[],
};
let callback_writes_exports = body_stmts.iter().any(|s| {
let js_ast::StmtData::SExpr(se) = &s.data else {
return false;
};
match &se.value.data {
Data::ECall(c) => {
matches!(
&c.target.data,
Data::EDot(d) if d.name == b"defineProperty" && is_identifier_named(&d.target, b"Object")
) && c.args.slice().first().is_some_and(is_exports_expr)
}
Data::EBinary(b) => {
b.op == js_ast::OpCode::BinAssign
&& matches!(&b.left.data, Data::EIndex(i) if is_exports_expr(&i.target))
}
_ => false,
}
});
if callback_writes_exports
&& matches!(
&dot.target.data,
Data::ECall(inner)
if matches!(
&inner.target.data,
Data::EDot(d) if d.name == b"keys" && is_identifier_named(&d.target, b"Object")
)
)
{
Action::Deopt
} else {
Action::None
}
} else {
Action::None
}
};
match action {
Action::Record(name, loc) => p.record_runtime_commonjs_export_name(name, loc),
Action::Deopt => p.commonjs_module_exports_assigned_deoptimized = true,
Action::None => {}
}
}

// Restore saved state.
p.options.ignore_dce_annotations = old_ce;
p.should_fold_typescript_constant_expressions =
Expand Down
11 changes: 9 additions & 2 deletions src/js_printer/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7742,13 +7742,20 @@ pub fn print_ast<'a, W: WriterTrait, const ASCII_ONLY: bool, const GENERATE_SOUR
.map_err(|_| crate::Error::WriteFailed)?;
}
// SAFETY: caller guarantees the cache outlives the print call.
unsafe { &mut *cache.as_ptr() }.put(
let cache = unsafe { &mut *cache.as_ptr() };
let cjs_export_names = core::mem::take(&mut cache.cjs_export_names);
let esm_record: &[u8] = if have_module_info {
&srlz_res
} else {
&cjs_export_names
};
cache.put(
printer.writer.slice(),
source_maps_chunk
.as_ref()
.map(|c| c.buffer.list.as_slice())
.unwrap_or(b""),
&srlz_res,
esm_record,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

Expand Down
Loading
Loading