From 64df085e181f38d0191a7f02680ab2fe54903653 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:11:44 +0000 Subject: [PATCH 1/7] js_parser: keep a sloppy block-level function as a declaration when no renamer runs In sloppy code a function declared in a block is also a `var` of the enclosing function (Annex B). `hoist_symbols` makes that `var` only when a renamer runs, but `visit_stmts` lowered the declaration to `let f = function(){}` in every case. So `bun file.cjs`, `Bun.Transpiler` and `bun build --no-bundle` printed the `let` alone, and the function was gone after its block. `visit_stmts` now lowers a block-level function only when the `let` is the whole answer: a renamer runs, the scope is strict, the block is at the top level of REPL input, or a lexical binding of the same name takes the `var` away. In every other case the declaration stays where it is and the engine applies Annex B. The transpiler cache version moves to 35 because the printed text changes. --- src/js_parser/p.rs | 19 ++ src/js_parser/visit/mod.rs | 1 + src/jsc/RuntimeTranspilerCache.rs | 3 +- .../preserve-use-strict-cjs.test.ts | 311 +++++++++++++++++- test/bundler/transpiler/transpiler.test.js | 81 ++++- test/js/bun/transpiler/repl-transform.test.ts | 25 ++ 6 files changed, 434 insertions(+), 6 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index daf1e90b0626..47fbbd54f057 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -329,6 +329,8 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool pub(crate) macro_call_count: MacroCallCountType, pub(crate) hoisted_ref_for_sloppy_mode_block_fn: RefRefMap, + /// Sloppy block-level functions with no Annex B `var`: a lexical binding of the same name is in its way. + pub(crate) sloppy_mode_block_fn_without_var: RefMap, // Used for forcing CommonJS pub(crate) has_with_scope: bool, @@ -3606,6 +3608,17 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> self.options.bundle || self.options.features.minify_identifiers } + /// Whether a block-level function becomes a `let`. Otherwise it stays a declaration for the engine's Annex B. + pub(crate) fn lowers_block_level_function(&self, name: Ref) -> bool { + // `hoist_symbols` makes the `var` twin of a sloppy function only for a renamer. + self.will_use_renamer() + // A function-level "use strict" is not printed, so strict code can run as sloppy text. + || self.is_strict_mode() + // The REPL wraps each input in a function, which would own the `var` of a top-level block. + || (self.options.repl_mode && self.fn_or_arrow_data_visit.is_outside_fn_or_arrow) + || self.sloppy_mode_block_fn_without_var.contains_key(&name) + } + 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 @@ -3839,6 +3852,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> .hoisted_ref_for_sloppy_mode_block_fn .remove(&original_member_ref); } + } else if self.symbols[symbol_idx].kind + == js_ast::symbol::Kind::HoistedFunction + { + self.sloppy_mode_block_fn_without_var + .insert(original_member_ref, ()); } continue 'next_member; } @@ -9977,6 +9995,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> has_classic_runtime_warned: false, macro_call_count: 0, hoisted_ref_for_sloppy_mode_block_fn: Default::default(), + sloppy_mode_block_fn_without_var: Default::default(), has_with_scope: false, has_top_level_function_merged_with_var: false, is_file_considered_to_have_esm_exports: false, diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index f98cdc96e5dd..932d463f93c3 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -1581,6 +1581,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> && p.symbols[data.func.name.unwrap().ref_.inner_index() as usize] .kind == SymbolKind::HoistedFunction + && p.lowers_block_level_function(data.func.name.unwrap().ref_) { break 'list_getter &mut before; } diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index ff170d4c8c38..8f452dde0bb8 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -64,7 +64,8 @@ bun_core::declare_scope!(cache, visible); /// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. -const EXPECTED_VERSION: u32 = 34; +/// Version 35: A block-level function in sloppy code stays a declaration instead of becoming a `let`. +const EXPECTED_VERSION: u32 = 35; /// 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/transpiler/preserve-use-strict-cjs.test.ts b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts index a948aab3b2b3..5ece01649bab 100644 --- a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts +++ b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts @@ -1,5 +1,5 @@ -import { expect, test } from "bun:test"; -import { bunRun } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunRun, tempDir } from "harness"; import path from "path"; test.concurrent(`"use strict'; preserves strict mode in CJS`, async () => { @@ -9,3 +9,310 @@ test.concurrent(`"use strict'; preserves strict mode in CJS`, async () => { test.concurrent(`sloppy mode by default in CJS`, async () => { expect(await bunRun(path.join(import.meta.dir, "sloppy-mode-fixture.ts"))).toSpawn(); }); + +describe("block-level function declarations in CommonJS", () => { + // Annex B: in sloppy code the name is also a `var` of the enclosing function, + // assigned when the declaration is evaluated. Every expected value is what node prints. + test.concurrent("sloppy code sees the function after its block", async () => { + using dir = tempDir("block-level-function", { + "sloppy.cjs": ` +const attempt = fn => { + try { + return fn(); + } catch (e) { + return e.name; + } +}; +const out = {}; + +{ function topLevelBlock() { return 9; } } +out.topLevelBlock = attempt(() => topLevelBlock()); + +if (true) { function ifBlock() { return 2; } } +out.ifBlock = attempt(() => ifBlock()); + +if (true) function ifBody() { return 1; } +out.ifBody = typeof ifBody; + +try { function tryBlock() { return 5; } } catch {} +out.tryBlock = attempt(() => tryBlock()); + +label: function labelled() { return 6; } +out.labelled = attempt(() => labelled()); + +if (typeof featureTest !== "function") { function featureTest() { return "own"; } } +out.featureTest = typeof featureTest === "function" ? featureTest() : "fallback"; + +// https://github.com/oven-sh/bun/issues/23633 +function outer() { return "outer"; } +out.shadow = [outer()]; +{ + function outer() { return "block"; } + out.shadow.push(outer()); +} +out.shadow.push(outer()); + +out.inFunction = attempt(function () { + { function inner() { return "in"; } } + return inner(); +}); + +// The parser template that jison emits (https://github.com/oven-sh/bun/issues/25737). +out.jison = attempt(function parse() { + _token_stack: function lex() { return "token"; } + return lex(); +}); + +out.switchCase = attempt(() => + (function (x) { + switch (x) { + case 1: function s() { return 3; } + case 2: return s(); + } + })(2), +); + +out.beforeBlock = attempt(function () { + const seen = [typeof f, f === undefined]; + { function f() {} } + seen.push(typeof f); + return seen; +}); + +out.neverEntered = attempt(function () { + if (false) { function f() {} } + return String(f); +}); + +out.leftBeforeDeclaration = attempt(function () { + exit: { break exit; function f() {} } + return typeof f; +}); + +out.callBeforeDeclaration = attempt(function () { + { var r = f(); function f() { return 7; } } + return r; +}); + +out.assignBeforeDeclaration = attempt(function () { + { f = 1; function f() {} } + return typeof f; +}); + +out.assignBeforeDeclarationWithEval = attempt(function () { + { f = 1; function f() {} eval(""); } + return typeof f; +}); + +out.twoBlocks = attempt(function () { + const seen = []; + { function f() { return 1; } } + seen.push(f()); + { function f() { return 2; } } + seen.push(f()); + return seen; +}); + +out.loop = attempt(function () { + const seen = []; + for (var i = 0; i < 2; i++) { function f() {} seen.push(f); } + return [f === seen[1], seen[0] === seen[1]]; +}); + +out.outerLet = attempt(function () { + let f = 1; + { function f() {} } + return typeof f; +}); + +out.parameter = attempt(() => + (function (f) { + { function f() {} } + return typeof f; + })(1), +); + +out.catchParameter = attempt(function () { + try { throw 1; } catch (f) { { function f() {} } } + return typeof f; +}); + +// A lexical binding of an enclosing block takes the var away, also when +// the transpiler inlines or drops that binding. +out.constOfEnclosingBlock = attempt(function () { + var t = "outer"; + return (function () { + { + const t = 5; + globalThis.sink = t; + { function t() {} } + } + return typeof t; + })(); +}); + +out.letOfEnclosingBlock = attempt(function () { + var f = "outer"; + return (function (x) { + var seen; + { + let f = x(); + seen = f; + { function f() {} } + } + return typeof f; + })(() => 1); +}); + +out.letOfDeadBlock = attempt(function () { + var f = "outer"; + return (function () { + if (false) { + let f = 1; + { function f() {} } + } + return typeof f; + })(); +}); + +out.source = attempt(function () { + { function named(a, b) { return a + b; } } + return [named.name, named.toString().split(" ").join("").startsWith("functionnamed(a,b)")]; +}); + +console.log(JSON.stringify(out)); +module.exports = out; +`, + }); + const result = await bunRun(path.join(String(dir), "sloppy.cjs")); + expect(result).toSpawn(); + expect(JSON.parse(result.stdout)).toEqual({ + topLevelBlock: 9, + ifBlock: 2, + ifBody: "function", + tryBlock: 5, + labelled: 6, + featureTest: "own", + shadow: ["outer", "block", "block"], + inFunction: "in", + jison: "token", + switchCase: 3, + beforeBlock: ["undefined", true, "function"], + neverEntered: "undefined", + leftBeforeDeclaration: "undefined", + callBeforeDeclaration: 7, + assignBeforeDeclaration: "number", + assignBeforeDeclarationWithEval: "number", + twoBlocks: [1, 2], + loop: [true, false], + outerLet: "number", + parameter: "number", + catchParameter: "function", + constOfEnclosingBlock: "string", + letOfEnclosingBlock: "string", + letOfDeadBlock: "string", + source: ["named", true], + }); + }); + + // The inner function gets no `var`: the function of the enclosing block has the name. + // test262 annexB/language/function-code/block-decl-nested-blocks-with-fun-decl.js. V8 in Node 20 and later returns 2. + test.concurrent("a same-name function in a nested block stays in its block", async () => { + using dir = tempDir("block-level-function", { + "nested.cjs": ` +function g() { + { + function f() { return 1; } + { function f() { return 2; } } + } + return f(); +} +console.log(g()); +module.exports = g; +`, + }); + expect(await bunRun(path.join(String(dir), "nested.cjs"))).toSpawn("1"); + }); + + test.concurrent("also through require(), import() and a .js file", async () => { + const lib = (value: string) => `{ function z() { return "${value}"; } }\nmodule.exports = z();\n`; + using dir = tempDir("block-level-function", { + "entry.cjs": ` +const out = { require: require("./required.cjs"), js: require("./plain.js") }; +import("./imported.cjs").then(imported => { + out.import = imported.default; + console.log(JSON.stringify(out)); +}); +`, + "required.cjs": lib("require"), + "plain.js": lib("js"), + "imported.cjs": lib("import"), + }); + const result = await bunRun(path.join(String(dir), "entry.cjs")); + expect(result).toSpawn(); + expect(JSON.parse(result.stdout)).toEqual({ require: "require", js: "js", import: "import" }); + }); + + test.concurrent("also in bun -e", async () => { + const code = `const { sep } = require("node:path"); { function z() { return typeof sep; } } console.log(z());`; + expect(await bunRun(["-e", code])).toSpawn("string"); + }); + + test.concurrent("bun build --no-bundle prints the declaration", async () => { + const source = "{\n function z() {\n return 9;\n }\n}\nmodule.exports = z();"; + using dir = tempDir("block-level-function", { "lib.cjs": source }); + expect(await bunRun(["build", "--no-bundle", path.join(String(dir), "lib.cjs")])).toSpawn(source); + }); + + // A "use strict" that is not printed leaves these as sloppy text, so the + // engine cannot be trusted with them: the function must stay block scoped. + test.concurrent("strict code keeps the function block scoped", async () => { + const body = ` + function f() { return "outer"; } + { function f() { return "block"; } } + return f();`; + using dir = tempDir("block-level-function", { + "entry.cjs": ` +const out = {}; +out.strictFunction = (function () { + "use strict";${body} +})(); +out.nestedInStrictFunction = (function () { + "use strict"; + return (function () {${body} + })(); +})(); +out.classMethod = new (class { + method() {${body} + } +})().method(); +out.strictFile = require("./strict-file.cjs"); +import("./module.mjs").then(module => { + out.module = module.default; + console.log(JSON.stringify(out)); +}); +`, + "strict-file.cjs": `"use strict"; +module.exports = (function () {${body} +})(); +`, + "module.mjs": `export default (function () {${body} +})(); +`, + }); + const result = await bunRun(path.join(String(dir), "entry.cjs")); + expect(result).toSpawn(); + expect(JSON.parse(result.stdout)).toEqual({ + strictFunction: "outer", + nestedInStrictFunction: "outer", + classMethod: "outer", + strictFile: "outer", + module: "outer", + }); + }); + + test.concurrent('bun -e with "use strict" keeps the function block scoped', async () => { + const code = `"use strict"; const { sep } = require("node:path"); function f() { return "outer"; } { function f() { return "block"; } } console.log(f(), typeof sep);`; + expect(await bunRun(["-e", code])).toSpawn("outer string"); + }); +}); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 2ec82493d4ff..0e0bffc51a0c 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -718,7 +718,7 @@ describe("Bun.Transpiler", () => { exp("if (x) {} else function g(): void;\nfor (z in {}) {}", "if (x) {}\nfor (z in {}) {}"); // A function declaration with a body in the same position is still wrapped. - exp("if(l)function f(ag): g {}\nfor (g in {}) {}", "if (l) {\n let f = function(ag) {};\n}\nfor (g in {}) {}"); + exp("if(l)function f(ag): g {}\nfor (g in {}) {}", "if (l) {\n function f(ag) {}\n}\nfor (g in {}) {}"); // The exact fuzz repro: ts loader, dead-code elimination, trailing \r. const dce = new Bun.Transpiler({ loader: "ts", target: "browser", deadCodeElimination: true }); @@ -5686,8 +5686,7 @@ describe("export of a block-scoped function declaration", () => { it("does not affect block-level function declarations in sloppy mode", () => { const transpiler = new Bun.Transpiler({ loader: "js" }); const out = transpiler.transformSync("{\n function f() {}\n}\nmodule.exports = f;"); - expect(out).toContain("let f = function"); - expect(out).toContain("module.exports = f"); + expect(out).toBe("{\n function f() {}\n}\nmodule.exports = f;\n"); }); it("reports the error when running a module with this pattern", async () => { @@ -5706,6 +5705,82 @@ describe("export of a block-scoped function declaration", () => { }); }); +describe("block-level function declarations", () => { + const print = (code, options) => new Bun.Transpiler({ loader: "js", ...options }).transformSync(code); + + // In sloppy code the name is also a `var` of the enclosing function (Annex B). + // Without a renamer there is no second name for that `var`, so the declaration + // is printed as written and the engine does the rest. + it.each([ + ["a block", "{\n function f() {}\n}\nmodule.exports = f;\n"], + ["an if block", "if (x) {\n function f() {}\n}\nmodule.exports = f;\n"], + ["a try block", "try {\n function f() {}\n} catch {}\nmodule.exports = f;\n"], + ["a switch case", "switch (x) {\n case 1:\n function f() {}\n}\nmodule.exports = f;\n"], + ["a block inside a function", "function o() {\n {\n function f() {}\n }\n return f;\n}\n"], + ["a block, after another statement", "{\n g();\n function f() {}\n}\nmodule.exports = f;\n"], + ])("sloppy code keeps the declaration in %s", (_, code) => { + expect(print(code)).toBe(code); + }); + + it("keeps the block around a function that is the body of an if or an else", () => { + expect(print("if (x) function f() {}\nmodule.exports = f;")).toBe( + "if (x) {\n function f() {}\n}\nmodule.exports = f;\n", + ); + expect(print("if (x) {} else function f() {}\nmodule.exports = f;")).toBe( + "if (x) {} else {\n function f() {}\n}\nmodule.exports = f;\n", + ); + }); + + // The bare form is a SyntaxError in strict code, and a file with no CommonJS marker runs as a module. + // Inside the block the function is assigned when the label runs, not at function entry as the bare form is. + it("keeps the block around a labelled function", () => { + expect(print("l: function f() {}\nmodule.exports = f;")).toBe("l: {\n function f() {}\n}\nmodule.exports = f;\n"); + }); + + // Annex B makes no `var` when a lexical binding of an enclosing scope has the name. + it.each([ + [ + "a let", + "{\n let f = g();\n {\n function f() {}\n }\n}\nmodule.exports = 1;\n", + "{\n let f = g();\n {\n let f = function() {};\n }\n}\nmodule.exports = 1;\n", + ], + [ + "a function", + "{\n function f() {}\n {\n function f() {}\n }\n}\nmodule.exports = f;\n", + "{\n function f() {}\n {\n let f = function() {};\n }\n}\nmodule.exports = f;\n", + ], + ])("sloppy code lowers a function to let inside the block of %s with its name", (_, code, expected) => { + expect(print(code)).toBe(expected); + }); + + // A strict scope has no `var` to lose, and its "use strict" may not be printed. + it.each([ + ['a function with "use strict"', 'function o() {\n "use strict";\n {\n function f() {}\n }\n}'], + ['a file with "use strict"', '"use strict";\n{\n function f() {}\n}\nmodule.exports = 1;'], + ["a class method", "class A {\n m() {\n {\n function f() {}\n }\n }\n}"], + ["a module", "{\n function f() {}\n}\nexport {};"], + ])("strict code is still lowered to let in %s", (_, code) => { + const out = print(code); + expect(out).toContain("let f = function() {};"); + expect(out).not.toContain("function f()"); + }); + + it("a renamer still gets the let and the var alias", () => { + expect(print("{\n function f() {}\n}\nmodule.exports = f;", { minify: { identifiers: true } })).toMatch( + /^\{\n let (\w+) = function\(\) \{\};\n (\w+) = \1;\n\}\nmodule\.exports = \2;\nvar \2;\n$/, + ); + }); + + // Each REPL input runs inside a wrapper function, which would own the `var` of a top-level block. + it("REPL input is lowered to let at its top level only", () => { + const out = print("if (x) {\n function f() {}\n}\nfunction o() {\n {\n function g() {}\n }\n}", { + replMode: true, + }); + expect(out).toContain("let f = function() {};"); + expect(out).toContain(" {\n function g() {}\n }"); + }); +}); + describe("using declarations in switch statements", () => { const reparse = out => new Bun.Transpiler({ loader: "js" }).transformSync(out); diff --git a/test/js/bun/transpiler/repl-transform.test.ts b/test/js/bun/transpiler/repl-transform.test.ts index 4f811b4762e3..7f3d1b3a42a7 100644 --- a/test/js/bun/transpiler/repl-transform.test.ts +++ b/test/js/bun/transpiler/repl-transform.test.ts @@ -178,6 +178,31 @@ describe("Bun.Transpiler replMode", () => { expect(results[1]).toBeInstanceOf(context.Counter); expect(typeof context.Counter).toBe("function"); }); + + // Each line runs inside a wrapper function. If a sloppy block-level function stayed a + // declaration there, its Annex B `var` would belong to the wrapper and hide the global. + test("a block-level function does not hide a function of an earlier line", async () => { + const { results } = await runReplSession([ + "function w() { return 1; }", + "if (false) { function w() { return 2; } } w()", + "exit: { break exit; function w() { return 2; } } w()", + "if (typeof w !== 'function') { function w() { return 2; } } w()", + ]); + + expect(results.slice(1)).toEqual([1, 1, 1]); + }); + + // Inside a function of the line, the `var` belongs to that function, as it does in a file. + test("a block-level function inside a function is visible after its block", async () => { + const { results } = await runReplSession([ + "function parse() { _token_stack: function lex() { return 'token'; } return lex(); }", + "parse()", + "function w() { return 1; }", + "if (false) { function w() { return 2; } } [w(), (function () { { function n() {} } return typeof n; })()]", + ]); + + expect([results[1], results[3]]).toEqual(["token", [1, "function"]]); + }); }); describe("object literal detection", () => { From f2a6c8080d3ec5b61ce4adfa0f26256499e1d6a5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:20:18 +0000 Subject: [PATCH 2/7] test: assert the whole printed text for the strict and REPL cases --- test/bundler/transpiler/transpiler.test.js | 36 ++++++++++++++-------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 0e0bffc51a0c..8f34fb1e33ea 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -5755,14 +5755,25 @@ describe("block-level function declarations", () => { // A strict scope has no `var` to lose, and its "use strict" may not be printed. it.each([ - ['a function with "use strict"', 'function o() {\n "use strict";\n {\n function f() {}\n }\n}'], - ['a file with "use strict"', '"use strict";\n{\n function f() {}\n}\nmodule.exports = 1;'], - ["a class method", "class A {\n m() {\n {\n function f() {}\n }\n }\n}"], - ["a module", "{\n function f() {}\n}\nexport {};"], - ])("strict code is still lowered to let in %s", (_, code) => { - const out = print(code); - expect(out).toContain("let f = function() {};"); - expect(out).not.toContain("function f()"); + [ + 'a function with "use strict"', + 'function o() {\n "use strict";\n {\n function f() {}\n }\n}\n', + "function o() {\n {\n let f = function() {};\n }\n}\n", + ], + [ + 'a file with "use strict"', + '"use strict";\n{\n function f() {}\n}\nmodule.exports = 1;\n', + "{\n let f = function() {};\n}\nmodule.exports = 1;\n", + ], + [ + "a class method", + "class A {\n m() {\n {\n function f() {}\n }\n }\n}\n", + "class A {\n m() {\n {\n let f = function() {};\n }\n }\n}\n", + ], + ["a module", "{\n function f() {}\n}\n\nexport {};\n", "{\n let f = function() {};\n}\n\nexport {};\n"], + ])("strict code is still lowered to let in %s", (_, code, expected) => { + // Whether the directive itself is printed is not the subject here. + expect(print(code).replace(/^ *"use strict";\n/m, "")).toBe(expected); }); it("a renamer still gets the let and the var alias", () => { @@ -5773,11 +5784,10 @@ describe("block-level function declarations", () => { // Each REPL input runs inside a wrapper function, which would own the `var` of a top-level block. it("REPL input is lowered to let at its top level only", () => { - const out = print("if (x) {\n function f() {}\n}\nfunction o() {\n {\n function g() {}\n }\n}", { - replMode: true, - }); - expect(out).toContain("let f = function() {};"); - expect(out).toContain(" {\n function g() {}\n }"); + const input = "if (x) {\n function f() {}\n h(function() {\n {\n function g() {}\n }\n });\n}"; + expect(print(input, { replMode: true })).toBe( + "(() => {\n if (x) {\n let f = function() {};\n h(function() {\n {\n function g() {}\n }\n });\n }\n})();\n", + ); }); }); From 9c567f6281d0be45c4d8cee9f61b2f493ded5a50 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:38:26 +0000 Subject: [PATCH 3/7] js_parser: keep the `let` for a block-level function in statically dead code Dead code elimination removes a `let` together with its dead branch. A function declaration survives the removal because declarations are hoisted, so the body of a function in `if (false) { ... }` stayed in the output. A file with no CommonJS marker runs as a module, and sloppy-only syntax in that body (`var static`, a function named `eval`) then ended the load with a SyntaxError. A block-level function at a statically dead position is lowered to `let` again, so the branch is removed as before. --- src/js_parser/p.rs | 2 ++ .../transpiler/preserve-use-strict-cjs.test.ts | 14 ++++++++------ test/bundler/transpiler/transpiler.test.js | 7 +++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index 47fbbd54f057..e9663d380430 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -3616,6 +3616,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> || self.is_strict_mode() // The REPL wraps each input in a function, which would own the `var` of a top-level block. || (self.options.repl_mode && self.fn_or_arrow_data_visit.is_outside_fn_or_arrow) + // Dead code elimination removes a `let` with its branch. A declaration survives it. + || (self.is_control_flow_dead && self.options.features.dead_code_elimination) || self.sloppy_mode_block_fn_without_var.contains_key(&name) } diff --git a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts index 5ece01649bab..b297640da66e 100644 --- a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts +++ b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts @@ -79,11 +79,6 @@ out.beforeBlock = attempt(function () { return seen; }); -out.neverEntered = attempt(function () { - if (false) { function f() {} } - return String(f); -}); - out.leftBeforeDeclaration = attempt(function () { exit: { break exit; function f() {} } return typeof f; @@ -198,7 +193,6 @@ module.exports = out; jison: "token", switchCase: 3, beforeBlock: ["undefined", true, "function"], - neverEntered: "undefined", leftBeforeDeclaration: "undefined", callBeforeDeclaration: 7, assignBeforeDeclaration: "number", @@ -234,6 +228,14 @@ module.exports = g; expect(await bunRun(path.join(String(dir), "nested.cjs"))).toSpawn("1"); }); + // A file with no CommonJS marker runs as a module, where this text is a SyntaxError. + test.concurrent("a function in statically dead code does not reach the engine", async () => { + using dir = tempDir("block-level-function", { + "dead.js": `if (false) { function o() { var static = 1; } }\nif (false) { function eval() {} }\nconsole.log("loaded");\n`, + }); + expect(await bunRun(path.join(String(dir), "dead.js"))).toSpawn("loaded"); + }); + test.concurrent("also through require(), import() and a .js file", async () => { const lib = (value: string) => `{ function z() { return "${value}"; } }\nmodule.exports = z();\n`; using dir = tempDir("block-level-function", { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 8f34fb1e33ea..165411c56bcf 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -5753,6 +5753,13 @@ describe("block-level function declarations", () => { expect(print(code)).toBe(expected); }); + // Dead code elimination removes a `let` with its branch. A declaration would stay in the output. + it("a function in a statically dead branch is still lowered to let", () => { + expect(print("if (false) {\n function f() {}\n}\nmodule.exports = 1;\n")).toBe( + "if (false) {\n let f = function() {};\n}\nmodule.exports = 1;\n", + ); + }); + // A strict scope has no `var` to lose, and its "use strict" may not be printed. it.each([ [ From b4991c4f0ac54d13830aae9cb6b0685141cda828 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:40:34 +0000 Subject: [PATCH 4/7] js_parser: keep a block-level function a declaration in dead code too This takes back 9c567f6281. The `let` for statically dead code tied the Annex B `var` to the dead code elimination option: a read of the name after `if (false) { function f() {} }` threw under `bun file.cjs` and gave `undefined` under `bun test --coverage` and `bun -p`. It also did not see a block behind a `return`, which is found dead after the visit. The declaration now stays in every mode, so the engine gives node's answer. --- src/js_parser/p.rs | 2 -- .../transpiler/preserve-use-strict-cjs.test.ts | 14 ++++++-------- test/bundler/transpiler/transpiler.test.js | 7 ------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index e9663d380430..47fbbd54f057 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -3616,8 +3616,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool, const SEMA: bool> || self.is_strict_mode() // The REPL wraps each input in a function, which would own the `var` of a top-level block. || (self.options.repl_mode && self.fn_or_arrow_data_visit.is_outside_fn_or_arrow) - // Dead code elimination removes a `let` with its branch. A declaration survives it. - || (self.is_control_flow_dead && self.options.features.dead_code_elimination) || self.sloppy_mode_block_fn_without_var.contains_key(&name) } diff --git a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts index b297640da66e..5ece01649bab 100644 --- a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts +++ b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts @@ -79,6 +79,11 @@ out.beforeBlock = attempt(function () { return seen; }); +out.neverEntered = attempt(function () { + if (false) { function f() {} } + return String(f); +}); + out.leftBeforeDeclaration = attempt(function () { exit: { break exit; function f() {} } return typeof f; @@ -193,6 +198,7 @@ module.exports = out; jison: "token", switchCase: 3, beforeBlock: ["undefined", true, "function"], + neverEntered: "undefined", leftBeforeDeclaration: "undefined", callBeforeDeclaration: 7, assignBeforeDeclaration: "number", @@ -228,14 +234,6 @@ module.exports = g; expect(await bunRun(path.join(String(dir), "nested.cjs"))).toSpawn("1"); }); - // A file with no CommonJS marker runs as a module, where this text is a SyntaxError. - test.concurrent("a function in statically dead code does not reach the engine", async () => { - using dir = tempDir("block-level-function", { - "dead.js": `if (false) { function o() { var static = 1; } }\nif (false) { function eval() {} }\nconsole.log("loaded");\n`, - }); - expect(await bunRun(path.join(String(dir), "dead.js"))).toSpawn("loaded"); - }); - test.concurrent("also through require(), import() and a .js file", async () => { const lib = (value: string) => `{ function z() { return "${value}"; } }\nmodule.exports = z();\n`; using dir = tempDir("block-level-function", { diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 165411c56bcf..8f34fb1e33ea 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -5753,13 +5753,6 @@ describe("block-level function declarations", () => { expect(print(code)).toBe(expected); }); - // Dead code elimination removes a `let` with its branch. A declaration would stay in the output. - it("a function in a statically dead branch is still lowered to let", () => { - expect(print("if (false) {\n function f() {}\n}\nmodule.exports = 1;\n")).toBe( - "if (false) {\n let f = function() {};\n}\nmodule.exports = 1;\n", - ); - }); - // A strict scope has no `var` to lose, and its "use strict" may not be printed. it.each([ [ From d5523372e7271f560435ae11524779453e82fecb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:30:01 +0000 Subject: [PATCH 5/7] test: dead code, modules with no import or export, and the known differences from node - The 15 rows of dead.cjs run with and without dead code elimination. - module.mjs and a file with no CommonJS marker keep the function in its block. - known.cjs records five shapes that are right on main as test.failing rows, and one file with no CommonJS marker that stops loading. - The printed text of a labelled function is no longer pinned. --- .../preserve-use-strict-cjs.test.ts | 280 ++++++++++++++++++ test/bundler/transpiler/transpiler.test.js | 6 - 2 files changed, 280 insertions(+), 6 deletions(-) diff --git a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts index 5ece01649bab..c5bdd8b0e5c3 100644 --- a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts +++ b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts @@ -11,6 +11,25 @@ test.concurrent(`sloppy mode by default in CJS`, async () => { }); describe("block-level function declarations in CommonJS", () => { + // What node prints for dead.cjs below. + const deadCode = { + branch: "undefined", + constant: "undefined", + elseBranch: "undefined", + behindReturn: "undefined", + behindBreak: "undefined", + nested: "undefined", + two: "undefinedundefined", + directEval: "undefined", + assign: "undefined", + shadow: "undefined", + functionLevel: "kept", + letOfFunction: "let", + parameter: "parameter", + arguments: "[object Arguments]", + strict: "outer", + }; + // Annex B: in sloppy code the name is also a `var` of the enclosing function, // assigned when the declaration is evaluated. Every expected value is what node prints. test.concurrent("sloppy code sees the function after its block", async () => { @@ -234,6 +253,178 @@ module.exports = g; expect(await bunRun(path.join(String(dir), "nested.cjs"))).toSpawn("1"); }); + // Code that never runs still gives the name its `var`. The answer does not depend on dead code elimination. + test.concurrent("dead code keeps the var, with and without dead code elimination", async () => { + using dir = tempDir("block-level-function", { + "dead.cjs": ` +const attempt = fn => { + try { + return fn(); + } catch (e) { + return e.name; + } +}; +const out = {}; + +out.branch = attempt(function () { + if (false) { function f() {} } + return String(f); +}); + +out.constant = attempt(function () { + const DEV = false; + if (DEV) { function f() {} } + return String(f); +}); + +out.elseBranch = attempt(function () { + if (true) {} else { function f() {} } + return String(f); +}); + +out.behindReturn = attempt(function () { + return String(f); + { function f() {} } +}); + +out.behindBreak = attempt(function () { + for (;;) { + break; + { function f() {} } + } + return String(f); +}); + +out.nested = attempt(function () { + if (false) { try { function f() {} } finally {} } + return String(f); +}); + +out.two = attempt(function () { + if (false) { function f() {} } + if (false) { function f() {} function g() {} } + return String(f) + String(g); +}); + +out.directEval = attempt(function () { + if (false) { function f() {} eval(""); } + return String(f); +}); + +out.assign = attempt(function () { + (function () { + if (false) { function deadBlockFunctionLeak() {} } + deadBlockFunctionLeak = 1; + })(); + return typeof globalThis.deadBlockFunctionLeak; +}); + +// As in node, the name hides a binding of an outer function. +out.shadow = attempt(function () { + function f() { return "outer"; } + return (function () { + if (false) { function f() {} } + return String(f); + })(); +}); + +out.functionLevel = attempt(function () { + function f() { return "kept"; } + if (false) { function f() {} } + return f(); +}); + +out.letOfFunction = attempt(function () { + if (false) { function f() {} } + let f = "let"; + return f; +}); + +out.parameter = attempt(function (f) { + if (false) { function f() {} } + return f; +}.bind(null, "parameter")); + +out.arguments = attempt(function () { + if (false) { function arguments() {} } + return Object.prototype.toString.call(arguments); +}); + +out.strict = attempt(function () { + "use strict"; + function f() { return "outer"; } + return (function () { + if (false) { function f() {} } + return f(); + })(); +}); + +module.exports = JSON.stringify(out); +if (require.main === module) console.log(module.exports); +`, + }); + const file = path.join(String(dir), "dead.cjs"); + // `bun -p` turns dead code elimination off. + const [run, print] = await Promise.all([bunRun(file), bunRun(["-p", `require(${JSON.stringify(file)})`])]); + expect(run).toSpawn(); + expect(print).toSpawn(); + expect({ run: JSON.parse(run.stdout), print: JSON.parse(print.stdout) }).toEqual({ + run: deadCode, + print: deadCode, + }); + }); + + // A module with no import or export is parsed as sloppy code and runs as strict code. + // The engine keeps the function in its block there, and a file with no CommonJS marker runs the same way. + test.concurrent("a module with no import or export keeps the function in its block", async () => { + const source = ` +const attempt = fn => { + try { + return fn(); + } catch (e) { + return e.name; + } +}; +const out = {}; + +out.strict = (function () { return this === undefined; })(); + +{ function z() {} } +out.afterBlock = typeof z; + +out.switchCase = attempt(() => + (function (x) { + switch (x) { + case 1: function s() { return 3; } + case 2: return s(); + } + })(2), +); + +out.source = attempt(function () { + { + function named(a, b) { return a + b; } + return [named.name, named.toString().split(" ").join("").startsWith("functionnamed(a,b)")]; + } +}); + +console.log(JSON.stringify(out)); +`; + using dir = tempDir("block-level-function", { "module.mjs": source, "no-marker.js": source }); + const [module, noMarker] = await Promise.all([ + bunRun(path.join(String(dir), "module.mjs")), + bunRun(path.join(String(dir), "no-marker.js")), + ]); + expect(module).toSpawn(); + expect(noMarker).toSpawn(); + // What node prints for module.mjs. + const expected = { strict: true, afterBlock: "undefined", switchCase: 3, source: ["named", true] }; + expect({ module: JSON.parse(module.stdout), noMarker: JSON.parse(noMarker.stdout) }).toEqual({ + module: expected, + noMarker: expected, + }); + }); + test.concurrent("also through require(), import() and a .js file", async () => { const lib = (value: string) => `{ function z() { return "${value}"; } }\nmodule.exports = z();\n`; using dir = tempDir("block-level-function", { @@ -315,4 +506,93 @@ module.exports = (function () {${body} const code = `"use strict"; const { sep } = require("node:path"); function f() { return "outer"; } { function f() { return "block"; } } console.log(f(), typeof sep);`; expect(await bunRun(["-e", code])).toSpawn("outer string"); }); + + // Right on main and in node, wrong here. A row fails when its cause is fixed, and then it moves to the tables above. + describe("known differences from node", () => { + const node = { + labelThenPlain: "plain", + labelAfterVar: "V", + labelAfterAssignment: "A", + generatorArguments: "[object Arguments]", + twoOfANameWithEval: "2", + }; + let rows: Promise> | undefined; + const run = () => + (rows ??= (async () => { + using dir = tempDir("block-level-function", { + "known.cjs": ` +const attempt = fn => { + try { + return String(fn()); + } catch (e) { + return e.name; + } +}; +const out = {}; + +// The printer puts a labelled function in a block, so it is assigned when the label runs. +out.labelThenPlain = attempt(function () { + lf: function f() { return "labelled"; } + return f(); + function f() { return "plain"; } +}); + +out.labelAfterVar = attempt(function () { + var f = "V"; + lf: function f() { return "labelled"; } + return typeof f === "function" ? f() : f; +}); + +out.labelAfterAssignment = attempt(function () { + f = "A"; + lf: function f() { return "labelled"; } + return typeof f === "function" ? "function" : f; +}); + +// JavaScriptCore: in a generator the block function takes the name before its block runs. +out.generatorArguments = attempt(function () { + return (function* () { + const a = arguments; + { function arguments() {} } + return Object.prototype.toString.call(a); + })().next().value; +}); + +// The parser drops the first of two functions with one name. The second one never runs here. +out.twoOfANameWithEval = attempt(function () { + exit: { + function f() { return 1; } + eval(""); + break exit; + function f() { return 2; } + } + return typeof f === "function" ? f() : typeof f; +}); + +console.log(JSON.stringify(out)); +`, + }); + const result = await bunRun(path.join(String(dir), "known.cjs")); + expect(result).toSpawn(); + return JSON.parse(result.stdout); + })()); + + test("the fixture runs", async () => { + expect(Object.keys(await run())).toEqual(Object.keys(node)); + }); + + for (const [name, expected] of Object.entries(node)) { + test.failing(name, async () => { + expect((await run())[name]).toBe(expected); + }); + } + + // A file with no CommonJS marker runs as a module, and the dead declaration is still printed. + test.failing("a dead function with sloppy-only text in a file that runs as a module", async () => { + using dir = tempDir("block-level-function", { + "dead.js": `if (false) { function o() { var static = 1; } }\nconsole.log("loaded");\n`, + }); + expect(await bunRun(path.join(String(dir), "dead.js"))).toSpawn("loaded"); + }); + }); }); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 8f34fb1e33ea..3d631f2a1581 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -5731,12 +5731,6 @@ describe("block-level function declarations", () => { ); }); - // The bare form is a SyntaxError in strict code, and a file with no CommonJS marker runs as a module. - // Inside the block the function is assigned when the label runs, not at function entry as the bare form is. - it("keeps the block around a labelled function", () => { - expect(print("l: function f() {}\nmodule.exports = f;")).toBe("l: {\n function f() {}\n}\nmodule.exports = f;\n"); - }); - // Annex B makes no `var` when a lexical binding of an enclosing scope has the name. it.each([ [ From 5bbb4d98558e1b7254e12069b2b8e74971f6ed41 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:09:20 +0000 Subject: [PATCH 6/7] test: a switch-case function in a strict function stays in its block --- test/bundler/transpiler/preserve-use-strict-cjs.test.ts | 7 +++++++ test/bundler/transpiler/transpiler.test.js | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts index c5bdd8b0e5c3..84691c9de493 100644 --- a/test/bundler/transpiler/preserve-use-strict-cjs.test.ts +++ b/test/bundler/transpiler/preserve-use-strict-cjs.test.ts @@ -477,6 +477,12 @@ out.classMethod = new (class { method() {${body} } })().method(); +out.switchCaseInStrictFunction = (function () { + "use strict"; + function f() { return "outer"; } + switch (1) { case 1: function f() { return "case"; } } + return f(); +})(); out.strictFile = require("./strict-file.cjs"); import("./module.mjs").then(module => { out.module = module.default; @@ -497,6 +503,7 @@ module.exports = (function () {${body} strictFunction: "outer", nestedInStrictFunction: "outer", classMethod: "outer", + switchCaseInStrictFunction: "outer", strictFile: "outer", module: "outer", }); diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 3d631f2a1581..0da9bb937284 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -5759,6 +5759,11 @@ describe("block-level function declarations", () => { '"use strict";\n{\n function f() {}\n}\nmodule.exports = 1;\n', "{\n let f = function() {};\n}\nmodule.exports = 1;\n", ], + [ + 'a switch case in a function with "use strict"', + 'function o(x) {\n "use strict";\n switch (x) {\n case 1:\n function f() {}\n }\n}\n', + "function o(x) {\n switch (x) {\n case 1:\n let f = function() {};\n }\n}\n", + ], [ "a class method", "class A {\n m() {\n {\n function f() {}\n }\n }\n}\n", From 018e6cccaec800d08a7aa52f2b7790b49964d4a9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:04:27 +0000 Subject: [PATCH 7/7] test: do not pin the printed text of a strict switch-case function The runtime row in preserve-use-strict-cjs.test.ts stays. The printed `let` is the lowering of main, and a read from another case is wrong with it in strict code, so this PR does not pin that text. --- test/bundler/transpiler/transpiler.test.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index 0da9bb937284..3d631f2a1581 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -5759,11 +5759,6 @@ describe("block-level function declarations", () => { '"use strict";\n{\n function f() {}\n}\nmodule.exports = 1;\n', "{\n let f = function() {};\n}\nmodule.exports = 1;\n", ], - [ - 'a switch case in a function with "use strict"', - 'function o(x) {\n "use strict";\n switch (x) {\n case 1:\n function f() {}\n }\n}\n', - "function o(x) {\n switch (x) {\n case 1:\n let f = function() {};\n }\n}\n", - ], [ "a class method", "class A {\n m() {\n {\n function f() {}\n }\n }\n}\n",