From fab857fb49c622a0c8ecfb0da4b08a34daa534e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:05:02 +0000 Subject: [PATCH 1/3] replMode: keep helper imports and lowering temporaries in the wrapper The REPL transform hoists top-level declarations out of the wrapper function, so that they are properties of the global object and persist between inputs. It hoisted every declaration. That includes the ones the parser adds for its own use: the temporaries of a lowered class, the stack of a lowered `using`, and the bindings of the helper and JSX runtime imports. The parser prints the same names for every input, so a later input replaced the value that an earlier class still reads. A name now persists only if it is the module scope's member for that name. Every other declaration stays in the wrapper of its input. The module of a named import is kept in a temporary, because the name of its namespace symbol, `import_`, is one an input can declare. A REPL input is one part, like an input with a top-level `using`. With tree shaking the parser moved a class above the statements before it and above its helper import, which runs as a statement in the wrapper. --- packages/bun-types/bun.d.ts | 8 +- src/js_parser/parse/parse_entry.rs | 8 +- src/js_parser/repl_transforms.rs | 203 ++++++++------ src/runtime/cli/repl.rs | 4 +- test/js/bun/repl/repl.test.ts | 68 +++++ test/js/bun/transpiler/repl-transform.test.ts | 249 ++++++++++++++++++ 6 files changed, 455 insertions(+), 85 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2ded9f1ad305..63504c994ca7 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3054,9 +3054,13 @@ declare module "bun" { /** * Enable REPL mode transforms: * - Wraps top-level inputs that appear to be object literals (inputs starting with '{' without trailing ';') in parentheses - * - Hoists all declarations as var for REPL persistence across vm.runInContext calls + * - Hoists the declarations the input wrote as var for REPL persistence across vm.runInContext calls. + * A helper import or a temporary that the transpiler adds stays inside the IIFE * - Wraps last expression in { __proto__: null, value: expr } for result capture - * - Wraps code in sync/async IIFE to avoid parentheses around object literals + * - Wraps code in sync/async IIFE to avoid parentheses around object literals. + * The IIFE is async, and the result is a Promise, if the input has a top-level await or an import, + * or if the transpiler imports helpers for it (decorators, `accessor`, a lowered `using`, automatic JSX). + * An import becomes an `import()` call: with `vm.runInContext`, pass `importModuleDynamically` * * @default false */ diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 62ce6aa7b3ab..97db90bb3010 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -1059,8 +1059,12 @@ impl<'a> Parser<'a> { // Note that top-level lowered "using" declarations disable tree-shaking // because we only do tree-shaking on top-level statements and lowering // a top-level "using" declaration moves all top-level statements into a - // nested scope. - if !p.options.tree_shaking || p.will_wrap_module_in_try_catch_for_using { + // nested scope. The REPL transform moves them into a function too, where + // an import runs as a statement: a class must not move above it. + if !p.options.tree_shaking + || p.will_wrap_module_in_try_catch_for_using + || p.options.repl_mode + { // When tree shaking is disabled, everything comes in a single part p.append_part(&mut parts, stmts)?; } else { diff --git a/src/js_parser/repl_transforms.rs b/src/js_parser/repl_transforms.rs index 72c8fb32390c..bb79b40104a6 100644 --- a/src/js_parser/repl_transforms.rs +++ b/src/js_parser/repl_transforms.rs @@ -3,7 +3,7 @@ //! This module provides transformations for REPL mode: //! - Wraps the last expression in { value: expr } for result capture //! - Wraps code with await in async IIFE with variable hoisting -//! - Hoists declarations for variable persistence across REPL lines +//! - Hoists the declarations the input wrote for variable persistence across REPL lines use bun_alloc::Arena as Bump; use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; @@ -12,7 +12,7 @@ use bun_collections::VecExt; use bun_ast as js_ast; use bun_ast::flags; use bun_ast::stmt::Data as StmtData; -use bun_ast::{B, Binding, E, Expr, ExprNodeList, G, S, Stmt}; +use bun_ast::{B, Binding, E, Expr, ExprNodeList, G, Ref, S, Stmt}; use crate::p::P; @@ -21,6 +21,8 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { /// This transforms code for interactive evaluation: /// - Wraps the last expression in { value: expr } for result capture /// - Wraps code with await in async IIFE with variable hoisting + #[cold] + #[inline(never)] pub(crate) fn apply_repl_transforms<'bump>( &mut self, parts: &mut BumpVec<'bump, js_ast::Part>, @@ -100,38 +102,33 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { let mut hoisted_stmts = BumpVec::::with_capacity_in(all_stmts.len(), bump); let mut inner_stmts = BumpVec::::with_capacity_in(all_stmts.len(), bump); - // Process each statement - hoist all declarations for REPL persistence + // Process each statement - hoist the declarations the input wrote for REPL persistence for stmt in all_stmts.iter() { match stmt.data { StmtData::SLocal(local) => { - // Hoist all declarations as var so they become context properties + // Hoist the declarations the input wrote as var so they become context properties // In sloppy mode, var at top level becomes a property of the global/context object // This is essential for REPL variable persistence across vm.runInContext calls let kind = S::Kind::KVar; - // Extract individual identifiers from binding patterns for hoisting let mut hoisted_decl_list = BumpVec::::new_in(bump); for decl in local.decls.slice() { + if !self.repl_binding_persists(decl.binding) { + inner_stmts.push(self.repl_wrapper_local( + local.kind, + decl.binding, + decl.value, + stmt.loc, + )); + continue; + } + + // Extract individual identifiers from binding patterns for hoisting self.repl_extract_identifiers_from_binding( decl.binding, &mut hoisted_decl_list, )?; - } - - if !hoisted_decl_list.is_empty() { - let decls = G::DeclList::from_bump_vec(hoisted_decl_list); - hoisted_stmts.push(self.s( - S::Local { - kind, - decls, - ..Default::default() - }, - stmt.loc, - )); - } - // Create assignment expressions for the inner statements - for decl in local.decls.slice() { if let Some(value) = decl.value { // Create assignment expression: binding = value let assign_expr = @@ -145,6 +142,18 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { )); } } + + if !hoisted_decl_list.is_empty() { + let decls = G::DeclList::from_bump_vec(hoisted_decl_list); + hoisted_stmts.push(self.s( + S::Local { + kind, + decls, + ..Default::default() + }, + stmt.loc, + )); + } } StmtData::SFunction(func) => { // For function declarations: @@ -152,6 +161,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { // Inner: this.funcName = funcName; function funcName() {} if let Some(name_loc) = func.func.name { let name_ref = name_loc.ref_; + debug_assert!(self.repl_persists(name_ref)); hoisted_stmts.push(self.s( S::Local { kind: S::Kind::KVar, @@ -217,6 +227,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { // Inner: ClassName = class ClassName {} if let Some(name_loc) = class.class.class_name { let name_ref = name_loc.ref_; + debug_assert!(self.repl_persists(name_ref)); hoisted_stmts.push(self.s( S::Local { kind: S::Kind::KVar, @@ -297,6 +308,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { if !import_data.star_name_loc.is_empty() { // import * as X from 'mod' -> var X = await import('mod') + debug_assert!(self.repl_persists(import_data.namespace_ref)); hoisted_stmts.push(self.s( S::Local { kind: S::Kind::KVar, @@ -340,6 +352,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { // import X from 'mod' -> var X = (await import('mod')).default // import X, { a } from 'mod' -> var __ns = await import('mod'); var X = __ns.default; var a = __ns.a; let default_ref = default_name.ref_; + debug_assert!(self.repl_persists(default_ref)); hoisted_stmts.push(self.s( S::Local { kind: S::Kind::KVar, @@ -358,9 +371,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { if !import_items.is_empty() { // Share a single await import() between default and named imports. - // namespace_ref is synthesized by processImportStatement for all non-star imports. - self.repl_convert_named_imports( - &*import_data, + let namespace_ref = self.repl_convert_named_imports( import_items, await_expr, &mut hoisted_stmts, @@ -370,7 +381,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { )?; let ns_ref_expr = self.new_expr( E::Identifier { - ref_: import_data.namespace_ref, + ref_: namespace_ref, ..Default::default() }, stmt.loc, @@ -442,7 +453,6 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { } else if !import_items.is_empty() { // import { a, b } from 'mod' -> destructure from await import('mod') self.repl_convert_named_imports( - &*import_data, import_items, await_expr, &mut hoisted_stmts, @@ -546,75 +556,34 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { /// import { a, b as c } from 'mod' -> /// var a; var c; (hoisted) /// var __mod = await import('mod'); a = __mod.a; c = __mod.b; (inner) - #[allow(clippy::too_many_arguments)] + /// Returns the ref of `__mod`. fn repl_convert_named_imports<'bump>( &mut self, - import_data: &S::Import, import_items: &[bun_ast::ClauseItem], await_expr: Expr, hoisted_stmts: &mut BumpVec<'bump, Stmt>, inner_stmts: &mut BumpVec<'bump, Stmt>, bump: &'bump Bump, loc: bun_ast::Loc, - ) -> Result<(), bun_alloc::AllocError> { - // Store the module in the namespace ref: var __ns = await import('mod') - hoisted_stmts.push(self.s( - S::Local { - kind: S::Kind::KVar, - decls: repl_one_decl( - bump, - Binding::alloc( - bump, - B::Identifier { - r#ref: import_data.namespace_ref, - }, - loc, - ), - ), - ..Default::default() - }, - loc, - )); - let left = self.new_expr( - E::Identifier { - ref_: import_data.namespace_ref, - ..Default::default() - }, - loc, - ); - let ns_assign = self.new_expr( - E::Binary { - op: js_ast::OpCode::BinAssign, - left, - right: await_expr, + ) -> Result { + // Store the module in a temporary: var __mod = await import('mod') + // The import's namespace symbol prints `import_mod`, a name an input can declare. + let namespace_ref = self.generate_temp_ref(None); + let namespace = Binding::alloc( + bump, + B::Identifier { + r#ref: namespace_ref, }, loc, ); - inner_stmts.push(self.s( - S::SExpr { - value: ns_assign, - ..Default::default() - }, - loc, - )); + inner_stmts.push(self.repl_wrapper_local(S::Kind::KVar, namespace, Some(await_expr), loc)); - // For each named import: var name; name = __ns.originalName; + // For each named import: var name; name = __mod.originalName; for item in import_items.iter() { let item_ref = item.name.ref_; - hoisted_stmts.push(self.s( - S::Local { - kind: S::Kind::KVar, - decls: repl_one_decl( - bump, - Binding::alloc(bump, B::Identifier { r#ref: item_ref }, item.name.loc), - ), - ..Default::default() - }, - loc, - )); let ns_ref_expr = self.new_expr( E::Identifier { - ref_: import_data.namespace_ref, + ref_: namespace_ref, ..Default::default() }, loc, @@ -630,6 +599,25 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { }, loc, ); + let binding = Binding::alloc(bump, B::Identifier { r#ref: item_ref }, item.name.loc); + // A helper or JSX runtime binding of an import the parser added + if !self.repl_persists(item_ref) { + inner_stmts.push(self.repl_wrapper_local( + S::Kind::KVar, + binding, + Some(prop_access), + loc, + )); + continue; + } + hoisted_stmts.push(self.s( + S::Local { + kind: S::Kind::KVar, + decls: repl_one_decl(bump, binding), + ..Default::default() + }, + loc, + )); let left = self.new_expr( E::Identifier { ref_: item_ref, @@ -654,7 +642,64 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { )); } - Ok(()) + Ok(namespace_ref) + } + + /// A declaration that stays inside the wrapper. + fn repl_wrapper_local( + &mut self, + kind: S::Kind, + binding: Binding, + value: Option, + loc: bun_ast::Loc, + ) -> Stmt { + self.s( + S::Local { + kind, + decls: G::DeclList::init_one(G::Decl { binding, value }), + ..Default::default() + }, + loc, + ) + } + + /// Whether `ref_` is a name the input declared at its top level: the module + /// scope's member for that name. Only such a name is hoisted. A lowering's + /// temporary or the binding of a helper import is no scope's member. It + /// stays in the wrapper, because the next input prints the same name for + /// its own. + fn repl_persists(&self, mut ref_: Ref) -> bool { + // Follow links because "var" declarations may be merged due to hoisting + loop { + let symbol = &self.symbols[ref_.inner_index() as usize]; + if !symbol.has_link() { + break; + } + ref_ = symbol.link.get(); + } + let name = self.symbols[ref_.inner_index() as usize] + .original_name + .slice(); + self.module_scope() + .members + .get(name) + .is_some_and(|member| member.ref_ == ref_) + } + + /// Whether every name that `binding` declares persists. + fn repl_binding_persists(&self, binding: Binding) -> bool { + match binding.data { + B::B::BIdentifier(ident) => self.repl_persists(ident.r#ref), + B::B::BArray(arr) => arr + .items() + .iter() + .all(|item| self.repl_binding_persists(item.binding)), + B::B::BObject(obj) => obj + .properties() + .iter() + .all(|prop| self.repl_binding_persists(prop.value)), + B::B::BMissing(_) => true, + } } /// Wrap the last expression in return { value: expr } diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 99f8a2f90f1e..1c5a775256a3 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -2150,8 +2150,8 @@ impl<'a> Repl<'a> { opts.features.top_level_await = true; // Enable top-level await in REPL // Keep `lower_using` at its default (true) here even though JavaScriptCore // supports `using` / `await using` natively. The REPL transform in - // `js_parser/repl_transforms.rs` rewrites every top-level `s_local` into a - // hoisted `var` + assignment for cross-input persistence, which would + // `js_parser/repl_transforms.rs` rewrites a top-level `s_local` the input + // wrote into a hoisted `var` + assignment for persistence, which would // silently discard disposal semantics if `using` declarations survived // until that pass. Lowering wraps the declaration in `try/finally` first, // which the REPL transform passes through intact. diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 5947a8d30a3f..745880d00259 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -604,6 +604,18 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("a named import leaves a variable with the name of its module alone", async () => { + const { outputs, stderr, exitCode } = await runRepl([ + 'var import_path = "./data.json"', + "import { join } from 'path'", + "import_path", + ".exit", + ]); + expect(outputs).toEqual(['"./data.json"', "[Function: join]", '"./data.json"']); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + test("import of a nonexistent module shows an error and keeps running", async () => { const { outputs, stderr, exitCode } = await runRepl(["import _ from 'nonexistent-module-xyz'", "1 + 1", ".exit"]); expect(outputs).toEqual([ @@ -741,6 +753,21 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("a line adds only the names it declares to the global object", async () => { + const { outputs, stderr, exitCode } = await runRepl([ + "var before = new Set(Object.keys(globalThis)); before.size > 0", + // A lowered `using` declares a disposal stack and imports two helpers. + `using resource = { [Symbol.dispose]() { console.log("disposed") } }; console.log("body")`, + `import { join } from "node:path"`, + // `_` is the REPL's own, and `resource` is the line's. + `Object.keys(globalThis).filter(name => !before.has(name) && name !== "_" && name !== "resource")`, + ".exit", + ]); + expect(outputs).toEqual(["true", "body\ndisposed\nundefined", "[Function: join]", '[ "join" ]']); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + test("destructured declarations persist across evaluations", async () => { const { outputs, stderr, exitCode } = await runRepl([ "const [a, b, c] = [10, 20, 30]", @@ -971,6 +998,15 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("-e keeps the names of a lowered `using` out of the global object", async () => { + const { stdout, stderr, exitCode } = await runReplWith([ + "-e", + `using resource = { [Symbol.dispose]() { console.log("disposed") } }; + console.log(Object.keys(globalThis).filter(name => /^(import_|__using|__callDispose|__bun_temp_ref)/.test(name)));`, + ]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "[]\ndisposed\n", stderr: "", exitCode: 0 }); + }); + test("-e supports require()", async () => { const { stdout, stderr, exitCode } = await runReplWith(["-p", "require('path').posix.join('/a', 'b')"]); expect(stdout).toBe('"/a/b"\n'); @@ -1072,6 +1108,24 @@ describe.todoIf(isWindows).concurrent("Bun REPL (Terminal)", () => { }); }); + test("a line stopped with Ctrl+C still disposes its own `using`", async () => { + await withTerminalRepl(async ({ send, waitFor, proc }) => { + const resource = (name: string) => `{ async [Symbol.asyncDispose]() { log.push("dispose ${name}") } }`; + send( + `globalThis.log = []; globalThis.release = Promise.withResolvers(); await using a = ${resource("a")}; ` + + `console.log("hold" + "ing"); await release.promise; log.push("a done")\n`, + ); + await waitFor("holding"); + // The line stays suspended and holds `a`. The next line runs to its end. + proc.kill("SIGINT"); + await waitFor(/\u276f|> /); + send(`await using b = ${resource("b")}; log.push("b done")\n`); + send('release.resolve(); await new Promise(resolve => setImmediate(resolve)); "l" + "og: " + log.join(", ")\n'); + const output = stripAnsi(await waitFor(/"log: [^"]*"/)); + expect(output.match(/"log: [^"]*"/)![0]).toBe('"log: b done, dispose b, a done, dispose a"'); + }); + }); + test("Ctrl+D exits on empty line", async () => { await withTerminalRepl(async ({ send, proc }) => { send("\x04"); // Ctrl+D @@ -1248,6 +1302,20 @@ describe.todoIf(isWindows).concurrent("Bun REPL (Terminal)", () => { ); }); + test("a named import does not change the suggestion for `imp`", async () => { + await withTerminalRepl( + async ({ send, waitFor }) => { + send('import { join } from "node:path"; "imp" + "orted"\n'); + await waitFor('"imported"'); + // The keyword, as in a new session: the line left no global whose name starts with `import_`. + send("imp"); + const output = await waitFor(/\x1b\[2mort[^\x1b]*\x1b\[0m/); + expect(output).toContain(`${DIM}ort\x1b[0m`); + }, + { env: colorEnv }, + ); + }); + test("no global or keyword suggestion for a property of an unresolvable expression", async () => { await withTerminalRepl( async ({ send, waitFor }) => { diff --git a/test/js/bun/transpiler/repl-transform.test.ts b/test/js/bun/transpiler/repl-transform.test.ts index 4f811b4762e3..e963985ada71 100644 --- a/test/js/bun/transpiler/repl-transform.test.ts +++ b/test/js/bun/transpiler/repl-transform.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { tempDir } from "harness"; import vm from "node:vm"; describe("Bun.Transpiler replMode", () => { @@ -180,6 +181,254 @@ describe("Bun.Transpiler replMode", () => { }); }); + // The parser declares names of its own beside the input's: the temporaries of + // a lowered class, the stack of a lowered `using`, the helpers it imports. It + // prints the same names for every input. + describe.each(["transformSync", "transform"] as const)("names the parser adds (%s)", method => { + // A lowered input imports its helpers from "bun:wrap". + const evalOptions = { importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER }; + // A field decorator: the field starts at its initial value plus `amount`. + // A decorated field reads the temporaries of its class when an instance is made. + const add = (amount: number) => () => (initial: number) => initial + amount; + + function replTranspiler(options: Bun.TranspilerOptions = {}) { + const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true, ...options }); + return async (line: string) => await transpiler[method](line); + } + + /** Evaluates the lines in one context. `added` are the names that the lines added to it. */ + async function runSession( + lines: string[], + { options, globals = {} }: { options?: Bun.TranspilerOptions; globals?: Record } = {}, + ) { + const transform = replTranspiler(options); + const context = vm.createContext({ ...globals }); + const results: any[] = []; + for (const line of lines) { + const result = await vm.runInContext(await transform(line), context, evalOptions); + results.push(result?.value ?? result); + } + return { results, added: Object.keys(context).filter(name => !Object.hasOwn(globals, name)) }; + } + + test("a lowered class keeps working after later inputs", async () => { + const { results, added } = await runSession( + [ + "class P { @add(100) v = 1 }", + "class Q { @add(200) v = 2 }", + "[new P().v, new Q().v]", + "class A { accessor x = 1 }", + "var a = new A()", + "class B { accessor x = 2 }", + "[a.x, new B().x]", + "function tagged(value) { return class extends value { tag = value.name } }", + "@tagged class R {}", + "@tagged class S {}", + "[new R().tag, new S().tag]", + ], + { globals: { add } }, + ); + expect({ fieldDecorators: results[2], accessors: results[6], classDecorators: results[10], added }).toEqual({ + fieldDecorators: [101, 202], + accessors: [1, 2], + classDecorators: ["R", "S"], + added: ["P", "Q", "A", "a", "B", "tagged", "R", "S"], + }); + }); + + // Each input declares `K`, a class expression with a decorated field, + // and the other names listed. + test.each([ + ["in a declaration", [], (n: number) => `var K${n} = class { @add(${n}00) x = ${n} }`], + [ + "in a parameter default", + ["f"], + (n: number) => `function f${n}(C = class { @add(${n}00) x = ${n} }) { return C }; var K${n} = f${n}()`, + ], + [ + "in a field initializer", + ["O"], + (n: number) => `class O${n} { inner = class { @add(${n}00) x = ${n} } }; var K${n} = new O${n}().inner`, + ], + ["in the head of a loop", [], (n: number) => `for (var K${n} = class { @add(${n}00) x = ${n} };;) break`], + // The temporaries of these three are registered in the scope of the enum (#38761). + [ + "in an enum initializer in a block", + [], + (n: number) => `var K${n}; { enum E { A = (K${n} = class { @add(${n}00) x = ${n} }, 0) } }`, + ], + [ + "in an enum initializer in a namespace", + ["N"], + (n: number) => + `var K${n}; namespace N${n} { export enum E { A = (K${n} = class { @add(${n}00) x = ${n} }, 0) } }`, + ], + [ + "in an enum initializer in a function", + ["h"], + (n: number) => + `var K${n}; function h${n}() { enum E { A = (K${n} = class { @add(${n}00) x = ${n} }, 0) } }; h${n}()`, + ], + ])("a lowered class expression %s keeps working after a later input", async (_, names, input) => { + const { results, added } = await runSession([input(1), input(2), "[new K1().x, new K2().x]"], { + globals: { add }, + }); + expect({ values: results[2], added: added.sort() }).toEqual({ + values: [101, 202], + added: ["K", ...names].flatMap(name => [name + 1, name + 2]).sort(), + }); + }); + + test("one output evaluated twice makes two classes", async () => { + const transform = replTranspiler(); + const context = vm.createContext({ add, made: [] }); + const code = await transform("class K { @add(made.length * 100) x = 1 }; made.push(K); made.map(K => new K().x)"); + const first = await vm.runInContext(code, context, evalOptions); + const second = await vm.runInContext(code, context, evalOptions); + expect([first.value, second.value]).toEqual([[1], [1, 101]]); + }); + + test("an input that throws adds no name", async () => { + const transform = replTranspiler(); + const context = vm.createContext({ add }); + const code = await transform( + "class T { @add(1) x = 1; static thrown = (() => { throw new Error('thrown') })() }", + ); + await expect((async () => await vm.runInContext(code, context, evalOptions))()).rejects.toThrow("thrown"); + expect(Object.keys(context)).toEqual(["add"]); + }); + + test("inputs that overlap dispose their own resources", async () => { + const transform = replTranspiler(); + const log: string[] = []; + const holding = Promise.withResolvers(); + const release = Promise.withResolvers(); + const context = vm.createContext({ log, holding, release }); + const resource = (name: string) => `{ async [Symbol.asyncDispose]() { log.push("dispose ${name}") } }`; + + const first = vm.runInContext( + await transform( + `await using a = ${resource("a")}; holding.resolve(); await release.promise; log.push("first done")`, + ), + context, + evalOptions, + ); + // The first input holds `a` and waits. The second one runs to its end. + await holding.promise; + await vm.runInContext( + await transform(`await using b = ${resource("b")}; log.push("second done")`), + context, + evalOptions, + ); + release.resolve(); + await first; + + expect(log).toEqual(["second done", "dispose b", "first done", "dispose a"]); + // `a` and `b` are the inputs' own names. + expect(Object.keys(context).filter(name => name !== "a" && name !== "b")).toEqual(["log", "holding", "release"]); + }); + + test("a named import leaves a variable with the name of its module alone", async () => { + const { results, added } = await runSession([ + 'var import_node_path = "earlier"', + 'import { sep } from "node:path"; import_node_path', + 'import { join } from "node:path"; import_node_path = "assigned"', + "import_node_path", + // Declared in the input that imports, and two imports of one module in one input. + 'import { readFileSync } from "node:fs"; import { existsSync } from "node:fs"; var import_node_fs = "declared"', + "[import_node_fs, typeof sep, typeof join, typeof readFileSync, typeof existsSync]", + ]); + expect({ read: results[1], assigned: results[3], declared: results[5], added }).toEqual({ + read: "earlier", + assigned: "assigned", + declared: ["declared", "string", "function", "function", "function"], + added: ["import_node_path", "sep", "join", "readFileSync", "existsSync", "import_node_fs"], + }); + }); + + test("a function keeps the JSX runtime of its input", async () => { + const runtime = (name: string) => + `export const jsxDEV = type => "${name}:" + type; export const jsx = jsxDEV, jsxs = jsxDEV;`; + using dir = tempDir("repl-jsx-runtime", { + "a/jsx-dev-runtime.js": runtime("a"), + "a/jsx-runtime.js": runtime("a"), + "b/jsx-dev-runtime.js": runtime("b"), + "b/jsx-runtime.js": runtime("b"), + }); + const source = (name: string) => `/** @jsxImportSource ${String(dir).replaceAll("\\", "/")}/${name} */`; + const { results, added } = await runSession( + [ + `${source("a")} function one() { return }`, + `${source("b")} function two() { return }`, + "[one(), two()]", + ], + { options: { autoImportJSX: true } }, + ); + expect({ values: results[2], added }).toEqual({ values: ["a:a", "b:b"], added: ["one", "two"] }); + }); + + test("with tree shaking, a class stays where the input wrote it", async () => { + const { results, added } = await runSession( + [ + "42; class Last {}", + "class Redeclared { static v = 1 }", + "var old = Redeclared; class Redeclared { static v = 2 }; old.v", + // The static block of this class calls a helper that the input imports. + "class Lowered { static accessor x = 3 }; Lowered.x", + ], + { options: { treeShaking: true } }, + ); + expect({ last: results[0].name, redeclared: results[2], lowered: results[3], added: added.sort() }).toEqual({ + last: "Last", + redeclared: 1, + lowered: 3, + added: ["Last", "Lowered", "Redeclared", "old"], + }); + }); + + test("every name the input declares persists beside a lowered class", async () => { + const { results, added } = await runSession( + [ + `class Lowered { @add(100) x = 1 } + var v = 2; let l = 3; const c = 4; + var { a, b: [, d, ...rest], ...others } = { a: 5, b: [0, 6, 7], e: 8 }; + var twice = 0; var twice = 19; + function fn() { return 9 } + async function afn() {} + function* gen() {} + class Cls { static v = 10 } + abstract class Abs { static v = 11 } + import def, { join, sep as separator } from "node:path"; + import * as ns from "node:path"; + import req = require("node:path"); + enum En { A = 12 } + const enum ConstEn { A = 13 } + namespace Ns { export const v = 14 } + for (var i = 0; i < 15; i++); + var merged; function merged() { return 16 } + export const exported = 17; + export class ExportedCls { static v = 18 }`, + `[new Lowered().x, v, l, c, a, d, rest, others, fn(), typeof afn, typeof gen, Cls.v, Abs.v, + def === ns.default, join === def.join, separator === def.sep, req === def, + En.A, En[12], ConstEn.A, Ns.v, i, merged(), exported, ExportedCls.v, twice]`, + ], + { globals: { add, require } }, + ); + expect(results[1]).toEqual([ + ...[101, 2, 3, 4, 5, 6, [7], { e: 8 }, 9, "function", "function", 10, 11], + ...[true, true, true, true], + ...[12, "A", 13, 14, 15, 16, 17, 18, 19], + ]); + expect(added.sort()).toEqual( + [ + ...["Lowered", "v", "l", "c", "a", "d", "rest", "others", "twice", "fn", "afn", "gen", "Cls", "Abs"], + ...["def", "join", "separator", "ns", "req"], + ...["En", "ConstEn", "Ns", "i", "merged", "exported", "ExportedCls"], + ].sort(), + ); + }); + }); + describe("object literal detection", () => { const transpiler = new Bun.Transpiler({ loader: "tsx", replMode: true }); From c7d0fa61c8b3f9d77066511cb25ecb4026c21faf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:31:52 +0000 Subject: [PATCH 2/3] replMode: bind the default of `import d, * as ns` The namespace arm of the import conversion did not look at the default name, so `d` was never declared. The arm now declares the default for every form and reads it from where the statement keeps the module. The test of an input that throws no longer depends on whether the vm shows a `var` that is declared and never assigned. --- src/js_parser/repl_transforms.rs | 180 ++++++++---------- test/js/bun/repl/repl.test.ts | 12 ++ test/js/bun/transpiler/repl-transform.test.ts | 10 +- 3 files changed, 93 insertions(+), 109 deletions(-) diff --git a/src/js_parser/repl_transforms.rs b/src/js_parser/repl_transforms.rs index bb79b40104a6..65ff61ca9bfb 100644 --- a/src/js_parser/repl_transforms.rs +++ b/src/js_parser/repl_transforms.rs @@ -306,7 +306,29 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { // `items` is an arena-owned `StoreSlice` valid for 'a. let import_items: &[bun_ast::ClauseItem] = import_data.items.slice(); - if !import_data.star_name_loc.is_empty() { + if let Some(default_name) = import_data.default_name { + debug_assert!(self.repl_persists(default_name.ref_)); + hoisted_stmts.push(self.s( + S::Local { + kind: S::Kind::KVar, + decls: repl_one_decl( + bump, + Binding::alloc( + bump, + B::Identifier { + r#ref: default_name.ref_, + }, + default_name.loc, + ), + ), + ..Default::default() + }, + stmt.loc, + )); + } + + // The ref that holds the module, if the statement keeps it + let module_ref = if !import_data.star_name_loc.is_empty() { // import * as X from 'mod' -> var X = await import('mod') debug_assert!(self.repl_persists(import_data.namespace_ref)); hoisted_stmts.push(self.s( @@ -348,119 +370,67 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { }, stmt.loc, )); - } else if let Some(default_name) = import_data.default_name { - // import X from 'mod' -> var X = (await import('mod')).default - // import X, { a } from 'mod' -> var __ns = await import('mod'); var X = __ns.default; var a = __ns.a; - let default_ref = default_name.ref_; - debug_assert!(self.repl_persists(default_ref)); - hoisted_stmts.push(self.s( - S::Local { - kind: S::Kind::KVar, - decls: repl_one_decl( - bump, - Binding::alloc( - bump, - B::Identifier { r#ref: default_ref }, - default_name.loc, - ), - ), - ..Default::default() - }, - stmt.loc, - )); - - if !import_items.is_empty() { - // Share a single await import() between default and named imports. - let namespace_ref = self.repl_convert_named_imports( - import_items, - await_expr, - &mut hoisted_stmts, - &mut inner_stmts, - bump, - stmt.loc, - )?; - let ns_ref_expr = self.new_expr( - E::Identifier { - ref_: namespace_ref, - ..Default::default() - }, - stmt.loc, - ); - let dot_default = self.new_expr( - E::Dot { - target: ns_ref_expr, - name: b"default".into(), - name_loc: stmt.loc, - ..Default::default() - }, - stmt.loc, - ); - let left = self.new_expr( - E::Identifier { - ref_: default_ref, - ..Default::default() - }, - default_name.loc, - ); - let assign = self.new_expr( - E::Binary { - op: js_ast::OpCode::BinAssign, - left, - right: dot_default, - }, - stmt.loc, - ); - inner_stmts.push(self.s( - S::SExpr { - value: assign, - ..Default::default() - }, - stmt.loc, - )); - } else { - let dot_default = self.new_expr( - E::Dot { - target: await_expr, - name: b"default".into(), - name_loc: stmt.loc, - ..Default::default() - }, - stmt.loc, - ); - let left = self.new_expr( - E::Identifier { - ref_: default_ref, - ..Default::default() - }, - default_name.loc, - ); - let assign = self.new_expr( - E::Binary { - op: js_ast::OpCode::BinAssign, - left, - right: dot_default, - }, - stmt.loc, - ); - inner_stmts.push(self.s( - S::SExpr { - value: assign, - ..Default::default() - }, - stmt.loc, - )); - } + Some(import_data.namespace_ref) } else if !import_items.is_empty() { // import { a, b } from 'mod' -> destructure from await import('mod') - self.repl_convert_named_imports( + Some(self.repl_convert_named_imports( import_items, await_expr, &mut hoisted_stmts, &mut inner_stmts, bump, stmt.loc, - )?; + )?) } else { + None + }; + + if let Some(default_name) = import_data.default_name { + // import X from 'mod' -> var X = (await import('mod')).default + // import X, { a } from 'mod' -> var __ns = await import('mod'); var X = __ns.default; var a = __ns.a; + // import X, * as Y from 'mod' -> var Y = await import('mod'); var X = Y.default; + let module = match module_ref { + Some(ref_) => self.new_expr( + E::Identifier { + ref_, + ..Default::default() + }, + stmt.loc, + ), + None => await_expr, + }; + let dot_default = self.new_expr( + E::Dot { + target: module, + name: b"default".into(), + name_loc: stmt.loc, + ..Default::default() + }, + stmt.loc, + ); + let left = self.new_expr( + E::Identifier { + ref_: default_name.ref_, + ..Default::default() + }, + default_name.loc, + ); + let assign = self.new_expr( + E::Binary { + op: js_ast::OpCode::BinAssign, + left, + right: dot_default, + }, + stmt.loc, + ); + inner_stmts.push(self.s( + S::SExpr { + value: assign, + ..Default::default() + }, + stmt.loc, + )); + } else if module_ref.is_none() { // import 'mod' (side-effect only) -> await import('mod') inner_stmts.push(self.s( S::SExpr { diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 745880d00259..2aaefb126b53 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -604,6 +604,18 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("binds the default and the namespace of one import statement", async () => { + // `path` alone is a global of the REPL. + const { outputs, stderr, exitCode } = await runRepl([ + "import pathDefault, * as pathNamespace from 'path'", + "[pathDefault === pathNamespace.default, typeof pathDefault.join, typeof pathNamespace.join]", + ".exit", + ]); + expect(outputs).toEqual([expect.stringContaining("join: [Function: join]"), '[ true, "function", "function" ]']); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + test("a named import leaves a variable with the name of its module alone", async () => { const { outputs, stderr, exitCode } = await runRepl([ 'var import_path = "./data.json"', diff --git a/test/js/bun/transpiler/repl-transform.test.ts b/test/js/bun/transpiler/repl-transform.test.ts index e963985ada71..89f9e11eda80 100644 --- a/test/js/bun/transpiler/repl-transform.test.ts +++ b/test/js/bun/transpiler/repl-transform.test.ts @@ -295,7 +295,8 @@ describe("Bun.Transpiler replMode", () => { "class T { @add(1) x = 1; static thrown = (() => { throw new Error('thrown') })() }", ); await expect((async () => await vm.runInContext(code, context, evalOptions))()).rejects.toThrow("thrown"); - expect(Object.keys(context)).toEqual(["add"]); + // `T` is the input's own name. It is declared and never assigned. + expect(Object.keys(context).filter(name => name !== "T")).toEqual(["add"]); }); test("inputs that overlap dispose their own resources", async () => { @@ -400,6 +401,7 @@ describe("Bun.Transpiler replMode", () => { abstract class Abs { static v = 11 } import def, { join, sep as separator } from "node:path"; import * as ns from "node:path"; + import both, * as bothNs from "node:path"; import req = require("node:path"); enum En { A = 12 } const enum ConstEn { A = 13 } @@ -409,20 +411,20 @@ describe("Bun.Transpiler replMode", () => { export const exported = 17; export class ExportedCls { static v = 18 }`, `[new Lowered().x, v, l, c, a, d, rest, others, fn(), typeof afn, typeof gen, Cls.v, Abs.v, - def === ns.default, join === def.join, separator === def.sep, req === def, + def === ns.default, join === def.join, separator === def.sep, both === bothNs.default, req === def, En.A, En[12], ConstEn.A, Ns.v, i, merged(), exported, ExportedCls.v, twice]`, ], { globals: { add, require } }, ); expect(results[1]).toEqual([ ...[101, 2, 3, 4, 5, 6, [7], { e: 8 }, 9, "function", "function", 10, 11], - ...[true, true, true, true], + ...[true, true, true, true, true], ...[12, "A", 13, 14, 15, 16, 17, 18, 19], ]); expect(added.sort()).toEqual( [ ...["Lowered", "v", "l", "c", "a", "d", "rest", "others", "twice", "fn", "afn", "gen", "Cls", "Abs"], - ...["def", "join", "separator", "ns", "req"], + ...["def", "join", "separator", "ns", "both", "bothNs", "req"], ...["En", "ConstEn", "Ns", "i", "merged", "exported", "ExportedCls"], ].sort(), ); From 588a8d71cdf02b996696b2b891dffabbe7731bb3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:34:27 +0000 Subject: [PATCH 3/3] replMode: one-line comments, no edit in repl.rs The added comments are one line each. The comment in repl.rs is as on main. The replMode JSDoc names `autoImportJSX` as the condition for the JSX import. The Ctrl+C test prints its marker from an immediate, which runs after the REPL installed its SIGINT handler. --- packages/bun-types/bun.d.ts | 2 +- src/js_parser/parse/parse_entry.rs | 4 ++-- src/js_parser/repl_transforms.rs | 13 +++---------- src/runtime/cli/repl.rs | 4 ++-- test/js/bun/repl/repl.test.ts | 4 +++- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 63504c994ca7..3e5b2e7bf055 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3059,7 +3059,7 @@ declare module "bun" { * - Wraps last expression in { __proto__: null, value: expr } for result capture * - Wraps code in sync/async IIFE to avoid parentheses around object literals. * The IIFE is async, and the result is a Promise, if the input has a top-level await or an import, - * or if the transpiler imports helpers for it (decorators, `accessor`, a lowered `using`, automatic JSX). + * or if the transpiler imports helpers for it (decorators, `accessor`, a lowered `using`, JSX with `autoImportJSX`). * An import becomes an `import()` call: with `vm.runInContext`, pass `importModuleDynamically` * * @default false diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index 97db90bb3010..05a57a5b2aea 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -1059,10 +1059,10 @@ impl<'a> Parser<'a> { // Note that top-level lowered "using" declarations disable tree-shaking // because we only do tree-shaking on top-level statements and lowering // a top-level "using" declaration moves all top-level statements into a - // nested scope. The REPL transform moves them into a function too, where - // an import runs as a statement: a class must not move above it. + // nested scope. if !p.options.tree_shaking || p.will_wrap_module_in_try_catch_for_using + // The REPL transform moves all top-level statements into a function. || p.options.repl_mode { // When tree shaking is disabled, everything comes in a single part diff --git a/src/js_parser/repl_transforms.rs b/src/js_parser/repl_transforms.rs index 65ff61ca9bfb..a7b21348a14a 100644 --- a/src/js_parser/repl_transforms.rs +++ b/src/js_parser/repl_transforms.rs @@ -386,9 +386,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { }; if let Some(default_name) = import_data.default_name { - // import X from 'mod' -> var X = (await import('mod')).default - // import X, { a } from 'mod' -> var __ns = await import('mod'); var X = __ns.default; var a = __ns.a; - // import X, * as Y from 'mod' -> var Y = await import('mod'); var X = Y.default; + // import X[, ...] from 'mod' -> X = .default let module = match module_ref { Some(ref_) => self.new_expr( E::Identifier { @@ -536,8 +534,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { bump: &'bump Bump, loc: bun_ast::Loc, ) -> Result { - // Store the module in a temporary: var __mod = await import('mod') - // The import's namespace symbol prints `import_mod`, a name an input can declare. + // var __mod = await import('mod'). Not the import's namespace symbol: an input can declare its name. let namespace_ref = self.generate_temp_ref(None); let namespace = Binding::alloc( bump, @@ -633,11 +630,7 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { ) } - /// Whether `ref_` is a name the input declared at its top level: the module - /// scope's member for that name. Only such a name is hoisted. A lowering's - /// temporary or the binding of a helper import is no scope's member. It - /// stays in the wrapper, because the next input prints the same name for - /// its own. + /// Whether the input declared `ref_` at its top level: the module scope's member for its name. fn repl_persists(&self, mut ref_: Ref) -> bool { // Follow links because "var" declarations may be merged due to hoisting loop { diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 1c5a775256a3..99f8a2f90f1e 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -2150,8 +2150,8 @@ impl<'a> Repl<'a> { opts.features.top_level_await = true; // Enable top-level await in REPL // Keep `lower_using` at its default (true) here even though JavaScriptCore // supports `using` / `await using` natively. The REPL transform in - // `js_parser/repl_transforms.rs` rewrites a top-level `s_local` the input - // wrote into a hoisted `var` + assignment for persistence, which would + // `js_parser/repl_transforms.rs` rewrites every top-level `s_local` into a + // hoisted `var` + assignment for cross-input persistence, which would // silently discard disposal semantics if `using` declarations survived // until that pass. Lowering wraps the declaration in `try/finally` first, // which the REPL transform passes through intact. diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 2aaefb126b53..b12a80f6dea4 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -1123,9 +1123,11 @@ describe.todoIf(isWindows).concurrent("Bun REPL (Terminal)", () => { test("a line stopped with Ctrl+C still disposes its own `using`", async () => { await withTerminalRepl(async ({ send, waitFor, proc }) => { const resource = (name: string) => `{ async [Symbol.asyncDispose]() { log.push("dispose ${name}") } }`; + // The immediate runs in the loop that waits for the line, which is where the REPL takes a SIGINT. send( `globalThis.log = []; globalThis.release = Promise.withResolvers(); await using a = ${resource("a")}; ` + - `console.log("hold" + "ing"); await release.promise; log.push("a done")\n`, + `await new Promise(resolve => setImmediate(resolve)); console.log("hold" + "ing"); ` + + `await release.promise; log.push("a done")\n`, ); await waitFor("holding"); // The line stays suspended and holds `a`. The next line runs to its end.