Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/bundler/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,8 @@ A footer added to the final bundle. This can be a comment block for a license or

Removes function calls from a bundle. For example, `--drop=console` removes all calls to `console.log`. Bun also removes the arguments to dropped calls, even if they have side effects. Dropping `debugger` removes all `debugger` statements.

`console.log.bind(console)` does not call `console.log`, so it becomes an empty function: a logger that is bound once and called later keeps working.

<Tabs>
<Tab title="JavaScript">
```ts title="build.ts" icon="/icons/typescript.svg"
Expand Down
12 changes: 12 additions & 0 deletions docs/bundler/minifier.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,16 @@ void 0;
x=void 0;
```

`console.log.bind(console)` does not call `console.log`. It makes a function, so it becomes an empty function and the result stays callable.

```ts Input
const log = console.log.bind(console);
```

```js Output
const log=()=>{};
```

### Drop custom function calls

**Mode:** `--drop=<name>`
Expand All @@ -1166,6 +1176,8 @@ void 0;
void 0;
```

`.bind()` on a method of a dropped name becomes an empty function: `assert.equal.bind(assert)` is `()=>{}`.

## Keep Names

To keep original function and class names for debugging while minifying identifiers, use the `--keep-names` flag:
Expand Down
100 changes: 100 additions & 0 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,98 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
p.should_fold_typescript_constant_expressions =
prev_should_fold_typescript_constant_expressions;
}
/// `console.log.bind(console)` makes a function to call later: `() => {}`, not `undefined`.
#[cold]
#[inline(never)]
fn replace_bind_of_dropped_function(p: &mut Self, e: &mut Expr) -> bool {
let Data::ECall(mut call) = e.data else {
return false;
};
let Data::EDot(bind) = call.target.data else {
return false;
};
debug_assert!(bind.name == b"bind");
// Not a bind of a dropped function: `Mousetrap.bind(k, f)`, or a callee on the drop list.
if !matches!(bind.target.data, Data::EDot(..))
Comment on lines +1883 to +1884

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users who drop a namespace whose API has a method literally named bind now get a bare () => {} where the base gave undefined, so guards like if (h) h.unbind() enter and throw. src/js_parser/visit/visit_expr.rs:1884 only rejects a direct X.bind receiver; app.keys.bind(k, fn) with --drop=app passes because app.keys is an EDot and is_dropped_name matches app. Fix: rewrite to an arrow only when the receiver is a dropped callable method shape the user asked for (for example require the exact a.b.bind not be a plain method of a dropped namespace, or default to undefined when the bind call carries more than one argument), and apply the same rule at every sibling receiver shape.

Why this was flagged

Trigger: --drop=app (or --drop=$, --drop=Mousetrap-style namespace entries) and a call app.keys.bind("ctrl+s", fn) reached through e_call at src/js_parser/visit/visit_expr.rs:2212. The guard at src/js_parser/visit/visit_expr.rs:1884 only requires bind.target to be an EDot; is_drop_key at :1901 is false unless the user also listed app.keys.bind; is_dropped_name at :1930 returns true as soon as the root app is a drop entry. The helper then emits () => {} at :1897. On the base the same call reached :2245 and became undefined. Code that stored the result and guarded it, const h = app.keys.bind(k, fn); if (h) h.unbind(), skipped the branch on the base and now calls unbind on a bare arrow, throwing TypeError. The PR text names this as a downside but sizes it as rare; jQuery-style $.fn.bind and hotkey libraries make bind a common method name under a dropped namespace.

Verification: Trigger: --drop=<ns> where <ns> has a nested object whose own method is literally named bind and the call's return value is used. At src/js_parser/visit/visit_expr.rs:1884 bind.target is an EDot; is_drop_key (:1901) is false with only app dropped; is_dropped_name (:1924) returns true; the call is rewritten to E::Arrow::NOOP_RETURN_UNDEFINED at :1897. On the base the same input became undefined.

|| Self::is_drop_key(p, call.target)
|| !Self::is_dropped_name(p, bind.target)
{
return false;
}
// The arguments are removed but still visited: the visit enters every scope in parse order.
let old_is_control_flow_dead = p.is_control_flow_dead;
p.is_control_flow_dead = true;
for arg in call.args.slice_mut() {
p.visit_expr(arg);
}
p.is_control_flow_dead = old_is_control_flow_dead;
*e = p.new_expr(E::Arrow::NOOP_RETURN_UNDEFINED, e.loc);
true
}
/// Whether `callee` spells a drop key. Unlike `is_dot_define_match`, `a?.b` counts as `a.b`.
fn is_drop_key(p: &Self, callee: Expr) -> bool {
let Data::EDot(last) = callee.data else {
return false;
};
let Some(keys) = p.define.dots_for(last.name.slice()) else {
return false;
};
keys.as_slice().iter().any(|key| {
let Some((root, members)) = key.parts.split_first() else {
return false;
};
let mut expr = callee;
for member in members.iter().rev() {
match expr.data {
Data::EDot(dot) if strings::eql(member, &dot.name) => expr = dot.target,
_ => return false,
}
}
let Data::EIdentifier(id) = expr.data else {
return false;
};
key.data.method_call_must_be_replaced_with_undefined()
&& strings::eql(p.load_name_from_ref(id.ref_), root)
})
Comment on lines +1899 to +1924

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users who list both a.b.c and a.b.c.bind in --drop still get () => {} instead of undefined when the call is spelled a["b"].c.bind(x). The flag is raised by is_dot_define_match (src/js_parser/p.rs:7280), which accepts a UTF-8 string EIndex as a dot part, but is_drop_key at src/js_parser/visit/visit_expr.rs:1911 only walks EDot nodes, so the full-callee drop key is missed and is_dropped_name then matches the a.b.c entry. Fix: make is_drop_key recognise every callee spelling that can raise the flag (string EIndex parts as well as EDot), e.g. by walking the same shapes is_dot_define_match walks, so the documented 'add <callee>.bind to the drop list to keep undefined' escape hatch holds for all spellings.

Why this was flagged

Input lib["log"].info.bind(x) built with drop: ["lib.log.info", "lib.log.info.bind"] and no minify-syntax, through bun build or Bun.build. Visiting the call target in e_dot (src/js_parser/visit/visit_expr.rs:1337-1357) matches lib.log.info.bind because is_dot_define_match treats lib["log"] as lib.log (src/js_parser/p.rs:7280-7291) and raises method_call_must_be_replaced_with_undefined. In e_call the new arm calls replace_bind_of_dropped_function; is_drop_key (src/js_parser/visit/visit_expr.rs:1899-1924) walks bind then info as EDot, reaches the EIndex lib["log"] and returns false, so the explicit lib.log.info.bind drop entry is not seen. is_dropped_name then matches lib.log.info via is_dot_define_match and the call is rewritten to () => {}. On the base branch the same input became undefined. The PR text says adding <callee>.bind to the drop list keeps undefined; that only holds for the pure dot spelling (and ?.), not for the bracket spelling the flag-setting matcher already accepts.

Verification: Trigger: --drop lists both a dropped name/method and its .bind spelling, the call uses bracket notation (lib["log"].info.bind(x)), and minify-syntax is off. is_drop_key (visit_expr.rs:1901-1925) walks the chain matching only Data::EDot, so the EIndex at lib["log"] makes it return false even though the full key is listed. Result: () => {} where base gives undefined.

}
/// Reads the drop list, not the flag alone: `import.meta.hot` raises it with no drop list.
fn is_dropped_name(p: &mut Self, mut expr: Expr) -> bool {
loop {
match expr.data {
Data::EDot(dot) => {
if Self::is_dropped_dot(p, expr) {
return true;
}
expr = dot.target;
}
Data::EIdentifier(id) => {
let name = p.load_name_from_ref(id.ref_);
return p.symbols[id.ref_.inner_index() as usize].kind
== js_ast::symbol::Kind::Unbound
&& !id.must_keep_due_to_with_stmt()
&& p.define.for_identifier(name).is_some_and(|define| {
define.method_call_must_be_replaced_with_undefined()
});
}
_ => return false,
}
}
}
fn is_dropped_dot(p: &mut Self, expr: Expr) -> bool {
let Data::EDot(dot) = expr.data else {
return false;
};
let defines = p.define;
let Some(candidates) = defines.dots_for(dot.name.slice()) else {
return false;
};
for define in candidates.as_slice() {
if p.is_dot_define_match(expr, &define.parts) {
return define.data.method_call_must_be_replaced_with_undefined();
}
}
false
}
fn e_call(p: &mut Self, e: &mut Expr, in_: ExprIn) {
let expr = *e;
let mut e_ = expr.data.e_call().expect("infallible: variant checked");
Expand Down Expand Up @@ -2117,6 +2209,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
if method_call_should_be_replaced_with_undefined {
p.method_call_must_be_replaced_with_undefined = false;
match &e_.target.data {
Data::EDot(dot)
if dot.name == b"bind" && Self::replace_bind_of_dropped_function(p, e) =>
{
p.options.ignore_dce_annotations = old_ce;
p.should_fold_typescript_constant_expressions =
old_should_fold_typescript_constant_expressions;
return;
}
// If we're removing this call, don't count any arguments as symbol uses
Data::EIndex(..) | Data::EDot(..) | Data::EIdentifier(..) => {
p.is_control_flow_dead = true;
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ bun_core::declare_scope!(cache, visible);
/// the cache-HIT path reinstates #33904 for them.
/// Version 34: An import that a plugin `onResolve` answers is printed as it is written.
/// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double.
const EXPECTED_VERSION: u32 = 35;
/// Version 36: `--drop` turns `<dropped>.method.bind(...)` into `() => {}`, not `undefined`.
const EXPECTED_VERSION: u32 = 36;

/// Source files smaller than this are not written to / read from the on-disk
/// transpiler cache. Originally 50 KiB, which excluded almost every file in a
Expand Down
188 changes: 188 additions & 0 deletions test/bundler/bundler_drop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,192 @@ describe("bundler", () => {
drop: ["ASSERT"],
backend: "api",
});

// `console.log.bind(console)` does not call console.log. It makes a function that the
// program calls later, so it must stay a function.
for (const [suffix, options] of [
["", { backend: "api" }],
["Cli", { backend: "cli" }],
["NoBundle", { bundling: false }],
["MinifySyntax", { minifySyntax: true }],
] as const) {
itBundled(`drop/BindStaysCallable${suffix}`, {
files: {
"/a.js": `
const out = text => process.stdout.write(text + "\\n");
globalThis.my = { logger() { out("FAIL"); } };
const log = console.log.bind(console);
log("FAIL");
const logger = { info: console.info.bind(console), warn: console.warn.bind(console) };
logger.info("FAIL");
logger.warn("FAIL");
class Logger {
log = console.log.bind(console);
}
new Logger().log("FAIL");
function withDefault(log = console.log.bind(console)) {
log("FAIL");
}
withDefault();
console.log.bind(console)("FAIL");
console.log.bind(console).bind(null)("FAIL");
console.log?.bind(console)("FAIL");
console?.log.bind(console)("FAIL");
console.log.bind?.(console)("FAIL");
["FAIL"].forEach(console.log.bind(console));
Promise.reject(new Error("FAIL")).catch(console.error.bind(console));
my.logger.bind(null)("FAIL");
Comment on lines +147 to +151

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): a maintainer gets a viaAlias check that prints "callable" with or without the fix, plus a comment that misdescribes the parser. m.logger.bind(null) at test/bundler/bundler_drop.test.ts:150 has a bound local m as its root, so e_identifier (src/js_parser/visit/visit_expr.rs:256) never sets the drop flag and the call is left as written. Single-use inlining (src/js_parser/visit/mod.rs:1963) swaps my in after the statement was visited; nothing is "visited again". Fix: either drop viaAlias or make it assert the negative contract it actually pins (an alias of a dropped name is not dropped), and reword the comment at test/bundler/bundler_drop.test.ts:143 so it does not claim a second visit.

