From 6df0a856d440c44681d55334973092e7ca85693f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:36:44 +0000 Subject: [PATCH 01/15] js_parser: don't inline single-use anonymous fn/arrow/class initializers A let/const binding with an anonymous function, arrow or class expression as its initializer is a NamedEvaluation context: the value receives the binding name as its ".name" property. The single-use inlining pass was substituting such an initializer into its sole use site, which drops that name and makes it observably empty. This is always on at runtime (target=bun implies minify_syntax), and the block-level function-declaration lowering produces exactly this shape, so "{ function f() {} ... }" was losing f.name whenever f was referenced exactly once. First observed as performance.timerify() emitting entries with an empty name for a block-scoped function declaration. --- src/js_parser/visit/mod.rs | 11 ++ ...ranspiler-inline-anonymous-fn-name.test.ts | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 1d38925946ab..7ff159d5a0b6 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1637,6 +1637,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let last: &mut Decl = &mut local.decls.slice_mut()[last_idx]; let Some(replacement) = last.value else { break }; + // The declaration site is a NamedEvaluation context: an anonymous + // function/arrow/class initializer picks up the binding name as its + // ".name". Substituting it into the use site drops that and makes the + // name observably "". The block-level function-declaration lowering + // above produces exactly this shape ("let f = function() {}"), so a + // "{ function f() {} }" in source would otherwise lose its name at + // runtime when used once. + if replacement.is_anonymous_named() { + break; + } + // The binding must be an identifier that is only used once. // Ignore destructuring bindings since that's not the simple case. // Destructuring bindings could potentially execute side-effecting diff --git a/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts b/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts new file mode 100644 index 000000000000..e83d604a1183 --- /dev/null +++ b/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// The runtime transpiler runs with minify_syntax enabled (target=bun implies +// tree_shaking, which implies inlining, which implies minify_syntax). Its +// single-use-let inlining previously substituted an anonymous +// function/arrow/class initializer into the use site, dropping the ".name" it +// would have received via NamedEvaluation at the declaration. +// +// Block-level function declarations are lowered to exactly that shape +// ("let f = function() {}"), so "{ function f() {} ... }" lost its name at +// runtime when f was referenced exactly once. This was first observed as +// performance.timerify() emitting entries with an empty name. +// +// These tests spawn a fresh bun process so the fixture goes through the +// real runtime transpiler rather than this test file's own transpile. + +test("single-use let of an anonymous function keeps its .name at runtime", async () => { + const src = ` + function t1() { + let fn = function () {}; + return fn.name; + } + function t2() { + const arrow = () => {}; + return arrow.name; + } + function t3() { + let Cls = class {}; + return Cls.name; + } + function t4() { + // named function expression: inlining is fine, name is intrinsic + let fn = function named() {}; + return fn.name; + } + function t5() { + // non-function initializer still inlines + let n = 42; + return n + 1; + } + console.log(JSON.stringify([t1(), t2(), t3(), t4(), t5()])); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ + out: ["fn", "arrow", "Cls", "named", 43], + stderr: "", + exitCode: 0, + }); +}); + +test("block-level function declaration keeps its .name at runtime", async () => { + using dir = tempDir("block-fn-name", { + // .mjs so the block-level function declaration is a strict-mode let, + // which is the shape the lowering rewrites to "let f = function() {}". + "entry.mjs": ` + let a, b; + { + function slow() { return 42; } + a = slow.name; + } + { + function work() {} + const wrapped = work; + b = wrapped.name; + } + console.log(JSON.stringify([a, b])); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ + out: ["slow", "work"], + stderr: "", + exitCode: 0, + }); +}); + +test("Bun.Transpiler minify.syntax does not inline anonymous function into its single use", () => { + const transpiler = new Bun.Transpiler({ loader: "js", minify: { syntax: true } }); + const out = transpiler.transformSync(` + function outer() { + let fn = function () {}; + return fn.name; + } + `); + // The declaration must survive so JSC's NamedEvaluation assigns "fn". + expect(out).toContain("let fn = function"); + expect(out).not.toContain("return function"); +}); From 986958a9def4ea73cd9d9d6ba129f25f1ed0e772 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:41:27 +0000 Subject: [PATCH 02/15] test: reference #20398 and #22770, cover array-literal substitution path --- .../transpiler-inline-anonymous-fn-name.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts b/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts index e83d604a1183..bd20db797535 100644 --- a/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts +++ b/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts @@ -6,6 +6,8 @@ import { bunEnv, bunExe, tempDir } from "harness"; // single-use-let inlining previously substituted an anonymous // function/arrow/class initializer into the use site, dropping the ".name" it // would have received via NamedEvaluation at the declaration. +// https://github.com/oven-sh/bun/issues/20398 +// https://github.com/oven-sh/bun/issues/22770 // // Block-level function declarations are lowered to exactly that shape // ("let f = function() {}"), so "{ function f() {} ... }" lost its name at @@ -39,7 +41,13 @@ test("single-use let of an anonymous function keeps its .name at runtime", async let n = 42; return n + 1; } - console.log(JSON.stringify([t1(), t2(), t3(), t4(), t5()])); + function t6() { + // issue #20398: substituted into an array literal + const f = () => 0; + const a = [f]; + return a[0].name; + } + console.log(JSON.stringify([t1(), t2(), t3(), t4(), t5(), t6()])); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], @@ -50,7 +58,7 @@ test("single-use let of an anonymous function keeps its .name at runtime", async const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ - out: ["fn", "arrow", "Cls", "named", 43], + out: ["fn", "arrow", "Cls", "named", 43, "f"], stderr: "", exitCode: 0, }); From 872aca58066538545b177b4c3cef6e8429075f30 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:42:10 +0000 Subject: [PATCH 03/15] review: shorten NamedEvaluation comment --- src/js_parser/visit/mod.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 7ff159d5a0b6..a76b9f80641e 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1637,13 +1637,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let last: &mut Decl = &mut local.decls.slice_mut()[last_idx]; let Some(replacement) = last.value else { break }; - // The declaration site is a NamedEvaluation context: an anonymous - // function/arrow/class initializer picks up the binding name as its - // ".name". Substituting it into the use site drops that and makes the - // name observably "". The block-level function-declaration lowering - // above produces exactly this shape ("let f = function() {}"), so a - // "{ function f() {} }" in source would otherwise lose its name at - // runtime when used once. + // "let f = () => {}" gives the value ".name" via NamedEvaluation; + // substituting it away would make the name observably "". if replacement.is_anonymous_named() { break; } From b2411a3ac630c923a80b84f7da528738a9f4bd2a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:59:12 +0000 Subject: [PATCH 04/15] test: mark FunctionHoistingKeepNames3/4 todo (pre-existing --keep-names gap) --keep-names does not emit __name() for the block-level function declaration lowering, so the bundled output gives f.name === "f2" (node agrees on the bundled output). These two tests only passed before because the runtime transpiler inlined the single-use let back into the var assignment, which re-applied NamedEvaluation with the original name. Same gap FunctionHoistingKeepNames1/2 were already marked todo for. --- test/bundler/esbuild/extra.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 9354073c7229..49ca5fba180d 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -1663,6 +1663,11 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames3`, { + // --keep-names does not yet emit __name() for the block-level function + // lowering, so the bundled output reads f.name as the renamed "f2" (node + // agrees). This previously passed by accident because the runtime + // transpiler inlined the single-use let back into the var assignment. + todo: true, files: { "in.ts": ` if (1) { @@ -1682,6 +1687,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames4`, { + todo: true, // same --keep-names gap as FunctionHoistingKeepNames3 files: { "in.ts": ` if (1) { From d46d6259e92ba402c80f62f46fc74133b2e47082 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:16:35 +0000 Subject: [PATCH 05/15] ci: retrigger From a3315419b31ecc52e1572b612ac5e276757df389 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:29:00 +0000 Subject: [PATCH 06/15] test: link #36320 from FunctionHoistingKeepNames todo comments --- test/bundler/esbuild/extra.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 49ca5fba180d..d57697b2740e 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -1639,7 +1639,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames1`, { - todo: true, // keepNames requires Object.defineProperty implementation + todo: true, // https://github.com/oven-sh/bun/issues/36320 files: { "in.js": ` var f @@ -1651,7 +1651,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames2`, { - todo: true, // keepNames requires Object.defineProperty implementation + todo: true, // https://github.com/oven-sh/bun/issues/36320 files: { "in.js": ` var f @@ -1663,10 +1663,11 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames3`, { - // --keep-names does not yet emit __name() for the block-level function - // lowering, so the bundled output reads f.name as the renamed "f2" (node - // agrees). This previously passed by accident because the runtime - // transpiler inlined the single-use let back into the var assignment. + // https://github.com/oven-sh/bun/issues/36320: --keep-names does not yet + // emit __name() for the block-level function lowering, so the bundled + // output reads f.name as the renamed "f2" (node agrees). This previously + // passed by accident because the runtime transpiler inlined the + // single-use let back into the var assignment. todo: true, files: { "in.ts": ` @@ -1687,7 +1688,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames4`, { - todo: true, // same --keep-names gap as FunctionHoistingKeepNames3 + todo: true, // https://github.com/oven-sh/bun/issues/36320 files: { "in.ts": ` if (1) { From 25dd207dca188f740419e2faf56c3544f08885b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:43:34 +0000 Subject: [PATCH 07/15] js_parser: keep the declaration that names a single-use function when no renamer runs The single-use inliner moved `const f = () => {}` into the only use of `f`. That declaration is what names the function, so `f.name` was "" and its stack frame was ``. A read in a place that also names (`{ k: f }`, `sink = f`) gave the function the wrong name. - The inliner leaves an anonymous function, arrow or class in its `let` or `const` when no renamer runs: at run time, with `--no-bundle`, and in `Bun.Transpiler` without identifier minification. A bundle prints the same bytes as before. - `visit_decl` records on the symbol that the initializer, as written, is not an anonymous function or class. A fold can leave one there (`const f = (0, () => {})`), and the inliner still moves that value. - The transpiler cache version moves to 52, so a cached file is transpiled again. - The tests move into the existing transpiler test files. FunctionHoistingKeepNames3 and 4 passed only because the run time moved the function of the bundle again. They are todo. Co-authored-by: Peter Steinberger --- src/ast/symbol.rs | 4 + src/js_parser/p.rs | 7 ++ src/js_parser/visit/mod.rs | 15 +-- src/jsc/RuntimeTranspilerCache.rs | 3 +- test/bundler/bundler_minify.test.ts | 14 +++ test/bundler/esbuild/extra.test.ts | 15 ++- .../transpiler/runtime-transpiler.test.ts | 109 +++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 21 ++++ ...ranspiler-inline-anonymous-fn-name.test.ts | 112 ------------------ 9 files changed, 170 insertions(+), 130 deletions(-) delete mode 100644 test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts diff --git a/src/ast/symbol.rs b/src/ast/symbol.rs index a17540946f5e..443c6010210d 100644 --- a/src/ast/symbol.rs +++ b/src/ast/symbol.rs @@ -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 @@ -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; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 7af9d48c2bda..0a81c8b45dcb 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -3470,6 +3470,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` is what gives `value` its `name`. A renamer prints another name, so never with one. + 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() + } + fn hoist_symbols(&mut self, mut scope: js_ast::StoreRef) { // 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 diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index d5b3a45641e9..7d934c3e0c5a 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -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 { + 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, @@ -1989,12 +1991,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let last: &mut Decl = &mut local.decls.slice_mut()[last_idx]; let Some(replacement) = last.value else { break }; - // "let f = () => {}" gives the value ".name" via NamedEvaluation; - // substituting it away would make the name observably "". - if replacement.is_anonymous_named() { - break; - } - // The binding must be an identifier that is only used once. // Ignore destructuring bindings since that's not the simple case. // Destructuring bindings could potentially execute side-effecting @@ -2010,6 +2006,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) { diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..b6dd98191fba 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -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 diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index 37f69ce16a66..bd7d3b7f9c1e 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -239,6 +239,20 @@ describe("bundler", () => { minifySyntax: true, run: { stdout: "Controller Model" }, }); + // A bundle renames bindings, so the function that a `let` or `const` names still moves to its only use. + 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 */ ` diff --git a/test/bundler/esbuild/extra.test.ts b/test/bundler/esbuild/extra.test.ts index 8c9b7c5b6df4..7e4d95aa4e66 100644 --- a/test/bundler/esbuild/extra.test.ts +++ b/test/bundler/esbuild/extra.test.ts @@ -1637,7 +1637,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames1`, { - todo: true, // https://github.com/oven-sh/bun/issues/36320 + todo: true, // keepNames requires Object.defineProperty implementation files: { "in.js": ` var f @@ -1649,7 +1649,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames2`, { - todo: true, // https://github.com/oven-sh/bun/issues/36320 + todo: true, // keepNames requires Object.defineProperty implementation files: { "in.js": ` var f @@ -1661,11 +1661,10 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames3`, { - // https://github.com/oven-sh/bun/issues/36320: --keep-names does not yet - // emit __name() for the block-level function lowering, so the bundled - // output reads f.name as the renamed "f2" (node agrees). This previously - // passed by accident because the runtime transpiler inlined the - // single-use let back into the var assignment. + // The bundle holds `let f2 = function() {}; f = f2;` and no `__name(f2, "f")` + // (https://github.com/oven-sh/bun/pull/40503), so node and bun throw + // {"a":"a","b":"b","c":"c","d":"d","e":"e","f":"f2"}. It passed while the + // runtime moved the function into `f = function() {}`, which names it "f". todo: true, files: { "in.ts": ` @@ -1686,7 +1685,7 @@ describe("bundler", () => { run: true, }); itBundled(`extra/FunctionHoistingKeepNames4`, { - todo: true, // https://github.com/oven-sh/bun/issues/36320 + todo: true, // throws {"a":"a","b":"b","c":"c","d":"d","e":"e","f":"f2"}, as FunctionHoistingKeepNames3 files: { "in.ts": ` if (1) { diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 27e4b71f848b..12ecf4e4ee04 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -286,3 +286,112 @@ test.skipIf(isDebug || isASAN)( expect(exitCode).toBe(0); }, ); + +// https://github.com/oven-sh/bun/issues/20398 +describe("a let or const that names a function or class", () => { + const source = /* js */ ` + const rows = {}; + const row = (label, run) => { + try { + rows[label] = run(); + } catch (e) { + rows[label] = "throws " + e.name; + } + }; + const nameOf = f => f.name; + const frame = e => e.stack.split("\\n")[1].match(/at (?:new )?(\\S+) \\(/)?.[1]; + + // The declaration gives the name. + row("arrow", () => { const q = () => {}; return q.name; }); + row("function", () => { let q = function () {}; return q.name; }); + row("class", () => { const Q = class {}; return Q.name; }); + row("async arrow", () => { const q = async () => {}; return q.name; }); + row("generator", () => { const q = function* () {}; return q.name; }); + row("argument", () => { const q = () => {}; return nameOf(q); }); + row("frame", () => { const q = () => frame(new Error()); const top = q(); return top; }); + row("instance", () => { const Q = class {}; return new Q().constructor.name; }); + row("bound", () => { const q = () => {}; return q.bind(null).name; }); + + // A read in a place that names a function gives no second name. + row("property", () => { const q = () => {}; const o = { k: q }; return o.k.name; }); + row("assignment", () => { let sink; const q = () => {}; sink = q; return sink.name; }); + row("second declaration", () => { const a = () => {}; const b = a; return b.name + b.name; }); + row("element", () => { const f = () => 0; const a = [f]; return a[0].name; }); + + // A function that a block declares is a let. + row("block", () => { { function q() {} return q.name; } }); + row("if", () => { if (rows) { function q() {} return nameOf(q); } }); + row("block frame", () => { { function q() { return frame(new Error()); } const top = q(); return top; } }); + row("switch case", () => { switch (1) { case 1: function q() {} return q.name; } }); + + // The declaration gives no name. + row("named function", () => { const q = function named() {}; return q.name; }); + row("comma", () => { const q = (0, () => {}); return q.name; }); + row("nullish", () => { const q = null ?? (() => {}); return q.name; }); + row("and", () => { const q = 1 && function () {}; return q.name; }); + row("conditional", () => { const Q = true ? class {} : 0; return Q.name; }); + row("comma, second declaration", () => { const a = (0, () => {}); const b = a; return b.name; }); + + console.log(JSON.stringify(rows)); + `; + const expected = { + "arrow": "q", + "function": "q", + "class": "Q", + "async arrow": "q", + "generator": "q", + "argument": "q", + "frame": "q", + "instance": "Q", + "bound": "bound q", + + "property": "q", + "assignment": "q", + "second declaration": "aa", + "element": "f", + + "block": "q", + "if": "q", + "block frame": "q", + "switch case": "q", + + "named function": "named", + "comma": "", + "nullish": "", + "and": "", + "conditional": "", + "comma, second declaration": "", + }; + + test("in a test file", () => { + const fn = function () {}; + expect(fn.name).toBe("fn"); + }); + + test.concurrent.each([ + ["an ES module", ["module.mjs"]], + ["a CommonJS module", ["script.cjs"]], + ["bun -e", ["-e", source]], + ])("in %s", async (_, args) => { + using dir = tempDir("transpiler-named-by-declaration", { + "module.mjs": "export {};\n" + source, + "script.cjs": source, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + let rows: unknown = stdout; + try { + rows = JSON.parse(stdout); + } catch {} + expect({ rows, stderr, exitCode }).toEqual({ rows: expected, stderr: "", exitCode: 0 }); + }); +}); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 4724a1bd2a31..57ce45e17dc2 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -4736,6 +4736,27 @@ console.log(foo, array); // check("let x = arg0?.[foo]; (0, x)()", "let x = arg0?.[foo];\nx();"); }); + // https://github.com/oven-sh/bun/issues/20398 + it("substitution leaves a let or const that names a function or class", () => { + const print = (options, body) => + new Bun.Transpiler({ loader: "js", ...options }).transformSync(`function f() {\n${body}\n}`); + + for (const options of [{ target: "bun" }, { minify: { syntax: true } }]) { + const check = (input, output) => expect(print(options, input)).toBe(`function f() {\n${output}\n}\n`); + check("const q = () => {}; return q.name;", " const q = () => {};\n return q.name;"); + check("let q = function() {}; return q.name;", " let q = function() {};\n return q.name;"); + check("const Q = class {}; return new Q;", " const Q = class {\n };\n return new Q;"); + check("{ function q() {} return q.name; }", " {\n let q = function() {};\n return q.name;\n }"); + // These declarations give no name, so the value still moves. + check("const q = function named() {}; return q.name;", " return function named() {}.name;"); + check("const q = g(); return q.name;", " return g().name;"); + check("const q = (0, () => {}); return q.name;", " return (() => {}).name;"); + } + + // A renamer prints another name, so the value still moves. + expect(print({ minify: true }, "const q = () => {}; return q.name;")).toContain("{return(()=>{}).name}"); + }); + it("comma operator transforms", () => { const expectPrinted = (code, out) => { expect(parsed(code, true, true, transpilerMinifySyntax)).toBe(out); diff --git a/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts b/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts deleted file mode 100644 index bd20db797535..000000000000 --- a/test/js/bun/transpiler/transpiler-inline-anonymous-fn-name.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; - -// The runtime transpiler runs with minify_syntax enabled (target=bun implies -// tree_shaking, which implies inlining, which implies minify_syntax). Its -// single-use-let inlining previously substituted an anonymous -// function/arrow/class initializer into the use site, dropping the ".name" it -// would have received via NamedEvaluation at the declaration. -// https://github.com/oven-sh/bun/issues/20398 -// https://github.com/oven-sh/bun/issues/22770 -// -// Block-level function declarations are lowered to exactly that shape -// ("let f = function() {}"), so "{ function f() {} ... }" lost its name at -// runtime when f was referenced exactly once. This was first observed as -// performance.timerify() emitting entries with an empty name. -// -// These tests spawn a fresh bun process so the fixture goes through the -// real runtime transpiler rather than this test file's own transpile. - -test("single-use let of an anonymous function keeps its .name at runtime", async () => { - const src = ` - function t1() { - let fn = function () {}; - return fn.name; - } - function t2() { - const arrow = () => {}; - return arrow.name; - } - function t3() { - let Cls = class {}; - return Cls.name; - } - function t4() { - // named function expression: inlining is fine, name is intrinsic - let fn = function named() {}; - return fn.name; - } - function t5() { - // non-function initializer still inlines - let n = 42; - return n + 1; - } - function t6() { - // issue #20398: substituted into an array literal - const f = () => 0; - const a = [f]; - return a[0].name; - } - console.log(JSON.stringify([t1(), t2(), t3(), t4(), t5(), t6()])); - `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", src], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ - out: ["fn", "arrow", "Cls", "named", 43, "f"], - stderr: "", - exitCode: 0, - }); -}); - -test("block-level function declaration keeps its .name at runtime", async () => { - using dir = tempDir("block-fn-name", { - // .mjs so the block-level function declaration is a strict-mode let, - // which is the shape the lowering rewrites to "let f = function() {}". - "entry.mjs": ` - let a, b; - { - function slow() { return 42; } - a = slow.name; - } - { - function work() {} - const wrapped = work; - b = wrapped.name; - } - console.log(JSON.stringify([a, b])); - `, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "entry.mjs"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ - out: ["slow", "work"], - stderr: "", - exitCode: 0, - }); -}); - -test("Bun.Transpiler minify.syntax does not inline anonymous function into its single use", () => { - const transpiler = new Bun.Transpiler({ loader: "js", minify: { syntax: true } }); - const out = transpiler.transformSync(` - function outer() { - let fn = function () {}; - return fn.name; - } - `); - // The declaration must survive so JSC's NamedEvaluation assigns "fn". - expect(out).toContain("let fn = function"); - expect(out).not.toContain("return function"); -}); From f4879106933789db2f6765db16af2c2525ce56db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:45:26 +0000 Subject: [PATCH 08/15] test(aot): read block functions once, and update four stored frame columns - Two parity programs named each block function a second time, because plain Bun moved a function that is named once into the place of its name. Plain Bun keeps the declaration now, so they name it once and still print what the executable prints. - places-throw-with-another-text-or-column.js: the frame that calls a single-use arrow has the column of the call, as in node (105:60, was 105:53). Four stored lines. --- ...unctions-declared-in-blocks-beside-eval.mjs | 9 --------- .../text-of-functions-declared-in-blocks.ts | 18 ------------------ ...places-throw-with-another-text-or-column.js | 8 ++++---- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/test/aot/parallel/declarations/text-of-functions-declared-in-blocks-beside-eval.mjs b/test/aot/parallel/declarations/text-of-functions-declared-in-blocks-beside-eval.mjs index c7b713acb9b1..c01b107df419 100644 --- a/test/aot/parallel/declarations/text-of-functions-declared-in-blocks-beside-eval.mjs +++ b/test/aot/parallel/declarations/text-of-functions-declared-in-blocks-beside-eval.mjs @@ -5,10 +5,6 @@ import { mock } from "bun:test"; function show(what, f) { console.log(what + ":", String(f).replace(/^function[^(]*/, "function").replace(/\s+/g, ""), JSON.stringify([f.name, mock(f).getMockName()])); } -// Plain Bun prints a function that is named once in the place of that name, where nothing gives it a `name`. -function used(...functions) { - return functions.length; -} { function besideEval() { return 1; } @@ -18,7 +14,6 @@ function used(...functions) { { function aroundEval(a) { return eval("a + 1"); } show("around a direct eval", aroundEval); - used(aroundEval); } { function besideAClosureWithEval() { return 2; } @@ -29,7 +24,6 @@ function evalOutsideTheBlock() { { function inABlockBesideWhichEvalStands() { return 3; } show("eval stands outside the block", inABlockBesideWhichEvalStands); - used(inABlockBesideWhichEvalStands); } return eval("4"); } @@ -38,17 +32,14 @@ console.log(evalOutsideTheBlock()); function besideIndirectEval() { return 5; } show("beside an indirect eval", besideIndirectEval); console.log((0, eval)("6"), eval?.("7"), globalThis.eval("8")); - used(besideIndirectEval); } switch (eval("9")) { case 9: function behindEvalInTheSubject() { return 10; } show("eval in the subject of a switch", behindEvalInTheSubject); - used(behindEvalInTheSubject); } switch (11) { case eval("11"): function behindEvalInATest() { return 12; } show("eval in the test of a clause", behindEvalInATest); - used(behindEvalInATest); } diff --git a/test/aot/parallel/declarations/text-of-functions-declared-in-blocks.ts b/test/aot/parallel/declarations/text-of-functions-declared-in-blocks.ts index b2f4ac536414..d8a000e51f1c 100644 --- a/test/aot/parallel/declarations/text-of-functions-declared-in-blocks.ts +++ b/test/aot/parallel/declarations/text-of-functions-declared-in-blocks.ts @@ -5,7 +5,6 @@ import { mock } from "bun:test"; function show(what: string, f: Function) { console.log(what + ":", String(f).replace(/^function[^(]*/, "function").replace(/\s+/g, ""), JSON.stringify([f.name, mock(f as () => void).getMockName()])); } -// Plain Bun prints a function that is named once in the place of that name, where nothing gives it a `name`. function used(...functions: Function[]) { return functions.length; } @@ -13,7 +12,6 @@ function used(...functions: Function[]) { { function inABlock(a: number) { return a + 1; } show("a block", inABlock); - used(inABlock); } function atTheTop(a: number) { return a + 1; } show("the top of the file", atTheTop); @@ -26,7 +24,6 @@ function around() { { function twoBlocksDeep(a: number) { return inABlockOfAFunction(a); } show("two blocks deep", twoBlocksDeep); - used(twoBlocksDeep); } } } @@ -34,36 +31,29 @@ around(); if (used()) { function inAnArm(a: number) { return a + 1; } show("an arm of if", inAnArm); - used(inAnArm); } else { function inTheOtherArm(a: number) { return a + 1; } show("the other arm", inTheOtherArm); - used(inTheOtherArm); } for (let i = 0; i < 2; i++) { function inALoop(a: number) { return a + i; } show("a loop", inALoop); - used(inALoop); } switch (used()) { case 0: function inAClause(a: number) { return a + 1; } show("a clause", inAClause); - used(inAClause); } try { function inATry(a: number) { return a + 1; } show("try", inATry); - used(inATry); throw 1; } catch { function inACatch(a: number) { return a + 1; } show("catch", inACatch); - used(inACatch); } finally { function inAFinally(a: number) { return a + 1; } show("finally", inAFinally); - used(inAFinally); } { async function asynchronous(a: number) { return a + 1; } @@ -86,7 +76,6 @@ try { show("on its own line", onItsOwnLine); show("an escape", escaped); show("one character", $); - used(manyBlanks, withComments, onItsOwnLine, escaped, $); } { function generic(this: any, a: T): T { return a; } @@ -95,7 +84,6 @@ try { function overloaded(a: any): any { return a; } show("type parameters", generic); show("overloads", overloaded); - used(generic, overloaded); } { function callsItself(a: number): string { return a ? callsItself(a - 1) : String(callsItself).replace(/^function[^(]*/, "function").replace(/\s+/g, ""); } @@ -108,14 +96,12 @@ class Kind { { function inABlockOfAStaticBlock(a: number) { return a + 1; } show("a block in a static block", inABlockOfAStaticBlock); - used(inABlockOfAStaticBlock); } } method() { { function inABlockOfAMethod(a: number) { return a + 1; } show("a block in a method", inABlockOfAMethod); - used(inABlockOfAMethod); } } } @@ -126,7 +112,6 @@ const arrow = () => { { function inABlockOfAnArrow(a: number) { return a + 1; } show("a block in an arrow", inABlockOfAnArrow); - used(inABlockOfAnArrow); } }; arrow(); @@ -138,18 +123,15 @@ namespace Space { { function inABlockOfANamespace(a: number) { return a + 1; } show("a block in a namespace", inABlockOfANamespace); - used(inABlockOfANamespace); } } { function sent(a: number) { return a + 1; } console.log("evaluated from its text:", (0, eval)("(" + sent + ")")(1)); - used(sent); } // What stands behind the name in its line is still quoted, and found, where it is written. { function thrower(o: any) { function inner(p: any) { return p.a.b.c; } return inner(o); } try { thrower({}); } catch (error: any) { console.log(error.message, error.stack.split("\n")[1].replace(/\(.*[\\/]/, "(")); } - used(thrower); } // Text that is evaluated runs as it is written. console.log("in evaluated text:", (0, eval)("{ function inEvaluatedText(a) { return a + 1; } String(inEvaluatedText) }")); diff --git a/test/aot/parallel/errors/places-throw-with-another-text-or-column.js b/test/aot/parallel/errors/places-throw-with-another-text-or-column.js index 749a1f300e12..b3f5ba516584 100644 --- a/test/aot/parallel/errors/places-throw-with-another-text-or-column.js +++ b/test/aot/parallel/errors/places-throw-with-another-text-or-column.js @@ -228,10 +228,10 @@ destructure next throws -> Error: next [97:108] next@places-throw-with-another-t for-of undefined -> TypeError: undefined is not an object (evaluating 'x of nothing') [100:48] @places-throw-with-another-text-or-column.js:100:48 | attempt@places-throw-with-another-text-or-column.js:20:46 | @places-throw-with-another-text-or-column.js:100:1 yield star -> TypeError: undefined is not a function [101:58] @places-throw-with-another-text-or-column.js:101:58 | @places-throw-with-another-text-or-column.js:101:36 | attempt@places-throw-with-another-text-or-column.js:20:46 iterator no next -> TypeError: undefined is not a function [102:48] @places-throw-with-another-text-or-column.js:102:48 | attempt@places-throw-with-another-text-or-column.js:20:46 | @places-throw-with-another-text-or-column.js:102:1 -dead zone let -> ReferenceError: Cannot access 'later' before initialization. [105:53] read@places-throw-with-another-text-or-column.js:105:53 | @places-throw-with-another-text-or-column.js:105:53 | attempt@places-throw-with-another-text-or-column.js:20:46 -dead zone const -> ReferenceError: Cannot access 'later' before initialization. [106:55] read@places-throw-with-another-text-or-column.js:106:55 | @places-throw-with-another-text-or-column.js:106:55 | attempt@places-throw-with-another-text-or-column.js:20:46 -dead zone class -> ReferenceError: Cannot access 'Later' before initialization. [107:55] read@places-throw-with-another-text-or-column.js:107:55 | @places-throw-with-another-text-or-column.js:107:55 | attempt@places-throw-with-another-text-or-column.js:20:46 -dead zone store -> ReferenceError: Cannot access 'later' before initialization. [108:58] write@places-throw-with-another-text-or-column.js:108:58 | @places-throw-with-another-text-or-column.js:108:66 | attempt@places-throw-with-another-text-or-column.js:20:46 +dead zone let -> ReferenceError: Cannot access 'later' before initialization. [105:53] read@places-throw-with-another-text-or-column.js:105:53 | @places-throw-with-another-text-or-column.js:105:60 | attempt@places-throw-with-another-text-or-column.js:20:46 +dead zone const -> ReferenceError: Cannot access 'later' before initialization. [106:55] read@places-throw-with-another-text-or-column.js:106:55 | @places-throw-with-another-text-or-column.js:106:62 | attempt@places-throw-with-another-text-or-column.js:20:46 +dead zone class -> ReferenceError: Cannot access 'Later' before initialization. [107:55] read@places-throw-with-another-text-or-column.js:107:55 | @places-throw-with-another-text-or-column.js:107:62 | attempt@places-throw-with-another-text-or-column.js:20:46 +dead zone store -> ReferenceError: Cannot access 'later' before initialization. [108:58] write@places-throw-with-another-text-or-column.js:108:58 | @places-throw-with-another-text-or-column.js:108:72 | attempt@places-throw-with-another-text-or-column.js:20:46 in number -> TypeError: five is not an Object. (evaluating '"a" in five') [111:37] @places-throw-with-another-text-or-column.js:111:37 | attempt@places-throw-with-another-text-or-column.js:20:46 | @places-throw-with-another-text-or-column.js:111:1 in undefined -> TypeError: undefined is not an Object. (evaluating '"a" in nothing') [112:40] @places-throw-with-another-text-or-column.js:112:40 | attempt@places-throw-with-another-text-or-column.js:20:46 | @places-throw-with-another-text-or-column.js:112:1 in member -> TypeError: null is not an Object. (evaluating 'key in holder.inner') [113:37] @places-throw-with-another-text-or-column.js:113:37 | attempt@places-throw-with-another-text-or-column.js:20:46 | @places-throw-with-another-text-or-column.js:113:1 From 2cca0a37dc18e8d1e48770bf2305808c4b872d42 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:02:05 +0000 Subject: [PATCH 09/15] Sweep what was built around the single-use inliner - src/aot: the front end can no longer see a function printed in place of its only use, so the two arms that found positions inside such a function and their recursion guard go. `Printed::Function` stays for a name in code that the transpiler drops, and carries nothing. - inspect.test.js: the `f.length;` workaround and its TODO go. Both tests fail without the fix. - AOT tests: one more parity program, one fuzz source and the records of Bun's own tests no longer read a function twice. A new parity program prints the names of functions that are read once. - bun pm diff compares re-printed text: a function moved out of the variable that names it is a change now. One row. - runtime-transpiler.test.ts: a row for an operand, and todo rows for a function that a fold leaves behind a name. - The gate leaves a bundle as esbuild prints it. The comments say so. --- src/aot/lower/call/callee.rs | 25 +-- src/aot/lower/statement/substitution.rs | 15 +- src/js_parser/p.rs | 2 +- .../aot/fuzz/function-name-length-own-keys.ts | 6 +- test/aot/other-suites/bun-tests.known.txt | 10 +- test/aot/other-suites/bun-tests.same.txt | 1 + ...efining-the-name-a-function-already-has.js | 8 +- ...ead-once-takes-the-name-of-its-variable.js | 36 ++++ test/bundler/bundler_minify.test.ts | 2 +- .../transpiler/runtime-transpiler.test.ts | 186 +++++++++++------- test/cli/install/bun-pm-diff.test.ts | 15 ++ test/js/bun/util/inspect.test.js | 6 - 12 files changed, 184 insertions(+), 128 deletions(-) create mode 100644 test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js diff --git a/src/aot/lower/call/callee.rs b/src/aot/lower/call/callee.rs index 708209feac4e..829045395921 100644 --- a/src/aot/lower/call/callee.rs +++ b/src/aot/lower/call/callee.rs @@ -9,8 +9,8 @@ use bun_sema::bind::{Binding, FnOwner, Parent}; use bun_sema::check::spans::skip_trivia; use bun_sema::fold::{Constant, Decision, TreeNode, push_children}; use bun_sema::hir::{ - BinOp, Call, Chain, ExprFlags, ExprId, ExprKind, FnBody, FnId, FnKind, IdList, MemberKey, - NameKind, PropKey, PropKind, UnOp, end_of_expr, start_of, + BinOp, Call, Chain, ExprFlags, ExprId, ExprKind, FnBody, FnKind, IdList, MemberKey, NameKind, + PropKey, PropKind, UnOp, end_of_expr, start_of, }; use bun_sema::import_sites::{ImportSiteKind, find_module_printed_as_require}; use rustc_hash::FxHashMap; @@ -297,8 +297,6 @@ pub(in crate::lower) struct PrintedOperators { first_positions: RefCell>, // By a name in whose place an initializer is printed: what stands there once every such name is followed to its initializer. printed_behind_names: RefCell>, - // The functions printed in place of a name whose last position is being looked for. - entered_functions: RefCell>, } impl FileLowering<'_, '_, '_, '_> { @@ -365,17 +363,7 @@ impl FileLowering<'_, '_, '_, '_> { }, ExprKind::Ident(_) => match self.find_printed_in_place(last) { Some(Printed::Initializer(initializer)) => initializer, - Some(Printed::Function(function)) => { - let entered = &self.printed_operators.entered_functions; - if entered.borrow().contains(&function) { - return hir[last].pos; - } - entered.borrow_mut().push(function); - let position = self.last_mapped_function_position(function); - entered.borrow_mut().pop(); - return position.unwrap_or(hir[last].pos); - } - None => return hir[last].pos, + Some(Printed::Function) | None => return hir[last].pos, }, ExprKind::Dot { .. } if self.is_require_main_node(last) => { return hir[last].pos; @@ -687,8 +675,7 @@ impl FileLowering<'_, '_, '_, '_> { ExprKind::Ident(_) => { let printed = match self.find_printed_in_place(first) { Some(Printed::Initializer(initializer)) => initializer, - Some(Printed::Function(function)) => break hir[function].start, - None => break hir[first].pos, + Some(Printed::Function) | None => break hir[first].pos, }; replaced.push(first); printed @@ -763,7 +750,7 @@ impl FileLowering<'_, '_, '_, '_> { ExprKind::TaggedTemplate(_) => return false, ExprKind::Ident(_) => match self.find_printed_in_place(first) { Some(Printed::Initializer(initializer)) => (initializer, around), - Some(Printed::Function(_)) | None => return false, + Some(Printed::Function) | None => return false, }, _ => return false, }; @@ -1089,7 +1076,7 @@ impl FileLowering<'_, '_, '_, '_> { Some(Printed::Initializer(initializer)) => { self.append_written_path(initializer, path) } - Some(Printed::Function(_)) | None => false, + Some(Printed::Function) | None => false, }; } ExprKind::Ident(name) => self.append_identifier(inner, name, path), diff --git a/src/aot/lower/statement/substitution.rs b/src/aot/lower/statement/substitution.rs index f56d427f1a8e..d1176df63ca7 100644 --- a/src/aot/lower/statement/substitution.rs +++ b/src/aot/lower/statement/substitution.rs @@ -1,8 +1,8 @@ use bun_sema::bind::{Binding, Decl, Parent, PatParent, SymbolId}; use bun_sema::fold::{Constant, Decision, FoldedValue}; use bun_sema::hir::{ - BinOp, ExprId, ExprKind, Flags, FnBody, FnId, IdList, MemberKey, PatKind, PropKey, PropKind, - StmtId, StmtKind, UnOp, VarDeclId, VarKind, + BinOp, ExprId, ExprKind, Flags, FnBody, IdList, MemberKey, PatKind, PropKey, PropKind, StmtId, + StmtKind, UnOp, VarDeclId, VarKind, }; use smallvec::{SmallVec, smallvec}; @@ -14,8 +14,8 @@ use crate::lower::transpiled::{Rewrite, TranspiledFile}; #[derive(Copy, Clone)] pub(in crate::lower) enum Printed { Initializer(ExprId), - // A function that a block declares. The transpiler declares it as a `let` variable at the start of the block, with a function expression that has no name. - Function(FnId), + // A function whose declaration is not printed either: the name stands in code that the transpiler drops. + Function, } impl FileLowering<'_, '_, '_, '_> { @@ -298,9 +298,8 @@ impl FileLowering<'_, '_, '_, '_> { self.return_variable_at_once(later, holder) } - // What Bun's transpiler prints in place of the name of a constant, of the only use of a `let` or `const` variable or of a function that a block declares. + // What Bun's transpiler prints in place of the name of a constant or of the only use of a `let` or `const` variable. // An initializer that replaces a name ends in front of the name, so following names to their initializers comes to an end. - // A function may be declared behind the name: `last_mapped_position_in` enters none that it is in already. pub(in crate::lower) fn find_printed_in_place(&self, name: ExprId) -> Option { let hir = self.hir; let transpiled = self.transpiled.as_ref()?; @@ -331,7 +330,7 @@ impl FileLowering<'_, '_, '_, '_> { _ => None, }); last.filter(|&last| !transpiled.has_binding_at(hir[last].name_pos)) - .map(Printed::Function) + .map(|_| Printed::Function) } } } @@ -339,7 +338,7 @@ impl FileLowering<'_, '_, '_, '_> { pub(in crate::lower) fn find_printed_initializer(&self, name: ExprId) -> Option { match self.find_printed_in_place(name)? { Printed::Initializer(initializer) => Some(initializer), - Printed::Function(_) => None, + Printed::Function => None, } } diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 0a81c8b45dcb..a28dc121c71b 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -3470,7 +3470,7 @@ 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` is what gives `value` its `name`. A renamer prints another name, so never with one. + /// 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() diff --git a/test/aot/fuzz/function-name-length-own-keys.ts b/test/aot/fuzz/function-name-length-own-keys.ts index f055cee7f159..105729ed7c9c 100644 --- a/test/aot/fuzz/function-name-length-own-keys.ts +++ b/test/aot/fuzz/function-name-length-own-keys.ts @@ -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, @@ -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`; } diff --git a/test/aot/other-suites/bun-tests.known.txt b/test/aot/other-suites/bun-tests.known.txt index bd51eeaec39b..12c45f686edf 100644 --- a/test/aot/other-suites/bun-tests.known.txt +++ b/test/aot/other-suites/bun-tests.known.txt @@ -210,7 +210,6 @@ DIFFERS test/js/bun/sqlite/sqlite.test.js > an exception that is raised behind a DIFFERS test/js/bun/sqlite/sqlite.test.js > db.function() > terminating a Worker that is inside a function ends it: a file by a path that is computed or made at run time DIFFERS test/js/bun/sqlite/sqlite.test.js > db.function() > terminating a Worker that is inside a hook ends it: a file by a path that is computed or made at run time DIFFERS test/js/bun/sqlite/sqlite.test.js > db.onInsert, db.onUpdate and db.onDelete > the database keeps a hook alive, and nothing else > hooks that were replaced or removed, those of a closed database, and databases that their own hooks hold, are collected: the clock, and what is collected when -DIFFERS test/js/bun/test/describe.test.ts > blocks should handle a number, string, anonymous class, named class, or function for the first arg > MyClass > Should also pass: the name of the test is made of a function's text, or the plain run was cut DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > an error thrown by asymmetricMatch() fails the assertion: asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > asymmetricMatch() also works on the received value: asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > asymmetricMatch() can be a class method: asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields @@ -315,8 +314,8 @@ DIFFERS test/js/bun/util/fuzzy-wuzzy.test.ts > util > call: a file by a path tha DIFFERS test/js/bun/util/fuzzy-wuzzy.test.ts > util > construct-subclass: a file by a path that is computed or made at run time DIFFERS test/js/bun/util/fuzzy-wuzzy.test.ts > util > construct: a file by a path that is computed or made at run time DIFFERS test/js/bun/util/index-of-line.test.ts > indexOfLine searches a SharedArrayBuffer that another thread is writing: text at run time (eval, the Function constructor, a Worker or an import of text) -DIFFERS test/js/bun/util/inspect.test.js > Functions with names > "() => {\n const f = function () {};\n // workaround transpiler inlining losing the display name\n // TODO: the name of the test is made of a function's text, or the plain run was cut -DIFFERS test/js/bun/util/inspect.test.js > Functions with names > "() => {\n let f = function () {};\n // workaround transpiler inlining losing the display name\n // TODO: the name of the test is made of a function's text, or the plain run was cut +DIFFERS test/js/bun/util/inspect.test.js > Functions with names > "() => {\n const f = function () {};\n return f;\n }": the name of the test is made of a function's text, or the plain run was cut +DIFFERS test/js/bun/util/inspect.test.js > Functions with names > "() => {\n let f = function () {};\n return f;\n }": the name of the test is made of a function's text, or the plain run was cut DIFFERS test/js/bun/util/inspect.test.js > Functions with names > "() => {\n var f = function () {};\n return f;\n }": the name of the test is made of a function's text, or the plain run was cut DIFFERS test/js/bun/util/inspect.test.js > crash testing > inspecting "() =>\n new Proxy(" doesn't crash: the name of the test is made of a function's text, or the plain run was cut DIFFERS test/js/bun/util/inspect.test.js > crash testing > inspecting "() =>\n new Writab" doesn't crash: the name of the test is made of a function's text, or the plain run was cut @@ -929,13 +928,12 @@ DIFFERS test/regression/issue/22635/22635.test.ts > issue #22635 - MessagePort c DIFFERS test/regression/issue/issue-1825-jest-mock-functions.test.ts > Jest mock functions from issue #1825 > jest.mock should be available and work with factory function: a spy or a mock on what a module exports MISSING test/js/bun/http/bun-serve-html.test.ts > mixed api and html routes with non-* false routes (2): a file by a path that is computed or made at run time MISSING test/js/bun/http/bun-serve-html.test.ts > mixed api and html routes with non-* false routes (3): a file by a path that is computed or made at run time -MISSING test/js/bun/test/describe.test.ts > blocks should handle a number, string, anonymous class, named class, or function for the first arg > Should also pass: the compiled run is right: `const C = class {}` names the class MISSING test/js/bun/test/mock/mock-module.test.ts > adding a default on a module with no default (2): a spy or a mock on what a module exports MISSING test/js/bun/util/fuzzy-wuzzy.test.ts > http2 > call (2): the lock of the built-ins MISSING test/js/bun/util/fuzzy-wuzzy.test.ts > http2 > construct (2): the lock of the built-ins MISSING test/js/bun/util/fuzzy-wuzzy.test.ts > http2 > construct-subclass (2): the lock of the built-ins -MISSING test/js/bun/util/inspect.test.js > Functions with names > "() => {\n const f = function() {};\n f.length;\n return f;\n }": the name of the test is made of a function's text -MISSING test/js/bun/util/inspect.test.js > Functions with names > "() => {\n let f = function() {};\n f.length;\n return f;\n }": the name of the test is made of a function's text +MISSING test/js/bun/util/inspect.test.js > Functions with names > "() => {\n const f = function() {};\n return f;\n }": the name of the test is made of a function's text +MISSING test/js/bun/util/inspect.test.js > Functions with names > "() => {\n let f = function() {};\n return f;\n }": the name of the test is made of a function's text MISSING test/js/bun/util/inspect.test.js > Functions with names > "() => {\n var f = function() {};\n return f;\n }": the name of the test is made of a function's text MISSING test/js/bun/util/inspect.test.js > crash testing > inspecting "() => new Proxy({ yo" doesn't crash (2): the name of the test is made of a function's text MISSING test/js/bun/util/inspect.test.js > crash testing > inspecting "() => new WritableSt" doesn't crash: the name of the test is made of a function's text diff --git a/test/aot/other-suites/bun-tests.same.txt b/test/aot/other-suites/bun-tests.same.txt index 944659f35712..194d8b1ed3fa 100644 --- a/test/aot/other-suites/bun-tests.same.txt +++ b/test/aot/other-suites/bun-tests.same.txt @@ -305,6 +305,7 @@ test/js/bun/test/bun_test.test.ts test/js/bun/test/ci-restrictions.test.ts test/js/bun/test/concurrent.test.ts test/js/bun/test/concurrent_immediate.test.ts +test/js/bun/test/describe.test.ts test/js/bun/test/done-async.test.ts test/js/bun/test/dots.test.ts test/js/bun/test/expect-assertions.test.ts diff --git a/test/aot/parallel/classes-and-closures/defining-the-name-a-function-already-has.js b/test/aot/parallel/classes-and-closures/defining-the-name-a-function-already-has.js index c40e88948e39..71a477b4c3ea 100644 --- a/test/aot/parallel/classes-and-closures/defining-the-name-a-function-already-has.js +++ b/test/aot/parallel/classes-and-closures/defining-the-name-a-function-already-has.js @@ -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": () => { @@ -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* () {}, @@ -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"; }, diff --git a/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js b/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js new file mode 100644 index 000000000000..7822b034b860 --- /dev/null +++ b/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js @@ -0,0 +1,36 @@ +// 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; }); + +// 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; }); diff --git a/test/bundler/bundler_minify.test.ts b/test/bundler/bundler_minify.test.ts index bd7d3b7f9c1e..345bfe4429e8 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -239,7 +239,7 @@ describe("bundler", () => { minifySyntax: true, run: { stdout: "Controller Model" }, }); - // A bundle renames bindings, so the function that a `let` or `const` names still moves to its only use. + // 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 */ ` diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index 12ecf4e4ee04..f66fc0750991 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -289,7 +289,7 @@ test.skipIf(isDebug || isASAN)( // https://github.com/oven-sh/bun/issues/20398 describe("a let or const that names a function or class", () => { - const source = /* js */ ` + const before = /* js */ ` const rows = {}; const row = (label, run) => { try { @@ -299,87 +299,26 @@ describe("a let or const that names a function or class", () => { } }; const nameOf = f => f.name; + const first = a => a; const frame = e => e.stack.split("\\n")[1].match(/at (?:new )?(\\S+) \\(/)?.[1]; - - // The declaration gives the name. - row("arrow", () => { const q = () => {}; return q.name; }); - row("function", () => { let q = function () {}; return q.name; }); - row("class", () => { const Q = class {}; return Q.name; }); - row("async arrow", () => { const q = async () => {}; return q.name; }); - row("generator", () => { const q = function* () {}; return q.name; }); - row("argument", () => { const q = () => {}; return nameOf(q); }); - row("frame", () => { const q = () => frame(new Error()); const top = q(); return top; }); - row("instance", () => { const Q = class {}; return new Q().constructor.name; }); - row("bound", () => { const q = () => {}; return q.bind(null).name; }); - - // A read in a place that names a function gives no second name. - row("property", () => { const q = () => {}; const o = { k: q }; return o.k.name; }); - row("assignment", () => { let sink; const q = () => {}; sink = q; return sink.name; }); - row("second declaration", () => { const a = () => {}; const b = a; return b.name + b.name; }); - row("element", () => { const f = () => 0; const a = [f]; return a[0].name; }); - - // A function that a block declares is a let. - row("block", () => { { function q() {} return q.name; } }); - row("if", () => { if (rows) { function q() {} return nameOf(q); } }); - row("block frame", () => { { function q() { return frame(new Error()); } const top = q(); return top; } }); - row("switch case", () => { switch (1) { case 1: function q() {} return q.name; } }); - - // The declaration gives no name. - row("named function", () => { const q = function named() {}; return q.name; }); - row("comma", () => { const q = (0, () => {}); return q.name; }); - row("nullish", () => { const q = null ?? (() => {}); return q.name; }); - row("and", () => { const q = 1 && function () {}; return q.name; }); - row("conditional", () => { const Q = true ? class {} : 0; return Q.name; }); - row("comma, second declaration", () => { const a = (0, () => {}); const b = a; return b.name; }); - + `; + const after = /* js */ ` console.log(JSON.stringify(rows)); `; - const expected = { - "arrow": "q", - "function": "q", - "class": "Q", - "async arrow": "q", - "generator": "q", - "argument": "q", - "frame": "q", - "instance": "Q", - "bound": "bound q", - - "property": "q", - "assignment": "q", - "second declaration": "aa", - "element": "f", - - "block": "q", - "if": "q", - "block frame": "q", - "switch case": "q", - - "named function": "named", - "comma": "", - "nullish": "", - "and": "", - "conditional": "", - "comma, second declaration": "", - }; - test("in a test file", () => { - const fn = function () {}; - expect(fn.name).toBe("fn"); - }); - - test.concurrent.each([ - ["an ES module", ["module.mjs"]], - ["a CommonJS module", ["script.cjs"]], - ["bun -e", ["-e", source]], - ])("in %s", async (_, args) => { + const modes = ["an ES module", "a CommonJS module", "bun -e"] as const; + async function rowsOf(mode: (typeof modes)[number], rows: string) { + const source = before + rows + after; using dir = tempDir("transpiler-named-by-declaration", { "module.mjs": "export {};\n" + source, "script.cjs": source, }); await using proc = Bun.spawn({ - cmd: [bunExe(), ...args], + cmd: [ + bunExe(), + ...(mode === "bun -e" ? ["-e", source] : [mode === "an ES module" ? "module.mjs" : "script.cjs"]), + ], env: bunEnv, cwd: String(dir), stdout: "pipe", @@ -388,10 +327,107 @@ describe("a let or const that names a function or class", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - let rows: unknown = stdout; + let printed: unknown = stdout; try { - rows = JSON.parse(stdout); + printed = JSON.parse(stdout); } catch {} - expect({ rows, stderr, exitCode }).toEqual({ rows: expected, stderr: "", exitCode: 0 }); + return { rows: printed, stderr, exitCode }; + } + + test("in a test file", () => { + const fn = function () {}; + expect(fn.name).toBe("fn"); + }); + + test.concurrent.each(modes)("in %s", async mode => { + const rows = /* js */ ` + // The declaration gives the name. + row("arrow", () => { const q = () => {}; return q.name; }); + row("function", () => { let q = function () {}; return q.name; }); + row("class", () => { const Q = class {}; return Q.name; }); + row("async arrow", () => { const q = async () => {}; return q.name; }); + row("generator", () => { const q = function* () {}; return q.name; }); + row("argument", () => { const q = () => {}; return nameOf(q); }); + row("frame", () => { const q = () => frame(new Error()); const top = q(); return top; }); + row("instance", () => { const Q = class {}; return new Q().constructor.name; }); + row("bound", () => { const q = () => {}; return q.bind(null).name; }); + + // A read in a place that names a function gives no second name. + row("property", () => { const q = () => {}; const o = { k: q }; return o.k.name; }); + row("assignment", () => { let sink; const q = () => {}; sink = q; return sink.name; }); + row("second declaration", () => { const a = () => {}; const b = a; return b.name + b.name; }); + row("operand", () => { const q = () => {}; const w = q ?? 1; return w.name + w.name; }); + row("element", () => { const f = () => 0; const a = [f]; return a[0].name; }); + + // A function that a block declares is a let. + row("block", () => { { function q() {} return q.name; } }); + row("if", () => { if (rows) { function q() {} return nameOf(q); } }); + row("block frame", () => { { function q() { return frame(new Error()); } const top = q(); return top; } }); + row("switch case", () => { switch (1) { case 1: function q() {} return q.name; } }); + + // The declaration gives no name. + row("named function", () => { const q = function named() {}; return q.name; }); + row("comma", () => { const q = (0, () => {}); return q.name; }); + row("nullish", () => { const q = null ?? (() => {}); return q.name; }); + row("and", () => { const q = 1 && function () {}; return q.name; }); + row("conditional", () => { const Q = true ? class {} : 0; return Q.name; }); + row("comma, second declaration", () => { const a = (0, () => {}); const b = a; return b.name; }); + `; + + expect(await rowsOf(mode, rows)).toEqual({ + rows: { + "arrow": "q", + "function": "q", + "class": "Q", + "async arrow": "q", + "generator": "q", + "argument": "q", + "frame": "q", + "instance": "Q", + "bound": "bound q", + + "property": "q", + "assignment": "q", + "second declaration": "aa", + "operand": "qq", + "element": "f", + + "block": "q", + "if": "q", + "block frame": "q", + "switch case": "q", + + "named function": "named", + "comma": "", + "nullish": "", + "and": "", + "conditional": "", + "comma, second declaration": "", + }, + stderr: "", + exitCode: 0, + }); + }); + + // A fold leaves the function in a declaration that stays, or in a place that names it: the rows give "a", "a", "k" and "sink". + // https://github.com/oven-sh/bun/pull/42691 and https://github.com/oven-sh/bun/pull/42585 keep such a function away from the name. + test.todo("a function behind a fold takes no name from where it ends", async () => { + const rows = /* js */ ` + row("before a function that stays", () => { const a = (0, () => {}); const f = () => {}; return first(a, f).name; }); + row("before a class that stays", () => { const a = null ?? (() => {}); const C = class {}; return first(a, C).name; }); + row("property", () => { const q = (0, () => {}); const o = { k: q }; return o.k.name; }); + row("assignment", () => { let sink; const q = (0, () => {}); sink = q; return sink.name; }); + `; + + expect(await rowsOf("an ES module", rows)).toEqual({ + rows: { + "before a function that stays": "", + "before a class that stays": "", + "property": "", + "assignment": "", + }, + stderr: "", + exitCode: 0, + }); }); }); diff --git a/test/cli/install/bun-pm-diff.test.ts b/test/cli/install/bun-pm-diff.test.ts index fcf62f324209..4ed554c77dec 100644 --- a/test/cli/install/bun-pm-diff.test.ts +++ b/test/cli/install/bun-pm-diff.test.ts @@ -838,6 +838,21 @@ describe.concurrent("bun pm diff (canonical re-print)", () => { expect(exitCode).toBe(0); }); + // https://github.com/oven-sh/bun/issues/20398 + test("a function moved out of the variable that names it is a change", async () => { + const { text, exitCode } = await pretty({ + "a/m.js": "export function run(x) {\n const step = () => x + 1;\n return step();\n}\n", + "b/m.js": "export function run(x) {\n return (() => x + 1)();\n}\n", + }); + expect(changedLines(text)).toEqual([ + " 2 │- const step = () => x + 1;", + " 3 │- return step();", + " 2 │+ return (() => x + 1)();", + ]); + expect(text).toMatch(/\nm\.js ─+ \+1 -2\n/); + expect(exitCode).toBe(0); + }); + test("two minified builds with different mangled names diff down to the real change", async () => { // Same program, names handed out differently by the "minifier", plus one genuine edit (* 2 → * 3) and one // inserted helper in b. Long enough on one line to count as minified. diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index 8e502eb2bab0..f427502ac1f7 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -644,16 +644,10 @@ describe("Functions with names", () => { }, () => { const f = function () {}; - // workaround transpiler inlining losing the display name - // TODO: preserve the name on functions being inlined - f.length; return f; }, () => { let f = function () {}; - // workaround transpiler inlining losing the display name - // TODO: preserve the name on functions being inlined - f.length; return f; }, () => { From 0691f3d59b3ed55b03ba1216fa89933805a1ed5c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:21:39 +0000 Subject: [PATCH 10/15] test: leave the printed text of a fold and of a block function to their own rows --- test/bundler/transpiler/transpiler.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 57ce45e17dc2..48084b85c06c 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -4746,11 +4746,9 @@ console.log(foo, array); check("const q = () => {}; return q.name;", " const q = () => {};\n return q.name;"); check("let q = function() {}; return q.name;", " let q = function() {};\n return q.name;"); check("const Q = class {}; return new Q;", " const Q = class {\n };\n return new Q;"); - check("{ function q() {} return q.name; }", " {\n let q = function() {};\n return q.name;\n }"); // These declarations give no name, so the value still moves. check("const q = function named() {}; return q.name;", " return function named() {}.name;"); check("const q = g(); return q.name;", " return g().name;"); - check("const q = (0, () => {}); return q.name;", " return (() => {}).name;"); } // A renamer prints another name, so the value still moves. From 05c34e944e2804a617c6045477fb952a532edb2a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:09:54 +0000 Subject: [PATCH 11/15] test: show the moved function in bun pm diff from runtime-transpiler.test.ts The row lived in bun-pm-diff.test.ts. That file returns to what main has. --- .../transpiler/runtime-transpiler.test.ts | 32 +++++++++++++++++++ test/cli/install/bun-pm-diff.test.ts | 15 --------- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/test/bundler/transpiler/runtime-transpiler.test.ts b/test/bundler/transpiler/runtime-transpiler.test.ts index f66fc0750991..329115a7ba18 100644 --- a/test/bundler/transpiler/runtime-transpiler.test.ts +++ b/test/bundler/transpiler/runtime-transpiler.test.ts @@ -409,6 +409,38 @@ describe("a let or const that names a function or class", () => { }); }); + // `bun pm diff` compares what the printer prints with syntax minified and no renamer. + test("bun pm diff shows a function moved out of its variable as a change", async () => { + using dir = tempDir("transpiler-named-by-declaration-diff", { + "a/m.js": "export function run(x) {\n const step = () => x + 1;\n return step();\n}\n", + "b/m.js": "export function run(x) {\n return (() => x + 1)();\n}\n", + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "diff", "./a", "./b"], + env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1", COLUMNS: "120" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const changed = stdout + .replace(/\x1b\[[0-9;]*[mK]/g, "") + .split("\n") + .filter(line => /^ +[\d:]+ │[-+] /.test(line)); + expect({ changed, stderr, exitCode }).toEqual({ + changed: [ + " 2 │- const step = () => x + 1;", + " 3 │- return step();", + " 2 │+ return (() => x + 1)();", + ], + stderr: "", + exitCode: 0, + }); + }); + // A fold leaves the function in a declaration that stays, or in a place that names it: the rows give "a", "a", "k" and "sink". // https://github.com/oven-sh/bun/pull/42691 and https://github.com/oven-sh/bun/pull/42585 keep such a function away from the name. test.todo("a function behind a fold takes no name from where it ends", async () => { diff --git a/test/cli/install/bun-pm-diff.test.ts b/test/cli/install/bun-pm-diff.test.ts index 4ed554c77dec..fcf62f324209 100644 --- a/test/cli/install/bun-pm-diff.test.ts +++ b/test/cli/install/bun-pm-diff.test.ts @@ -838,21 +838,6 @@ describe.concurrent("bun pm diff (canonical re-print)", () => { expect(exitCode).toBe(0); }); - // https://github.com/oven-sh/bun/issues/20398 - test("a function moved out of the variable that names it is a change", async () => { - const { text, exitCode } = await pretty({ - "a/m.js": "export function run(x) {\n const step = () => x + 1;\n return step();\n}\n", - "b/m.js": "export function run(x) {\n return (() => x + 1)();\n}\n", - }); - expect(changedLines(text)).toEqual([ - " 2 │- const step = () => x + 1;", - " 3 │- return step();", - " 2 │+ return (() => x + 1)();", - ]); - expect(text).toMatch(/\nm\.js ─+ \+1 -2\n/); - expect(exitCode).toBe(0); - }); - test("two minified builds with different mangled names diff down to the real change", async () => { // Same program, names handed out differently by the "minifier", plus one genuine edit (* 2 → * 3) and one // inserted helper in b. Long enough on one line to count as minified. From b066c2a3a6a2ac959105902c95d70d6579329999 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:09:55 +0000 Subject: [PATCH 12/15] aot: name a function that a let or const names and the next return reads The lowering treats the initializer of a let or const that is read once, by the return behind it, as the returned value: plain Bun moves it there. Plain Bun now leaves a declaration that names an anonymous function, arrow or class, so the lowering leaves it too and the value takes the name. --- src/aot/lower/statement/declarations.rs | 22 ++++++++++++++++--- test/aot/ir/lower/tail-calls/named-once.ts | 12 ++++++++-- ...e-syntax-takes-the-name-of-its-variable.ts | 14 ++++++++++++ ...ead-once-takes-the-name-of-its-variable.js | 7 ++++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 test/aot/parallel/inferred-names/a-function-read-once-behind-type-syntax-takes-the-name-of-its-variable.ts diff --git a/src/aot/lower/statement/declarations.rs b/src/aot/lower/statement/declarations.rs index 1338b4f86867..0316645539ad 100644 --- a/src/aot/lower/statement/declarations.rs +++ b/src/aot/lower/statement/declarations.rs @@ -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; @@ -39,6 +39,22 @@ impl FileLowering<'_, '_, '_, '_> { } } + /// Whether plain Bun leaves the `let` or `const` of `initializer` in front of a `return` of its name, because the declaration gives the `name`. + 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) -> Option<(StmtId, IdList)> { let hir = self.hir; let index = hir.ids(list).position(|statement| { @@ -98,7 +114,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; } diff --git a/test/aot/ir/lower/tail-calls/named-once.ts b/test/aot/ir/lower/tail-calls/named-once.ts index 938b08c745b2..f3e2f23b02df 100644 --- a/test/aot/ir/lower/tail-calls/named-once.ts +++ b/test/aot/ir/lower/tail-calls/named-once.ts @@ -12,11 +12,14 @@ // CHECK-NOT: ReturnCall // CHECK: Return [[first]] // 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 [[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; @@ -53,6 +56,11 @@ export function returnsFunction() { return made; } +export function returnsFunctionOfBranch(n: number) { + const made = n ? function () {} : null; + return made; +} + export function returnsFunctionOfVar() { var made = function () {}; return made; diff --git a/test/aot/parallel/inferred-names/a-function-read-once-behind-type-syntax-takes-the-name-of-its-variable.ts b/test/aot/parallel/inferred-names/a-function-read-once-behind-type-syntax-takes-the-name-of-its-variable.ts new file mode 100644 index 000000000000..33d40144047c --- /dev/null +++ b/test/aot/parallel/inferred-names/a-function-read-once-behind-type-syntax-takes-the-name-of-its-variable.ts @@ -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 = (() => {}); 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 = (x: T) => x; return q; }); diff --git a/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js b/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js index 7822b034b860..ceae400a489e 100644 --- a/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js +++ b/test/aot/parallel/inferred-names/a-function-read-once-takes-the-name-of-its-variable.js @@ -21,6 +21,13 @@ row("a frame", () => { const q = () => frame(new Error()); const top = q(); retu 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; }); From 39810c312667633204941c7030a78614c45889b3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 04:46:04 +0000 Subject: [PATCH 13/15] aot: leave the value of a let or const of Bun's own modules in the return Bun's own modules are a bundle. A bundle still moves the value of a let or const that is read once, so a function there takes no name. --- src/aot/lower/statement/declarations.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/aot/lower/statement/declarations.rs b/src/aot/lower/statement/declarations.rs index 0316645539ad..cdeb05a4be9f 100644 --- a/src/aot/lower/statement/declarations.rs +++ b/src/aot/lower/statement/declarations.rs @@ -9,6 +9,7 @@ use bun_sema::program::Sym; use smallvec::SmallVec; use super::statements_behind; +use crate::builtins::SourceDialect; use crate::builtins::id::BuiltinId; use crate::ir::{BlockId, DeclaredType, ValueId, VariableKey}; use crate::lower::call::emit_call; @@ -39,17 +40,19 @@ impl FileLowering<'_, '_, '_, '_> { } } - /// Whether plain Bun leaves the `let` or `const` of `initializer` in front of a `return` of its name, because the declaration gives the `name`. + /// Whether plain Bun leaves the `let` or `const` of `initializer` in front of a `return` of its name, because the declaration gives the `name`. Bun's own modules are a bundle, which moves the value. fn is_named_by_its_declaration(&mut self, initializer: ExprId) -> bool { let hir = self.hir; + let is_bundled = self.source.dialect() == SourceDialect::Runtime; match hir[initializer].kind { - ExprKind::Fn(function) => match hir[function].kind { + ExprKind::Fn(function) if !is_bundled => 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) + (!is_bundled && hir[class].name.is_none()) + || self.has_standard_class_decorators(class) } _ => false, } From 4287928799b9aff9831612614f69b3278478cd3b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:25:08 +0000 Subject: [PATCH 14/15] aot: name a returned function in Bun's own modules too, and hold each arm by a test The lowering no longer asks whether a file is one of Bun's own modules: a function, arrow or class that a let or const names takes the name there too. The IR test named-once.ts holds the arrow and the class beside the function. dead-zone-switch-constant-read-by-a-closure.js expects the name of its arrow. The driver of generated programs no longer passes over names of functions that a block declares or that a constant read once holds: plain Bun and the executable agree on them now. The no-bundle condition and the cache version come from #45022. Co-authored-by: Peter Steinberger --- src/aot/lower/statement/declarations.rs | 8 ++---- test/aot/fuzz/fuzzilli/BunCallbacks.swift | 2 +- test/aot/fuzz/fuzzilli/run-batch.ts | 27 ------------------- ...es-linked-below-classes-evaluated-later.ts | 3 +-- ...-zone-switch-constant-read-by-a-closure.js | 3 +-- test/aot/ir/lower/tail-calls/named-once.ts | 16 +++++++++++ 6 files changed, 21 insertions(+), 38 deletions(-) diff --git a/src/aot/lower/statement/declarations.rs b/src/aot/lower/statement/declarations.rs index cdeb05a4be9f..b64648577d11 100644 --- a/src/aot/lower/statement/declarations.rs +++ b/src/aot/lower/statement/declarations.rs @@ -9,7 +9,6 @@ use bun_sema::program::Sym; use smallvec::SmallVec; use super::statements_behind; -use crate::builtins::SourceDialect; use crate::builtins::id::BuiltinId; use crate::ir::{BlockId, DeclaredType, ValueId, VariableKey}; use crate::lower::call::emit_call; @@ -40,19 +39,16 @@ impl FileLowering<'_, '_, '_, '_> { } } - /// Whether plain Bun leaves the `let` or `const` of `initializer` in front of a `return` of its name, because the declaration gives the `name`. Bun's own modules are a bundle, which moves the value. fn is_named_by_its_declaration(&mut self, initializer: ExprId) -> bool { let hir = self.hir; - let is_bundled = self.source.dialect() == SourceDialect::Runtime; match hir[initializer].kind { - ExprKind::Fn(function) if !is_bundled => match hir[function].kind { + ExprKind::Fn(function) => match hir[function].kind { FnKind::Arrow => true, FnKind::Expr => hir[function].name.is_none(), _ => false, }, ExprKind::Class(class) => { - (!is_bundled && hir[class].name.is_none()) - || self.has_standard_class_decorators(class) + hir[class].name.is_none() || self.has_standard_class_decorators(class) } _ => false, } diff --git a/test/aot/fuzz/fuzzilli/BunCallbacks.swift b/test/aot/fuzz/fuzzilli/BunCallbacks.swift index 3f24230cab40..3db647c48444 100644 --- a/test/aot/fuzz/fuzzilli/BunCallbacks.swift +++ b/test/aot/fuzz/fuzzilli/BunCallbacks.swift @@ -305,7 +305,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) { diff --git a/test/aot/fuzz/fuzzilli/run-batch.ts b/test/aot/fuzz/fuzzilli/run-batch.ts index bc257cac947c..a98d653ded12 100644 --- a/test/aot/fuzz/fuzzilli/run-batch.ts +++ b/test/aot/fuzz/fuzzilli/run-batch.ts @@ -952,31 +952,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; @@ -2204,8 +2179,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); diff --git a/test/aot/fuzz/prototypes-linked-below-classes-evaluated-later.ts b/test/aot/fuzz/prototypes-linked-below-classes-evaluated-later.ts index c83c9db256b8..0316e718fb59 100644 --- a/test/aot/fuzz/prototypes-linked-below-classes-evaluated-later.ts +++ b/test/aot/fuzz/prototypes-linked-below-classes-evaluated-later.ts @@ -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); diff --git a/test/aot/ir/lower/scopes/dead-zone-switch-constant-read-by-a-closure.js b/test/aot/ir/lower/scopes/dead-zone-switch-constant-read-by-a-closure.js index 4043e127ac9a..beb4ad50f052 100644 --- a/test/aot/ir/lower/scopes/dead-zone-switch-constant-read-by-a-closure.js +++ b/test/aot/ir/lower/scopes/dead-zone-switch-constant-read-by-a-closure.js @@ -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: diff --git a/test/aot/ir/lower/tail-calls/named-once.ts b/test/aot/ir/lower/tail-calls/named-once.ts index f3e2f23b02df..ea2cf08a0131 100644 --- a/test/aot/ir/lower/tail-calls/named-once.ts +++ b/test/aot/ir/lower/tail-calls/named-once.ts @@ -11,12 +11,18 @@ // 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=[[ofVar]] +// CHECK: function [[arrowOfLet]] name="pointed" +// CHECK: function [[classOfConst]] name="Shape" // CHECK: function [[ofConst]] name="made" // CHECK: function [[unnamed]] module= // CHECK: function [[ofVar]] name="made" @@ -56,6 +62,16 @@ 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; From 60e8b133b28fcd2e807546606cb9a5bd66f2a9c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 11 Nov 2026 21:37:13 +0000 Subject: [PATCH 15/15] test: the class of describe(MyClass) has its name under bun test too The known list of cases that end otherwise under bun test --aot held this one: the transpiler of bun test moved the class into its use, where it got no name. Both runs give the title MyClass now. --- test/aot/the-suites-of-the-test-runner-run-compiled.known.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/aot/the-suites-of-the-test-runner-run-compiled.known.txt b/test/aot/the-suites-of-the-test-runner-run-compiled.known.txt index f75d7554bc3d..74c0f0802c20 100644 --- a/test/aot/the-suites-of-the-test-runner-run-compiled.known.txt +++ b/test/aot/the-suites-of-the-test-runner-run-compiled.known.txt @@ -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