Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1415,7 +1415,7 @@ In each case the member access compiles to a direct reference to `object`, the `

Assignments (`z.x = 1`), optional chains, and non-literal computed keys (`z[key]`) are left as property accesses; `z["object"]` is treated like `z.object`. `export default someImport` is followed only when it ends at a namespace; a default that snapshots a `let` export keeps snapshot semantics.

The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. The namespace object is created only when `React` itself is used as a value, and it lists the exports in assignment order, like `module.exports` does. A write through it, `React.useLayoutEffect = React.useEffect`, assigns the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting.
The same applies to the default import of a CommonJS module whose `exports.x = ...` assignments the bundler lifted to ES module exports, such as `react` and `scheduler`. The default import of a CommonJS module is its `module.exports`, which is that module's namespace, so `import React from "react"; React.useState()` compiles to a direct call of the lifted `useState` binding. This holds while every use of `React` reads, calls or assigns one of its properties: `React.useState`, `React[key]`, `const { useState } = React`. A write, `React.useLayoutEffect = React.useEffect`, goes through a namespace object whose setters assign the lifted binding, so every importer sees the new value, the same as a write to `module.exports`. Code that holds `React` itself as a value (`fn(React)`, `Object.keys(React)`, `const R = React`, a re-export) or deletes one of its properties can change the object in ways a namespace object cannot pass on to the lifted bindings (`Object.defineProperty`, `delete`, `Object.freeze`). The module then keeps its CommonJS wrapper and the import is the real `module.exports` object. `React.default`, and `ns.default` on `import * as ns`, is the lifted `default` export when the module has one, and otherwise the namespace itself, as `module.exports` is in Node. A module that sets both `exports.__esModule` and `exports.default` keeps its CommonJS wrapper when the importer is not an ES module by type (`.mjs`, `.mts`, or `"type": "module"`), because the default import then depends on that flag at run time. An `import()` of a module that does not set both resolves to that same namespace object as `default`, with or without code splitting.

### deprecatedNamespaceObjectSetters

Expand Down
4 changes: 4 additions & 0 deletions src/ast/symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ bitflags::bitflags! {
/// Renaming can also break any identifier used inside a "with" statement.
const MUST_NOT_BE_RENAMED = 1 << 2;

/// An import some use holds as a value or deletes from, not only `X.a`, `X[a]`, `const { a } = X`.
const IMPORT_USED_AS_VALUE = 1 << 3;

const REMOVE_OVERWRITTEN_FUNCTION_DECLARATION = 1 << 4;

/// The file assigns this variable after its declaration (or a mapped
Expand Down Expand Up @@ -151,6 +154,7 @@ macro_rules! symbol_flag_accessors {
symbol_flag_accessors! {
must_start_with_capital_letter_for_jsx, set_must_start_with_capital_letter_for_jsx => MUST_START_WITH_CAPITAL_LETTER_FOR_JSX;
must_not_be_renamed, set_must_not_be_renamed => MUST_NOT_BE_RENAMED;
import_used_as_value, set_import_used_as_value => IMPORT_USED_AS_VALUE;
remove_overwritten_function_declaration, set_remove_overwritten_function_declaration => REMOVE_OVERWRITTEN_FUNCTION_DECLARATION;
has_been_assigned_to, set_has_been_assigned_to => HAS_BEEN_ASSIGNED_TO;
called_as_method, set_called_as_method => CALLED_AS_METHOD;
Expand Down
68 changes: 65 additions & 3 deletions src/bundler/linker_context/scanImportsAndExports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ macro_rules! col_ref {
};
}

/// Importers of lifted CommonJS file `root` get its real `module.exports`, not the namespace object that stands in for it.
fn keep_commonjs_wrapper_of_lifted_file(
root: usize,
exports_kind: &mut [ExportsKind],
flags: &mut [js_meta::Flags],
ast_flags: &[AstFlags],
export_star_import_records: &[bun_alloc::AstVec<u32>],
import_records: &[ImportRecordList<'_>],
) {
let mut pending = vec![root];
let mut visited: Vec<usize> = Vec::new();
while let Some(file) = pending.pop() {
if visited.contains(&file) {
continue;
}
visited.push(file);
exports_kind[file] = ExportsKind::Cjs;
flags[file].wrap = WrapKind::Cjs;
// A lifted file's export star was `module.exports = require("./b")`: the object of "./b".
for &star in export_star_import_records[file].iter() {
if let Some(record) = import_records[file].as_slice().get(star as usize)
&& record.source_index.is_valid()
{
let target = record.source_index.get() as usize;
if target < ast_flags.len()
&& ast_flags[target].contains(AstFlags::COMMONJS_LIFTED_TO_ESM)
{
pending.push(target);
}
}
}
}
}

pub(crate) fn scan_imports_and_exports(
this: &mut LinkerContext,
) -> Result<(), ScanImportsAndExportsError> {
Expand Down Expand Up @@ -172,7 +206,11 @@ pub(crate) fn scan_imports_and_exports(
// Named static imports contribute exactly their aliases to the
// importee's observable-export set (see below); `* as ns` and
// `export * from` make everything observable.
for ni in col_ref!(named_imports)[id].values() {
for (import_ref, ni) in col_ref!(named_imports)[id]
.keys()
.iter()
.zip(col_ref!(named_imports)[id].values())
{
let Some(record) = col_ref!(import_records_list)[id]
.as_slice()
.get(ni.import_record_index as usize)
Expand All @@ -183,9 +221,33 @@ pub(crate) fn scan_imports_and_exports(
continue;
}
let other = record.source_index.get() as usize;
if other >= col_ref!(exports_kind).len()
|| col_ref!(exports_kind)[other] != ExportsKind::Esm
if other >= col_ref!(exports_kind).len() {
continue;
}
// `Object.freeze`, `defineProperty` and `delete` on a held default import need the real `module.exports`.
if col_ref!(ast_flags_list)[other].contains(AstFlags::COMMONJS_LIFTED_TO_ESM)
&& !ni.alias_is_star
&& ni.alias.is_some_and(|alias| alias.slice() == b"default")
&& this
.graph
.symbols
.get_const(*import_ref)
.is_some_and(|symbol| {
// `ns.default` can be the module's own `default` export, which only step 3 resolves.
symbol.import_item_status != bun_ast::ImportItemStatus::Generated
&& (ni.is_exported || symbol.import_used_as_value())
})
{
keep_commonjs_wrapper_of_lifted_file(
other,
col!(exports_kind),
col!(flags),
col_ref!(ast_flags_list),
col_ref!(export_star_import_records),
col_ref!(import_records_list),
);
}
if col_ref!(exports_kind)[other] != ExportsKind::Esm {
continue;
}
// The default import of a lifted CommonJS module is its namespace.
Expand Down
3 changes: 3 additions & 0 deletions src/js_parser/fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.with_assign_target(identifier_opts.assign_target())
.with_is_call_target(identifier_opts.is_call_target())
.with_is_delete_target(identifier_opts.is_delete_target())
.with_is_property_access_target(
identifier_opts.is_property_access_target(),
)
// If this expression is used as the target of a call expression, make
// sure the value of "this" is preserved.
.with_was_originally_identifier(false),
Expand Down
18 changes: 14 additions & 4 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2138,6 +2138,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
}

/// See `Symbol::import_used_as_value`.
fn note_import_use(&mut self, ref_: Ref, opts: IdentifierOpts) {
if !opts.is_property_access_target() && !self.is_control_flow_dead {
self.symbols[ref_.inner_index() as usize].set_import_used_as_value(true);
}
}

pub(crate) fn log_arrow_arg_errors(&mut self, errors: &mut DeferredArrowArgErrors) {
if errors.invalid_expr_await.len > 0 {
let r = errors.invalid_expr_await;
Expand Down Expand Up @@ -2263,12 +2270,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
}

self.note_import_use(ref_, opts);
return self.new_expr(E::ImportIdentifier::new(ident.ref_, true), loc);
}
}

// Substitute an EImportIdentifier now if this is an import item
if self.is_import_item.contains_key(&ref_) {
self.note_import_use(ref_, opts);
return self.new_expr(
E::ImportIdentifier::new(ref_, opts.was_originally_identifier()),
loc,
Expand Down Expand Up @@ -5492,7 +5501,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.with_must_keep_due_to_with_stmt(result.is_inside_with_scope)
.with_can_be_removed_if_unused(true),
Some(parts[0]),
IdentifierOpts::new().with_was_originally_identifier(true),
IdentifierOpts::new()
.with_was_originally_identifier(true)
.with_is_property_access_target(parts.len() > 1),
);
if parts.len() > 1 {
return Ok(self.member_expression(loc, value, &parts[1..]));
Expand Down Expand Up @@ -5529,14 +5540,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
) -> Expr {
let mut value = initial_value;

for part in parts {
for (i, part) in parts.iter().enumerate() {
if let Some(rewrote) = self.maybe_rewrite_property_access(
loc,
value,
part,
loc,
// All defaults on the packed-u8 IdentifierOpts.
IdentifierOpts::default(),
IdentifierOpts::default().with_is_property_access_target(i + 1 < parts.len()),
) {
value = rewrote;
} else {
Expand Down
14 changes: 14 additions & 0 deletions src/js_parser/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ impl IdentifierOpts {
const WAS_ORIGINALLY_IDENTIFIER: u8 = 1 << 3;
const IS_CALL_TARGET: u8 = 1 << 4;
const IS_TEMPLATE_TAG: u8 = 1 << 5;
const IS_PROPERTY_ACCESS_TARGET: u8 = 1 << 6;

#[inline]
pub(crate) const fn assign_target(self) -> js_ast::AssignTarget {
Expand Down Expand Up @@ -955,6 +956,11 @@ impl IdentifierOpts {
pub(crate) const fn is_template_tag(self) -> bool {
self.0 & Self::IS_TEMPLATE_TAG != 0
}
/// See `ExprIn::is_property_access_target`.
#[inline]
pub(crate) const fn is_property_access_target(self) -> bool {
self.0 & Self::IS_PROPERTY_ACCESS_TARGET != 0
}

// Builder-style helpers (this stays a packed u8 rather than a
// named-field struct).
Expand Down Expand Up @@ -987,6 +993,11 @@ impl IdentifierOpts {
self.0 = (self.0 & !Self::IS_TEMPLATE_TAG) | ((v as u8) << 5);
self
}
#[inline]
pub(crate) const fn with_is_property_access_target(mut self, v: bool) -> Self {
self.0 = (self.0 & !Self::IS_PROPERTY_ACCESS_TARGET) | ((v as u8) << 6);
self
}
}

pub(crate) fn statement_cares_about_scope(stmt: &Stmt) -> bool {
Expand Down Expand Up @@ -1041,6 +1052,9 @@ pub struct ExprIn {
pub(crate) is_immediately_assigned_to_decl: bool,

pub(crate) property_access_for_method_call_maybe_should_replace_with_undefined: bool,

/// The parent only reads, calls or assigns a property of this: `x.a`, `x[a]`, `const { a } = x`, not `delete x.a`.
pub(crate) is_property_access_target: bool,
}

/// This function exists to tie all of these checks together in one place
Expand Down
2 changes: 2 additions & 0 deletions src/js_parser/visit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
&mut val,
ExprIn {
is_immediately_assigned_to_decl: true,
// `const { a } = x` reads `x.a` and drops `x`.
is_property_access_target: matches!(decl.binding.data, BData::BObject(_)),
..Default::default()
},
);
Expand Down
18 changes: 14 additions & 4 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.ref_,
),
)
.with_was_originally_identifier(true),
.with_was_originally_identifier(true)
.with_is_property_access_target(in_.is_property_access_target),
);
}
// PERF(port:frame): keep these large, infrequently-taken arms out of the
Expand Down Expand Up @@ -883,7 +884,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
}
}

p.visit_expr_in_out(&mut e_.target, ExprIn::default());
p.visit_expr_in_out(
&mut e_.target,
ExprIn {
is_property_access_target: !is_delete_target,
..Default::default()
},
);

match e_.index.data {
Data::EPrivateIdentifier(mut private) => {
Expand Down Expand Up @@ -1008,7 +1015,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.with_is_call_target(is_call_target)
.with_is_template_tag(is_template_tag)
.with_is_delete_target(is_delete_target)
.with_assign_target(in_.assign_target);
.with_assign_target(in_.assign_target)
.with_is_property_access_target(in_.is_property_access_target);
if let Some(rewrite) = p.maybe_rewrite_property_access(
expr.loc,
e_.target,
Expand Down Expand Up @@ -1393,6 +1401,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
ExprIn {
property_access_for_method_call_maybe_should_replace_with_undefined: in_
.property_access_for_method_call_maybe_should_replace_with_undefined,
is_property_access_target: !is_delete_target,
..Default::default()
},
);
Expand Down Expand Up @@ -1433,7 +1442,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
.with_is_call_target(is_call_target)
.with_is_template_tag(is_template_tag)
.with_assign_target(in_.assign_target)
.with_is_delete_target(is_delete_target);
.with_is_delete_target(is_delete_target)
.with_is_property_access_target(in_.is_property_access_target);
if let Some(_expr) = p.maybe_rewrite_property_access(
expr.loc,
e_.target,
Expand Down
8 changes: 4 additions & 4 deletions test/bundler/bundler_cjs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,10 @@ describe("bundler", () => {
`,
},
run: {
// The default import is `module.exports`, which for a lifted CommonJS
// module is the namespace object itself, so the namespace has no
// separate `default` key (the same as a lone `import *`, Test 6).
stdout: '{"default":{"foo":"foo","bar":"bar"},"named":"foo","namespace":{"foo":"foo","bar":"bar"}}',
// The default import is held as a value, so the module keeps its
// CommonJS wrapper and the namespace goes through __toESM, as in Node.
stdout:
'{"default":{"foo":"foo","bar":"bar"},"named":"foo","namespace":{"default":{"foo":"foo","bar":"bar"},"foo":"foo","bar":"bar"}}',
},
});

Expand Down
Loading
Loading