Why this was flagged

The row out(viaAlias()) expects "callable" (test/bundler/bundler_drop.test.ts:160). viaAlias reads typeof m.logger.bind(null) where m is const m = my (test/bundler/bundler_drop.test.ts:148-150). Because m is a bound local, e_identifier's Unbound check at src/js_parser/visit/visit_expr.rs:256 never sets method_call_must_be_replaced_with_undefined, so e_call never reaches the undefined arm or the new bind arm. At run time my.logger is the real function from globalThis.my = { logger() {...} }, so typeof is "function" on the base branch and on this PR alike. The comment at test/bundler/bundler_drop.test.ts:143 says single-use locals are "inlined and visited again"; substitute_single_use_symbol_in_stmt (src/js_parser/visit/mod.rs:1963, src/js_parser/p.rs:2787) rewrites the already-visited AST and does not re-run the visitor, so the drop logic never sees the inlined form. The other rows in the same test (viaLocal, the bound calls) do fail on the base, so the test as a whole is live; only this row and its comment are vacuous.

Verification: The viaAlias row prints "callable" regardless of whether the .bind fix is present. m is a block-scoped const, so it is a bound symbol; src/js_parser/visit/visit_expr.rs:256-297 is gated on Kind::Unbound, so m.logger.bind(null) never reaches the drop branch of e_call and is emitted unchanged. The comment at test line 153 is wrong: nothing is visited again.

