Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6df0a85
js_parser: don't inline single-use anonymous fn/arrow/class initializers
robobun Jul 29, 2026
986958a
test: reference #20398 and #22770, cover array-literal substitution path
robobun Jul 29, 2026
872aca5
review: shorten NamedEvaluation comment
robobun Jul 29, 2026
b2411a3
test: mark FunctionHoistingKeepNames3/4 todo (pre-existing --keep-nam…
robobun Jul 29, 2026
d46d625
ci: retrigger
robobun Jul 29, 2026
a331541
test: link #36320 from FunctionHoistingKeepNames todo comments
robobun Jul 29, 2026
71a08f7
Merge remote-tracking branch 'origin/main' into farm/c47e5de4/fix-inl…
robobun Nov 1, 2026
25dd207
js_parser: keep the declaration that names a single-use function when…
robobun Nov 1, 2026
f487910
test(aot): read block functions once, and update four stored frame co…
robobun Nov 1, 2026
5d93520
Merge remote-tracking branch 'origin/main' into farm/c47e5de4/fix-inl…
robobun Nov 1, 2026
2cca0a3
Sweep what was built around the single-use inliner
robobun Nov 1, 2026
0691f3d
test: leave the printed text of a fold and of a block function to the…
robobun Nov 1, 2026
543dded
Merge remote-tracking branch 'origin/main' into farm/c47e5de4/fix-inl…
robobun Nov 8, 2026
05c34e9
test: show the moved function in bun pm diff from runtime-transpiler.…
robobun Nov 8, 2026
b066c2a
aot: name a function that a let or const names and the next return reads
robobun Nov 8, 2026
39810c3
aot: leave the value of a let or const of Bun's own modules in the re…
robobun Nov 8, 2026
4287928
aot: name a returned function in Bun's own modules too, and hold each…
robobun Nov 8, 2026
5bd0d7f
Merge remote-tracking branch 'origin/main' into farm/c47e5de4/fix-inl…
robobun Nov 11, 2026
60e8b13
test: the class of describe(MyClass) has its name under bun test too
robobun Nov 11, 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
21 changes: 18 additions & 3 deletions src/aot/lower/statement/declarations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use bun_sema::atom::{Atom, known};
use bun_sema::bind::{Binding, BindingStability, Decl, SymbolId};
use bun_sema::fold::{TreeNode, push_children};
use bun_sema::hir::{
ClassId, ExprId, ExprKind, Flags, FnBody, FnId, IdList, NodeData, PatId, PatKind, StmtId,
StmtKind, VarDeclId, VarKind,
ClassId, ExprId, ExprKind, Flags, FnBody, FnId, FnKind, IdList, NodeData, PatId, PatKind,
StmtId, StmtKind, VarDeclId, VarKind,
};
use bun_sema::program::Sym;
use smallvec::SmallVec;
Expand Down Expand Up @@ -39,6 +39,21 @@ impl FileLowering<'_, '_, '_, '_> {
}
}

fn is_named_by_its_declaration(&mut self, initializer: ExprId) -> bool {
let hir = self.hir;
match hir[initializer].kind {
ExprKind::Fn(function) => match hir[function].kind {
FnKind::Arrow => true,
FnKind::Expr => hir[function].name.is_none(),
_ => false,
},
ExprKind::Class(class) => {
hir[class].name.is_none() || self.has_standard_class_decorators(class)
}
_ => false,
}
}

