From 217258a6094cbfc622383f8e06bcb160688aa583 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Oct 2026 02:27:04 -0700 Subject: [PATCH 1/2] fix(transpiler): preserve single-use inferred names --- docs/bundler/minifier.mdx | 1 + src/js_parser/visit/mod.rs | 32 +++++++++++++++++++ src/jsc/RuntimeTranspilerCache.rs | 3 +- test/bundler/bundler_minify.test.ts | 48 ++++++++++++++++++++++++++++ test/cli/run/run-eval.test.ts | 49 +++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/docs/bundler/minifier.mdx b/docs/bundler/minifier.mdx index 3f5c0a58a971..24ce002b16d1 100644 --- a/docs/bundler/minifier.mdx +++ b/docs/bundler/minifier.mdx @@ -1188,6 +1188,7 @@ await Bun.build({ ``` `--keep-names` preserves the `.name` property on functions and classes while still minifying the identifiers themselves. +With identifier minification disabled, this also preserves names inferred from single-use local bindings, such as `const callback = () => {}`. ## Combined Example diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 0a8f52c60d92..0602ebc01ff5 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1696,6 +1696,25 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } } + // A single-use lowered function can initialize its hoisted binding + // directly. Otherwise a later parse infers the synthetic binding's name. + if p.options.features.minify_keep_names { + for var_decl in var_decls.iter_mut() { + let Some(Expr { data: ExprData::EIdentifier(id), .. }) = var_decl.value else { + continue; + }; + if p.symbols[id.ref_.inner_index() as usize].use_count_estimate != 1 { + continue; + } + if let Some(decl) = let_decls.iter_mut().find(|decl| { + matches!(decl.binding.data, BData::BIdentifier(binding) if binding.r#ref == id.ref_) + }) { + var_decl.value = decl.value.take(); + p.ignore_usage(id.ref_); + } + } + let_decls.retain(|decl| decl.value.is_some()); + } before.clear(); before.reserve( @@ -1998,6 +2017,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; let id = ident_ptr.r#ref; + // Lowered block functions use synthetic bindings. Preserve the + // existing inlining for those; their name comes from the hoisted assignment. + let is_lowered_block_function = + p.hoisted_ref_for_sloppy_mode_block_fn.contains_key(&id); + // Authored bindings supply the inferred name of anonymous + // functions and classes; substituting them would erase it. + if replacement.is_anonymous_named() + && (!p.options.bundle || p.options.features.minify_keep_names) + && !is_lowered_block_function + { + break; + } + let symbol: &Symbol = &p.symbols[id.inner_index() as usize]; // Try to substitute the identifier with the initializer. This will diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index cafb4f0281bf..8cc4658a88f0 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: Preserve inferred function and class names during single-use substitution. +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..d41bb5b85fce 100644 --- a/test/bundler/bundler_minify.test.ts +++ b/test/bundler/bundler_minify.test.ts @@ -3,6 +3,20 @@ import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; import { itBundled, type BundlerTestInput } from "./expectBundled"; describe("bundler", () => { + itBundled("minify/SingleUseInferredNamesLoweredBlockFunction", { + files: { + "/entry.js": ` + if (1) { + var a = 1; + function f() {} + } + console.log(f.name); + `, + }, + keepNames: true, + run: { runtime: "node", stdout: "f" }, + }); + // A direct eval at the top level of a file the bundler wraps in a CommonJS // closure can reach that file's top-level names, so they are not renamed. itBundled("minify/DirectEvalKeepsTopLevelNamesOfWrappedFile", { @@ -394,6 +408,40 @@ describe("bundler", () => { keepNames: true, target: "bun", }); + for (const { label, options, preserveNames } of [ + { label: "Default", options: {}, preserveNames: true }, + { label: "KeepNames", options: { minifySyntax: true, keepNames: true }, preserveNames: true }, + { label: "Minified", options: { minifySyntax: true, keepNames: false }, preserveNames: false }, + ]) { + itBundled(`minify/SingleUseInferredNames${label}`, { + files: { + "/entry.js": /* js */ ` + export {}; + { function declared() {} console.log(JSON.stringify(declared.name)); } + { const arrow = () => {}; console.log(JSON.stringify(arrow.name)); } + { let anonymous = function() {}; console.log(JSON.stringify(anonymous.name)); } + { const C = class {}; console.log(JSON.stringify(C.name)); } + { const callback = () => 42; console.log(callback()); } + { const C = class { value() { return 42; } }; console.log(new C().value()); } + `, + }, + backend: "api", + target: "bun", + minifyIdentifiers: false, + ...options, + run: { + runtime: "node", + validate({ stdout }) { + const result = stdout + .trim() + .split("\n") + .map(line => JSON.parse(line)); + expect(result.slice(4)).toEqual([42, 42]); + if (preserveNames) expect(result.slice(0, 4)).toEqual(["declared", "arrow", "anonymous", "C"]); + }, + }, + }); + } itBundled("minify/PrivateIdentifiersNameCollision", { files: { "/entry.js": /* js */ ` diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0c7462e3b5ec..a958294d5f11 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -5,6 +5,55 @@ import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join, sep } from "path"; +test.concurrent.each([ + { label: "block function", source: "{ function f() {} console.log(f.name); }", expected: "f" }, + { label: "arrow initializer", source: "{ const f = () => {}; console.log(f.name); }", expected: "f" }, + { label: "async arrow initializer", source: "{ const f = async () => {}; console.log(f.name); }", expected: "f" }, + { label: "function initializer", source: "{ let f = function() {}; console.log(f.name); }", expected: "f" }, + { label: "generator initializer", source: "{ const f = function*() {}; console.log(f.name); }", expected: "f" }, + { label: "class initializer", source: "{ const C = class {}; console.log(C.name); }", expected: "C" }, + { + label: "explicit function name", + source: "{ const alias = function original() {}; console.log(alias.name); }", + expected: "original", + }, + { + label: "explicit class name", + source: "{ const alias = class Original {}; console.log(alias.name); }", + expected: "Original", + }, + { + label: "mutable function binding", + source: '{ function f() { return f; } const original = f; f = "rebound"; console.log(original()); }', + expected: "rebound", + }, + { + label: "mutable class binding", + source: + '{ let C = class { static current() { return C; } }; const original = C; C = "rebound"; console.log(original.current()); }', + expected: "rebound", + }, + { + label: "recursive block function", + source: "{ function f(n) { return n === 0 ? 1 : n * f(n - 1); } console.log(f(5)); }", + expected: "120", + }, + { + label: "direct eval binding access", + source: `{ function f() { return 7; } console.log(eval("f.name + ':' + f()")); }`, + expected: "f:7", + }, +])("eval preserves $label semantics", async ({ source, expected }) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: expected + "\n", stderr: "", exitCode: 0 }); +}); + for (const flag of ["-e", "--print"]) { describe(`bun ${flag}`, () => { test("it works", async () => { From 699830fcb4628c07c7bc0e1a7037373c8d1e6919 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:28:12 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- src/js_parser/visit/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 0602ebc01ff5..9db518cda318 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1700,7 +1700,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // directly. Otherwise a later parse infers the synthetic binding's name. if p.options.features.minify_keep_names { for var_decl in var_decls.iter_mut() { - let Some(Expr { data: ExprData::EIdentifier(id), .. }) = var_decl.value else { + let Some(Expr { + data: ExprData::EIdentifier(id), + .. + }) = var_decl.value + else { continue; }; if p.symbols[id.ref_.inner_index() as usize].use_count_estimate != 1 {