Bun.inspect.table.bind(null)("FAIL");
// With minifySyntax, and at run time, these single-use locals are inlined and visited again.
function viaAlias() {
const m = my;
return typeof m.logger.bind(null) === "function" ? "callable" : "broken";
}
function viaLocal() {
const log = console.log.bind(console);
return log("FAIL") || "called";
}
out(viaAlias());
out(viaLocal());
out(console.log.bind(console) ? "truthy" : "falsy");
out(typeof console.log.bind(console));
out(typeof console["log"].bind(console));
out(typeof console.log["bind"](console));
out("ALIVE");
`,
},
drop: ["console", "my.logger", "Bun.inspect"],
run: { stdout: "callable\ncalled\ntruthy\nfunction\nfunction\nfunction\nALIVE" },
...options,
});
}
itBundled("drop/BindBecomesEmptyFunction", {
files: {
"/a.js": `export const log = console.log.bind(console);`,
},
drop: ["console"],
bundling: false,
onAfterBundle(api) {
api.expectFile("out.js").toContain("log = () => {}");
},
});
// A dropped call loses its arguments, and so does `.bind`: `ASSERT` does not exist at run
// time, `label.cjs` must not be bundled, and the scopes in the arguments are still visited.
itBundled("drop/BindArgumentsAreRemoved", {
files: {
"/a.js": `
const out = value => process.stdout.write(value + "\\n");
const fail = () => out("FAIL");
const v = 2;
const check = ASSERT.ok.bind(ASSERT, fail());
check(false);
console.log.bind(console, require("./label.cjs"))("FAIL");
console.log.bind(console, v => v, function f(b) { return b; }, class K { m() {} });
class After { value() { return v; } }
const after = () => new After().value();
out(after());
out("ALIVE");
`,
"/label.cjs": `
process.stdout.write("LABEL\\n");
module.exports = "[app]";
`,
},
drop: ["console", "ASSERT"],
run: { stdout: "2\nALIVE" },
onAfterBundle(api) {
api.expectFile("out.js").not.toContain("LABEL");
},
});
// These call the dropped function, or a method of the dropped name, so they stay `undefined`.
// `app.keys.bind` and `lib.hot.keys.bind` are on the drop list, and so are `app` and `lib.hot`.
itBundled("drop/BindKeepsPlainCallsDropped", {
files: {
"/a.js": `
const out = value => process.stdout.write(value + "\\n");
const fail = () => out("FAIL");
out(console.log.call(console, fail()));
out(console.log.apply(console, [fail()]));
out(console.log.bind.call(console.log, fail()));
out(console.bind(fail()));
out(Mousetrap.bind("ctrl+s", fail()));
out(hotkeys.bind("ctrl+s", fail()));
out(app.keys.bind("ctrl+s", fail()));
out(app.keys.bind?.("ctrl+s", fail()));
out(app.keys?.bind("ctrl+s", fail()));
out(app?.keys.bind("ctrl+s", fail()));
out(app?.keys?.bind?.("ctrl+s", fail()));
out(lib.hot.keys.bind("ctrl+s", fail()));
`,
},
drop: ["console", "Mousetrap", "hotkeys.bind", "app", "app.keys.bind", "lib.hot", "lib.hot.keys.bind"],
run: { stdout: Array(12).fill("undefined").join("\n") },
});
// One row for each spelling of a callee: "kept" when `callee(arg())` is not dropped, else
// `typeof callee.bind(null)`. The third column is the value with `minifySyntax`, where
// `a["b"]` is `a.b`.
const bindOfDroppedCallee: [callee: string, expected: string, minified?: string][] = [
["debug.log", "function"],
["debug?.log", "function"],
["(debug.log)", "function"],
["debug.a.b", "function"],
['debug["log"]', "kept", "function"],
["debug[key]", "kept"],
["(0, debug.log)", "kept"],
["globalThis.debug.log", "kept"],
["debug.log.call", "function"],
["debug.log.bind", "function"],
['debug.log["bind"]', "kept", "function"],
["ASSERT.ok", "function"],
["my.logger", "function"],
["my?.logger", "kept"],
['my["logger"]', "kept", "function"],
["my.logger.info", "function"],
['my["logger"].info', "kept", "function"],
['lib["log"].info', "function"],
// `ASSERT.bind(null)` looks the same as `Mousetrap.bind(key, fn)`: a call of a dropped method.
["ASSERT", "undefined"],
// `debug.a.bind` is on the drop list.
["debug.a", "undefined"],
// Not a `drop` name: the HMR API without HMR.
["import.meta.hot.accept", "undefined"],
// The define renames `renamed` after the drop key matched it.
["renamed.log", "undefined"],
];
for (const minifySyntax of [false, true]) {
itBundled(`drop/BindOfEachDroppedCallee${minifySyntax ? "MinifySyntax" : ""}`, {
files: {
"/a.js": `
const fn = () => {};
const namespace = { log: fn, info: fn, ok: fn, a: Object.assign(() => {}, { b: fn }) };
Object.assign(globalThis, {
key: "log",
debug: namespace,
other: namespace,
ASSERT: Object.assign(() => {}, namespace),
my: { logger: Object.assign(() => {}, namespace) },
lib: { log: namespace },
});
let calls = 0;
const arg = () => calls++;
${bindOfDroppedCallee
.map(
([callee]) => `
calls = 0;
${callee}(arg());
process.stdout.write(${JSON.stringify(callee)} + ": " + (calls ? "kept" : typeof ${callee}.bind(null)) + "\\n");`,
)
.join("")}
`,
},
drop: ["debug", "debug.a.bind", "ASSERT", "my.logger", "lib.log.info", "renamed.log"],
define: { renamed: "other" },
minifySyntax,
run: {
stdout: bindOfDroppedCallee
.map(([callee, expected, minified]) => `${callee}: ${(minifySyntax && minified) || expected}`)
.join("\n"),
},
});
}
});
14 changes: 14 additions & 0 deletions test/cli/run/transpiler-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,20 @@ describe("transpiler cache", () => {
expect(run(temp_dir, ["a.js"])).toBe("logged\nwritten");
expect(newCacheCount()).toBe(0);
});

test("--drop keeps a bound console method callable", () => {
writeFileSync(
join(temp_dir, "a.js"),
`const log = console.log.bind(console);\nlog("logged");\nprocess.stdout.write(typeof log + "\\n");` +
"\n//" +
filler,
);

expect(run(temp_dir, ["a.js"])).toBe("logged\nfunction");
// Transpiled on the first run, served from the cache on the second.
expect(run(temp_dir, ["--drop=console", "a.js"])).toBe("function");
expect(run(temp_dir, ["--drop=console", "a.js"])).toBe("function");
Comment on lines +467 to +469

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): the new cache test cannot tell a cache hit from a re-transpile, so the EXPECTED_VERSION bump it accompanies is not exercised. The comment at test/cli/run/transpiler-cache.test.ts:467 says the second run is served from the cache, but no newCacheCount() assertion backs it, unlike the sibling test at test/cli/run/transpiler-cache.test.ts:445-455. Fix: assert newCacheCount() after each run (1 after the first write, 0 after the --drop=console runs) so the test follows the file's convention and the cache-served claim can fail.

Why this was flagged

The test at test/cli/run/transpiler-cache.test.ts:458-470 runs the file three times and only compares stdout. Nothing in it reads the cache directory, so the test passes identically whether the second --drop=console run reads the cached entry or transpiles again, and it passes if the transpiler cache were disabled entirely. Every neighbouring test in this describe block (for example test/cli/run/transpiler-cache.test.ts:446, :449, :452, :455) pairs each run with expect(newCacheCount()).toBe(...). The comment on line 467 therefore states something the test does not verify. Consequence is test-quality only: no runtime behaviour differs from the base branch.

Verification: nit. Triggering condition: whenever the new test runs, it exercises only stdout, so it passes with the on-disk cache engaged, bypassed, or disabled. At test/cli/run/transpiler-cache.test.ts:458-470 the three run(...) calls are wrapped in expect(...).toBe(...) on stdout only; nothing in the test reads cache_dir (no newCacheCount()), so the comment at :467 is not backed by any assertion.

});
});

// Serving the entry point from the cache must not change how the modules it
Expand Down
Loading