fn find_printed_statement(&self, list: IdList<StmtId>) -> Option<(StmtId, IdList<StmtId>)> {
let hir = self.hir;
let index = hir.ids(list).position(|statement| {
Expand Down Expand Up @@ -98,7 +113,7 @@ impl FileLowering<'_, '_, '_, '_> {
let (hir, bound) = (self.hir, self.bound);
let initializer = self.skip_type_syntax(hir[first].init);
if matches!(hir[initializer].kind, ExprKind::Ident(_))
|| self.is_class_named_by_its_declaration(initializer)
|| self.is_named_by_its_declaration(initializer)
{
return None;
}
Expand Down
4 changes: 4 additions & 0 deletions src/ast/symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ pub struct Symbol {
bitflags::bitflags! {
#[derive(Copy, Clone, Eq, PartialEq, Default, Debug)]
pub struct SymbolFlags: u8 {
/// The initializer is not an anonymous function or class as written, as in `let f = (0, () => {})`: the declaration gives no `name`.
const INITIALIZER_NOT_NAMED_BY_DECL = 1 << 0;

const MUST_START_WITH_CAPITAL_LETTER_FOR_JSX = 1 << 1;

/// Certain symbols must not be renamed or minified. For example, the
Expand Down Expand Up @@ -152,6 +155,7 @@ macro_rules! symbol_flag_accessors {
}

symbol_flag_accessors! {
initializer_not_named_by_decl, set_initializer_not_named_by_decl => INITIALIZER_NOT_NAMED_BY_DECL;
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;
Expand Down
7 changes: 7 additions & 0 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3439,6 +3439,13 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
self.options.bundle || self.options.features.minify_identifiers
}

/// Whether the `let` or `const` of `symbol` gives `value` its `name` and has to stay. A bundle or `minify_identifiers` moves the value, as esbuild does.
pub(crate) fn declaration_names_value(&self, symbol: &Symbol, value: &Expr) -> bool {
value.is_anonymous_named()
&& !symbol.initializer_not_named_by_decl()
&& !self.will_use_renamer()
}
Comment thread
robobun marked this conversation as resolved.
Resolved

fn hoist_symbols(&mut self, mut scope: js_ast::StoreRef<js_ast::Scope>) {
// This runs before the visit pass, so it walks the scope tree at the full
// nesting depth the parser allowed; deep trees must error here instead of
Expand Down
9 changes: 6 additions & 3 deletions src/js_parser/visit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,10 +733,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
} else {
self.vis_scope().is_after_const_local_prefix = true;
}
let symbol = &mut self.symbols[id_ref.inner_index() as usize];
if !was_anonymous_named_expr {
Comment thread
robobun marked this conversation as resolved.
Resolved
symbol.set_initializer_not_named_by_decl(true);
}
// SAFETY: original_name is arena-owned, valid for 'a.
let original_name: &'a [u8] = self.symbols[id_ref.inner_index() as usize]
.original_name
.slice();
let original_name: &'a [u8] = symbol.original_name.slice();
decl.value = Some(self.maybe_keep_expr_symbol_name(
decl.value.unwrap(),
original_name,
Expand Down Expand Up @@ -2006,6 +2008,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
// fail if something with side effects is in between the declaration
// and the usage.
if symbol.use_count_estimate == 1
&& !p.declaration_names_value(symbol, &replacement)
&& !p.keeps_frame_for_call(&replacement)
&& p.substitute_single_use_symbol_in_stmt(stmt, id, replacement)
{
Expand Down
3 changes: 2 additions & 1 deletion src/jsc/RuntimeTranspilerCache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ bun_core::declare_scope!(cache, visible);
/// Version 50: The body of a class with lowered class decorators names the class by the
/// binding that holds what the decorators return.
/// Version 51: A `bun:` specifier that names no builtin is printed as it is written.
const EXPECTED_VERSION: u32 = 51;
/// Version 52: A `let` or `const` that names an anonymous function or class is not moved into its only use.
const EXPECTED_VERSION: u32 = 52;

/// 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
6 changes: 1 addition & 5 deletions test/aot/fuzz/function-name-length-own-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ function attempt(label, run) {
note(label + ":", "THROWS", error.constructor.name);
}
}
function keep() {}
// Plain Bun settles an operator whose left side is a constant, and the function behind it gets a name: so these are variables.
let nothing = null,
zero = 0,
Expand Down Expand Up @@ -175,8 +174,5 @@ export function generate(random: Random): string {
`attempt("made twice", () => {\nconst make = () => ${unnamed().startsWith("class") ? "class {}" : "function () {}"};\nconst first = make();\nObject.defineProperty(first, "name", { value: "the first" });\nreturn make();\n});`,
];
const rows = Array.from({ length: random.between(14, 30) }, () => random.pick(kinds)());
// Plain Bun inlines a `let` or `const` that is read once, in the next statement, and then the function gets no name,
// so a call is placed in between.
const text = rows.join("\n").replace(/;\n(return given|return new Proxy\(given)/g, ";\nkeep();\n$1");
return `${head}${text}\nconsole.log(lines.join("\\n"));\n`;
return `${head}${rows.join("\n")}\nconsole.log(lines.join("\\n"));\n`;
}
2 changes: 1 addition & 1 deletion test/aot/fuzz/fuzzilli/BunCallbacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ extension ProgramBuilder {

fileprivate func buildCallThatCallsBack(_ counter: Variable, _ objects: [Variable]) {
let call = chooseUniform(from: callsThatCallBack)
// A function that is declared in a block has another name under `bun run`, so the arguments stand in front of the try block.
// A function that is declared in a block has another text under `bun run`, so the arguments stand in front of the try block.
let arguments = call.arguments.map { buildArgument($0, counter, objects) }
let receiver = ["", "new"].contains(call.receiver) ? createNamedVariable(forBuiltin: call.name) : buildReceiver(call.receiver, objects)
printOutcome(of: call.name, counter) {
Expand Down
27 changes: 0 additions & 27 deletions test/aot/fuzz/fuzzilli/run-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,31 +946,6 @@ async function stoppedAtAnUnusedReadBeforeTheDeclaration(
return agree(ran.get(0)!, reference);
}

/**
* Whether the two runs print the same but for the names of functions that the program declares in blocks, and of classes and functions in constants that are used once.
* `bun run` makes a variable with a function expression of such a declaration, and puts such a constant in place of its use. Neither has a name of its own then.
*/
async function agreesButForFunctionsDeclaredInBlocks(
folder: string,
extension: string,
text: string,
compiled: Ended,
reference: Ended,
) {
layOut(folder, [text], extension, 1);
const rewritten = await spawn("rewrite", [bun, "build", "--no-bundle", `./programs/0${extension}`], folder, {}, 30);
const names = [...rewritten.stdout.matchAll(/\blet ([\w$]+) = (?:async )?function\b/g)]
.map(([, name]) => name.replaceAll("$", "\\$"))
.filter(name => new RegExp(`\\bfunction\\*? ?${name}\\b`).test(text));
for (const [, name] of text.matchAll(/\bconst ([\w$]+) = (?:class|(?:async )?function)\b/g))
if (text.split(new RegExp(`\\b${name.replaceAll("$", "\\$")}\\b`)).length === 3)
names.push(name.replaceAll("$", "\\$"));
if (names.length === 0 || compiled.end !== reference.end) return false;
const withoutThem = (output: string) =>
removeDetailsOfErrors(output).replace(new RegExp(`\\b(?:${names.join("|")})\\b ?`, "g"), "");
return withoutThem(compiled.stdout) === withoutThem(reference.stdout);
}

/** Where the bracket that opens at `open` closes, or -1. Strings are passed over. */
function closingBracket(text: string, open: number) {
let depth = 0;
Expand Down Expand Up @@ -2197,8 +2172,6 @@ for (
kind = "documented difference";
else if (await stoppedAtAnUnusedReadBeforeTheDeclaration(alone, extension, texts[index], said, referenceAgain))
kind = "documented difference";
else if (await agreesButForFunctionsDeclaredInBlocks(alone, extension, texts[index], second, referenceAgain))
kind = "documented difference";
rmSync(alone, { recursive: true, force: true });
}
add(kind);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ export function generate(random: Random): string {
"for (let i = 0; i < 300; i++) new Lowest();",
'told(() => "greet" in first);',
]);
// Each scene has names of its own: a bundler renames what two blocks declare alike, and the names are printed. The
// function is named twice at least, or plain Bun puts it where its one use stands and it takes that variable's name.
// Each scene has names of its own: a bundler renames what two blocks declare alike, and the names are printed.
const text = `{
${random.pick(lowers)}
kept.push(typeof Emitter);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
// CHECK: Switch {{*}}cases=[0, 1] -> case {{*}}, case [[entered]](| 0),
// CHECK: [[entered]]({{*}}):
// CHECK: = NewClosure {{*}}function=[[read]]
// CHECK: function [[read]] {{*}}kind=Arrow
// CHECK: function [[read]] name="read" {{*}}kind=Arrow
// CHECK-NOT: Return
// CHECK: = CheckInitialized {{*}}name="x"

// A `switch` that enters at `case 1` has not run the declaration in `case 0`, so the closure tests `x`. `bun run` prints "set" in place of the name.
// `const read = ..; return read;` runs as `return ..;`, so the arrow has no name: it is found by the closure that `case 1` makes.
export function pick(n) {
switch (n) {
case 0:
Expand Down
28 changes: 26 additions & 2 deletions test/aot/ir/lower/tail-calls/named-once.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@
// CHECK-LABEL: function {{*}}name="notTheLast"
// CHECK-NOT: ReturnCall
// CHECK: Return [[first]]
// CHECK-LABEL: function {{*}}name="returnsArrow"
// CHECK: NewClosure function=[[arrowOfLet]]
// CHECK-LABEL: function {{*}}name="returnsClass"
// CHECK: NewClosure function=[[classOfConst]]
// CHECK-LABEL: function {{*}}name="returnsFunction"
// CHECK: NewClosure function=[[ofConst]]
// CHECK-LABEL: function {{*}}name="returnsFunctionOfBranch"
// CHECK: NewClosure function=[[unnamed]]
// CHECK-LABEL: function {{*}}name="returnsFunctionOfVar"
// CHECK: NewClosure function=[[named]]
// CHECK: NewClosure function=[[ofVar]]
// CHECK: function [[arrowOfLet]] name="pointed"
// CHECK: function [[classOfConst]] name="Shape"
// CHECK: function [[ofConst]] name="made"
// CHECK: function [[unnamed]] module=
// CHECK: function [[named]] name="made"
// CHECK: function [[ofVar]] name="made"

declare function call(n: number): unknown;
declare function loose(n: number): any;
Expand Down Expand Up @@ -53,6 +62,21 @@ export function returnsFunction() {
return made;
}

export function returnsArrow() {
let pointed = () => {};
return pointed;
}

export function returnsClass() {
const Shape = class {};
return Shape;
}

export function returnsFunctionOfBranch(n: number) {
const made = n ? function () {} : null;
return made;
}

export function returnsFunctionOfVar() {
var made = function () {};
return made;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,7 @@ const computedKey = ["com", "puted"].join("");
let ticks = 0;
const capture = make => make(++ticks, ++ticks, ++ticks, ++ticks, ++ticks, ++ticks);

// Every call creates a new function object. One that is named after a variable is used twice: plain Bun's transpiler puts a constant that is used once in the place of its use, where the function gets no name.
let lastMade;
// Every call creates a new function object.
const kinds = {
"a function that is only called": () => function called() {},
"a function that is constructed": () => {
Expand All @@ -160,25 +159,21 @@ const kinds = {
"a function with six captures": () => capture((a, b, c, d, e, g) => function summed() { return a + b + c + d + e + g; }),
"a function expression in a variable": () => {
const inferred = function () {};
lastMade = inferred;
return inferred;
},
"a function without a name": () => function () {},
"an arrow function in a variable": () => {
const pointed = () => {};
lastMade = pointed;
return pointed;
},
"an arrow function with six captures": () =>
capture((a, b, c, d, e, g) => {
const adding = () => a + b + c + d + e + g;
lastMade = adding;
return adding;
}),
"a generator": () => function* yielding() {},
"a generator in a variable": () => {
const stepping = function* () {};
lastMade = stepping;
return stepping;
},
"a generator without a name": () => function* () {},
Expand All @@ -199,7 +194,6 @@ const kinds = {
"a class": () => class Circle {},
"a class in a variable": () => {
const Square = class {};
lastMade = Square;
return Square;
},
"a class with a static field called name": () => class Labeled { static name = "given"; },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Prints the names of functions, arrows and classes that a `let` or `const` names through type syntax and that are read once: each has the name of its variable.
type Run = () => void;
const row = (label: string, run: () => { name: string }) => console.log(label + ":", JSON.stringify(run().name));

row("an assertion", () => { const q = (() => {}) as Run; return q; });
row("an assertion around a function", () => { const q = function () {} as Run; return q; });
row("an annotation", () => { const q: Run = () => {}; return q; });
row("satisfies", () => { const q = (() => {}) satisfies Run; return q; });
row("a non-null assertion", () => { const q = (() => {})!; return q; });
row("an assertion in angle brackets", () => { const q = <Run>(() => {}); return q; });
row("an assertion in the return", () => { const q = () => {}; return q as Run; });
row("an assertion in a second variable", () => { const a = () => {}; const b = a as Run; return b; });
row("an assertion around a class", () => { const Q = class {} as { name: string }; return Q; });
row("type parameters", () => { const q = <T,>(x: T) => x; return q; });
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Prints the names of functions, arrows and classes that a `let` or `const` names and that are read once, and of functions that a block declares: each has the name of its variable, also in a stack trace.
const nameOf = f => f.name;
const frame = e => e.stack.split("\n")[1].match(/at (?:new )?(\S+) \(/)?.[1];
const row = (label, run) => {
let value;
try {
value = run();
} catch (error) {
value = "throws " + error.name;
}
console.log(label + ":", JSON.stringify(value));
};

row("an arrow", () => { const q = () => {}; return q.name; });
row("a function", () => { let q = function () {}; return q.name; });
row("a class", () => { const Q = class {}; return Q.name; });
row("an async arrow", () => { const q = async () => {}; return q.name; });
row("a generator", () => { const q = function* () {}; return q.name; });
row("an argument", () => { const q = () => {}; return nameOf(q); });
row("a frame", () => { const q = () => frame(new Error()); const top = q(); return top; });
row("an instance", () => { const Q = class {}; return new Q().constructor.name; });
row("a bound function", () => { const q = () => {}; return q.bind(null).name; });

// The `return` behind the declaration reads the variable and nothing else.
row("a returned function", () => nameOf((() => { const q = function () {}; return q; })()));
row("a returned arrow", () => nameOf((() => { let q = () => {}; return q; })()));
row("a returned class", () => nameOf((() => { const Q = class {}; return Q; })()));
row("a returned function behind a second variable", () => nameOf((() => { const a = function () {}; const b = a; return b; })()));
row("a returned function of a branch", () => nameOf((() => { const q = row ? function () {} : 0; return q; })()));

// A read in a place that names a function gives no second name.
row("a property", () => { const q = () => {}; const o = { k: q }; return o.k.name; });
row("an assignment", () => { let sink; const q = () => {}; sink = q; return sink.name; });
row("a second declaration", () => { const a = () => {}; const b = a; return b.name + b.name; });
row("an operand", () => { const q = () => {}; const w = q ?? 1; return w.name + w.name; });
row("an element", () => { const f = () => 0; const a = [f]; return a[0].name; });

row("a function in a block", () => { { function q() {} return q.name; } });
row("a function in an arm of if", () => { if (row) { function q() {} return nameOf(q); } });
row("a frame of a function in a block", () => { { function q() { return frame(new Error()); } const top = q(); return top; } });
row("a function in a clause", () => { switch (1) { case 1: function q() {} return q.name; } });

row("a function with its own name", () => { const q = function named() {}; return q.name; });
3 changes: 0 additions & 3 deletions test/aot/the-suites-of-the-test-runner-run-compiled.known.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,3 @@ bun test: (pass) name property is used for function calls in Bun.inspect
bun test: (pass) name property is used for function calls in Error.stack
bun test --aot: (fail) name property is used for function calls in Bun.inspect
bun test --aot: (fail) name property is used for function calls in Error.stack
# describe.test.ts: `const MyClass = class {}` in a function, used once. The transpiler of `bun test` lays the class into its use, where it gets no name. Compiled it is called MyClass, as the language says.
bun test: (pass) blocks should handle a number, string, anonymous class, named class, or function for the first arg > Should also pass
bun test --aot: (pass) blocks should handle a number, string, anonymous class, named class, or function for the first arg > MyClass > Should also pass
14 changes: 14 additions & 0 deletions test/bundler/bundler_minify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ describe("bundler", () => {
minifySyntax: true,
run: { stdout: "Controller Model" },
});
// A bundle still moves a single-use function, as esbuild does, so it has no name there: https://github.com/oven-sh/bun/pull/40503
itBundled("minify/SingleUseFunctionMovesToItsUse", {
files: {
"/entry.js": /* js */ `
export function arrow() { const q = () => {}; return q.name; }
export function block() { { function q() {} return q.name; } }
`,
},
minifySyntax: true,
onAfterBundle(api) {
api.expectFile("/out.js").toContain("return (() => {}).name;");
api.expectFile("/out.js").toContain("return function() {}.name;");
},
});
itBundled("minify/TemplateStringFolding", {
files: {
"/entry.js": /* js */ `
Expand Down
Loading
Loading