Skip to content
Draft
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
6 changes: 3 additions & 3 deletions docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ The generated `chunk-dqmx6gc8.js` file contains the shared code. To avoid collis

Each `import()` of a bundled JavaScript module also becomes its own chunk. Tree shaking applies to these chunks too: if every `import()` of a module lives in code that tree shaking removes — for example inside a function that is only called behind a [`define`](#define) or [`features`](#features) gate that evaluates to `false` — and nothing else in the live output imports the module, its chunk is not written and the module is absent from the [metafile](#metafile)'s `inputs` and `outputs`. Setting `treeShaking: false` keeps every `import()` chunk. This differs from esbuild, which emits a chunk for every reachable `import()` target.

With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-<hash>.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously.
With `target: "bun"`, a `require()` of a bundled ES module becomes a chunk of its own as well. The call is emitted as `import.meta.require("./chunk-<hash>.js")` and stays synchronous — Bun evaluates the chunk when the call runs — so a `require()` inside a function that never runs keeps its module out of the startup working set entirely. Tree shaking treats these chunks like `import()` chunks. `require()` of a CommonJS module is unaffected and keeps returning `module.exports`. A `require()` cycle between such chunks follows Bun's runtime `require()` of an ES module: a module required again while its chunk is still being loaded sees the partially initialized CommonJS placeholder (`{}`) rather than the live bindings the in-chunk wrapper provides. For other targets — and with `splitRequire: false` (`--no-split-require`) — the required module is inlined into the calling chunk behind a lazy wrapper instead, because the call must return synchronously. A CommonJS file whose `exports.x = ...` assignments the bundler lifted to ES module exports is a chunk as well. One example is `react-dom/server`. A `require()` of that file returns the object that stands in for its `module.exports`. When the bundle has such a `require()` that does more than read named exports off its result, an `import()` of the same file has a `module.exports` key in its namespace, as in Node. This also applies when tree shaking removes the call. On a Bun runtime older than 1.2.6 the `require()` returns the module namespace of the chunk.

#### Tree-shaking `import()` and `require()` results

Expand All @@ -575,7 +575,7 @@ await import("./polyfill"); // keeps only side effects

Recognized uses: destructuring (`const { a, b: c, ...rest } = await import(x)`, also `let`/`var` and `export const { a } = …`), property access on the awaited value or on a local holding it (`(await import(x)).a`, `const ns = await import(x); ns.a; const { b } = ns`), `.then(({ a }) => …)` / `.then(ns => ns.a)` with an arrow function, per-element destructuring of `await Promise.all([import(x), import(y)])`, the same shapes for `require()`, and a bare `import(x);` / `await import(x);` / `require(x);` statement (nothing observed).

The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed.
The module keeps every export as soon as one use can't be followed: the namespace is passed, returned, stored, spread or iterated (`() => import(x)`, `fn(ns)`, `{ ...ns }`, `Object.keys(ns)`), accessed with a computed key (or `?.` directly on the `await import(x)` expression), handed to a non-arrow `.then` callback, reached via `import * as ns` elsewhere, or read under a direct `eval`. CommonJS targets always keep everything. The exception is a file whose `exports.x = ...` assignments the bundler lifted to ES module exports: Bun drops the exports that nothing reads, as for an ES module. The imported module itself is always evaluated; only with `"sideEffects": false` can a module it merely re-exports from be skipped when none of those re-exports are observed.

<Tabs>
<Tab title="JavaScript">
Expand Down Expand Up @@ -1487,7 +1487,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. 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 default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `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 default import is the real `module.exports` object. This rule looks at `import React from` only. `ns.default` on `import * as ns`, the `default` of an `import()` and the `this` of a call like `React.fn()` are still the namespace object of a module that stays lifted. `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. A `require()` of a lifted module that stays a call in the output evaluates to that namespace object too. The call stays a call when it is inside `try`/`catch`. It also stays a call when its specifier is not a listed package name but resolves into one, for example through a tsconfig `paths` entry or a package.json `imports` entry. The object has an `__esModule` property only if the module sets `exports.__esModule`. A write to an export that the module assigns itself reaches the lifted binding. A module that runs a statement and then assigns `module.exports = require("./impl")` follows `./impl`. The call evaluates to the namespace object when `./impl` is a lifted CommonJS module, and the names of `./impl` are read-only on that object. `Object.defineProperty`, `delete` and `Object.freeze` on the object do not reach the lifted bindings. The same holds when the module becomes a chunk of its own, with `splitting: true` and `target: "bun"`.

### deprecatedNamespaceObjectSetters

Expand Down
39 changes: 39 additions & 0 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4533,6 +4533,45 @@ impl<'a> LinkerContext<'a> {
&& ast_flags.contains(AstFlags::COMMONJS_LIFTED_TO_ESM)
}

/// Does the chunk of this file export its namespace object as `"module.exports"`?
pub(crate) fn chunk_module_exports_is_namespace(meta_flags: crate::js_meta::Flags) -> bool {
meta_flags.needs_module_exports_export && meta_flags.wrap != WrapKind::Cjs
}

/// Is `target` a lifted CommonJS file, whose namespace object `require()` returns with no copy?
pub(crate) fn require_returns_namespace(
ast_flags: &[AstFlags],
exports_kinds: &[ExportsKind],
export_star_import_records: &[bun_alloc::AstVec<u32>],
import_records: &[bun_ast::import_record::List<'_>],
target: u32,
) -> bool {
let mut file = target as usize;
for _ in 0..ast_flags.len() {
// The flag is also on CommonJS, JSON, text and empty files of a listed package.
if !ast_flags[file].contains(AstFlags::COMMONJS_LIFTED_TO_ESM)
|| !matches!(
exports_kinds[file],
ExportsKind::Esm | ExportsKind::EsmWithDynamicFallbackFromCjs
)
{
return false;
}
// A lifted `module.exports = require("./impl")` is an `export *`: `./impl` decides.
let Some(&star) = export_star_import_records[file].first() else {
return true;
};
let source = import_records[file].as_slice()[star as usize].source_index;
// An external module can be an ES module.
if !source.is_valid() {
return false;
}
file = source.get() as usize;
}
// The walk is a cycle of lifted files.
true
}

/// Resolves every named import in one file to its matching export,
/// recording the bindings in `imports_to_bind`.
pub(crate) fn match_imports_with_exports_for_file(
Expand Down
2 changes: 2 additions & 0 deletions src/bundler/LinkerGraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ pub mod js_meta {
pub(crate) needs_export_symbol_from_runtime: bool,
pub(crate) did_wrap_dependencies: bool,
pub(crate) needs_synthetic_default_export: bool,
/// The chunk of this lifted file exports its namespace object as `"module.exports"`.
pub(crate) needs_module_exports_export: bool,
pub(crate) wrap: WrapKind,
}
pub use crate::WrapKind as Wrap;
Expand Down
11 changes: 9 additions & 2 deletions src/bundler/linker_context/computeCrossChunkDependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,14 +272,18 @@ impl<'a, 'bump> CrossChunkDependencies<'a, 'bump> {
flags,
deps.ast_flags[chunk.entry_point.source_index() as usize],
);
let module_exports_is_namespace =
LinkerContext::chunk_module_exports_is_namespace(flags);
if flags.wrap != WrapKind::Cjs {
let resolved_exports =
&deps.resolved_exports[chunk.entry_point.source_index() as usize];
let sorted_and_filtered_export_aliases = &deps
.sorted_and_filtered_export_aliases
[chunk.entry_point.source_index() as usize];
for alias in sorted_and_filtered_export_aliases.iter() {
if default_is_namespace && **alias == *b"default" {
if (default_is_namespace && **alias == *b"default")
|| (module_exports_is_namespace && **alias == *b"module.exports")
{
continue;
}
let export_ = resolved_exports.get(alias).unwrap();
Expand Down Expand Up @@ -324,7 +328,10 @@ impl<'a, 'bump> CrossChunkDependencies<'a, 'bump> {

// Ensure "exports" is included if the current output format needs it
// https://github.com/evanw/esbuild/blob/v0.27.2/internal/linker/linker.go#L1049-L1051
if flags.force_include_exports_for_entry_point || default_is_namespace {
if flags.force_include_exports_for_entry_point
|| default_is_namespace
|| module_exports_is_namespace
{
// result intentionally discarded
let _ = chunk_meta.imports.put(
deps.exports_refs[chunk.entry_point.source_index() as usize],
Expand Down
28 changes: 28 additions & 0 deletions src/bundler/linker_context/postProcessJSChunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -915,9 +915,12 @@ pub(crate) fn generate_entry_point_tail_js<'a>(

let default_is_namespace =
LinkerContext::chunk_default_export_is_namespace(flags, ast.flags);
let module_exports_is_namespace =
LinkerContext::chunk_module_exports_is_namespace(flags);

if !sorted_and_filtered_export_aliases.is_empty()
|| !cross_chunk_exports.is_empty()
|| module_exports_is_namespace
{
let resolved_exports: &ResolvedExports =
&c.graph.meta.items_resolved_exports()[source_index as usize];
Expand All @@ -939,6 +942,10 @@ pub(crate) fn generate_entry_point_tail_js<'a>(
if default_is_namespace && **alias == *b"default" {
continue;
}
// The namespace object takes the name. The file's own is a key of it.
if module_exports_is_namespace && **alias == *b"module.exports" {
continue;
}

// Only `.data` (an `ImportTracker`, `Copy`) is
// read/mutated below, so copy that field instead of
Expand Down Expand Up @@ -1073,6 +1080,19 @@ pub(crate) fn generate_entry_point_tail_js<'a>(
}
}

if module_exports_is_namespace {
// "export { exports_foo as "module.exports" };"
items.push(bun_ast::ClauseItem {
name: bun_ast::LocRef {
ref_: ast.exports_ref,
loc: bun_ast::Loc::EMPTY,
},
alias: bun_ast::StoreStr::new(b"module.exports"),
alias_loc: bun_ast::Loc::EMPTY,
..Default::default()
});
}

let own_len = items.len();
items.extend(cross_chunk_exports.iter().map(|item| bun_ast::ClauseItem {
alias: item.alias,
Expand All @@ -1084,6 +1104,14 @@ pub(crate) fn generate_entry_point_tail_js<'a>(
// collected Vec into the linker arena. The arena slice is also iterated
// below for the synthetic-default-export path.
let items: &mut [bun_ast::ClauseItem] = arena.alloc_slice_fill_iter(items);
debug_assert!(
(0..items.len()).all(|i| {
items[..i]
.iter()
.all(|item| item.alias.slice() != items[i].alias.slice())
}),
"two items of an export clause have one alias"
);
if !items.is_empty() {
stmts.push(Stmt::alloc(
S::ExportClause {
Expand Down
Loading
Loading