From ecf01a4e88ecea56731242a06c4536c3c0f00563 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:14:13 +0000 Subject: [PATCH 1/2] replMode: report the class for a class declaration with decorators The lowering of a decorator adds statements after the class, so a REPL input that declares such a class reported what the last of them returns: `undefined` for a TC39 class decorator, and a property descriptor or `undefined` for a legacy member decorator. A class declaration without decorators reports the class. In replMode, a class declaration at the top level of the input is now followed by a read of its name when the lowering added statements. --- src/js_parser/repl_transforms.rs | 25 +++++++++++++ src/js_parser/visit/visit_stmt.rs | 4 ++ test/js/bun/transpiler/repl-transform.test.ts | 37 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/src/js_parser/repl_transforms.rs b/src/js_parser/repl_transforms.rs index a7b21348a14a..8894a5d8d7fe 100644 --- a/src/js_parser/repl_transforms.rs +++ b/src/js_parser/repl_transforms.rs @@ -893,6 +893,31 @@ impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { B::B::BMissing(_) => self.new_expr(E::Missing {}, binding.loc), } } + + /// An input reports its last expression, so a read of the class follows a decorator lowering. + #[cold] + #[inline(never)] + pub(crate) fn repl_report_class_declaration( + &mut self, + stmts: &mut BumpVec<'a, Stmt>, + lowered: &[Stmt], + class_name: js_ast::LocRef, + loc: bun_ast::Loc, + ) { + if self.current_scope != self.module_scope + || matches!(lowered.last(), Some(last) if matches!(last.data, StmtData::SClass(_))) + { + return; + } + self.record_usage(class_name.ref_); + stmts.push(self.s( + S::SExpr { + value: Expr::init_identifier(class_name.ref_, class_name.loc), + ..Default::default() + }, + loc, + )); + } } /// Bump-allocate a single-element `G::DeclList`. diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index ae9a09690ae6..43e1a5bcf923 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -1162,6 +1162,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if !mark_as_dead || was_export_inside_namespace { // Lower class field syntax for browsers that don't support it stmts.extend_from_slice(lowered); + if p.options.repl_mode { + let class_name = data.class.class_name.expect("infallible: name checked"); + p.repl_report_class_declaration(stmts, lowered, class_name, stmt.loc); + } } else { let ref_ = data .class diff --git a/test/js/bun/transpiler/repl-transform.test.ts b/test/js/bun/transpiler/repl-transform.test.ts index 89f9e11eda80..56026411eac2 100644 --- a/test/js/bun/transpiler/repl-transform.test.ts +++ b/test/js/bun/transpiler/repl-transform.test.ts @@ -236,6 +236,43 @@ describe("Bun.Transpiler replMode", () => { }); }); + test.each([ + ["TC39", {}], + ["legacy", { tsconfig: { compilerOptions: { experimentalDecorators: true } } }], + ] as [string, Bun.TranspilerOptions][])( + "a class declaration with %s decorators reports the class, as one without does", + async (_, options) => { + const { results } = await runSession( + [ + "function dec() {}", + "function tagged(value) { return class extends value { static tag = value.name } }", + "class Plain {}", + "@tagged class Tagged {}", + "class Method { @dec m() {} }", + "class Field { @dec p = 1 }", + "class StaticField { @dec static s = 1 }", + "[Plain, Tagged, Method, Field, StaticField]", + ], + { options }, + ); + const [Plain, Tagged, Method, Field, StaticField] = results[7]; + expect({ + Plain: results[2] === Plain, + Tagged: results[3] === Tagged, + Method: results[4] === Method, + Field: results[5] === Field, + StaticField: results[6] === StaticField, + tag: Tagged.tag, + }).toEqual({ Plain: true, Tagged: true, Method: true, Field: true, StaticField: true, tag: "Tagged" }); + + // Only a declaration at the top level of a replMode input is followed by a read of its name. + const nested = await replTranspiler(options)("function inner() { @tagged class Local {} }"); + expect(nested).not.toMatch(/^\s*Local;$/m); + const notReplMode = await replTranspiler({ ...options, replMode: false })("@tagged class Tagged {}"); + expect(notReplMode).not.toMatch(/^\s*Tagged;$/m); + }, + ); + // Each input declares `K`, a class expression with a decorated field, // and the other names listed. test.each([ From 0d7671138aa735f915238432e4556fca22954e7d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:14:14 +0000 Subject: [PATCH 2/2] repl: take the decorator options from the tsconfig.json of the working directory `bun repl` passed constants for the decorator options: the legacy TypeScript decorators and no decorator metadata, whatever the tsconfig.json said. An `accessor` field was a SyntaxError and a TC39 decorator got the legacy arguments. The REPL now passes `experimentalDecorators` and `emitDecoratorMetadata` of the root tsconfig.json, which the module loader passes for a file. --- src/runtime/cli/repl.rs | 5 +- test/js/bun/repl/repl.test.ts | 186 +++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 335c0ddc3f07..7bc75c8510cd 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -2146,9 +2146,8 @@ impl<'a> Repl<'a> { vm.transpiler.options.jsx.clone(), bun_ast::Loader::Tsx, bun_js_parser::TsconfigOptions { - // The REPL transform below supports only the legacy decorators. - experimental_decorators: true, - emit_decorator_metadata: false, + experimental_decorators: vm.transpiler.options.experimental_decorators, + emit_decorator_metadata: vm.transpiler.options.emit_decorator_metadata, use_define_for_class_fields: vm.transpiler.options.use_define_for_class_fields, }, ); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 38a18190c97c..33e572968254 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -891,14 +891,188 @@ describe.concurrent("Bun REPL", () => { expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + + // The tsconfig.json of this repository asks for the legacy decorators, so each of the + // sessions below runs in a directory of its own. + test("accessor fields and TC39 decorators work where no tsconfig.json asks for the legacy decorators", async () => { + using dir = tempDir("repl-tc39-decorators", {}); + const { outputs, stderr, exitCode } = await runRepl( + [ + "class E { accessor a = 1 }", + "new E().a", + // A TC39 decorator gets (value, context). A legacy one never gets a context. + "function dec(value, context) { console.log(arguments.length, context.kind); return value }", + "class F { @dec accessor b = 2 }", + "new F().b", + "@dec class D {}", + ".exit", + ], + { cwd: String(dir) }, + ); + expect(outputs).toEqual(["[class E]", "1", "undefined", "2 accessor\n[class F]", "2", "2 class\n[class D]"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("a class with a decorated field is not changed by a later one", async () => { + using dir = tempDir("repl-decorated-classes", {}); + const { outputs, stderr, exitCode } = await runRepl( + [ + // A field decorator: the field starts at its initial value plus `amount`. + "const add = amount => () => initial => initial + amount", + "class P { @add(100) v = 1 }", + "class Q { @add(200) v = 2 }", + "[new P().v, new Q().v]", + ".exit", + ], + { cwd: String(dir) }, + ); + expect(outputs).toEqual(["[Function: add]", "[class P]", "[class Q]", "[ 101, 202 ]"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("a class with an accessor field is not changed by a later one of the same name", async () => { + using dir = tempDir("repl-accessor-classes", {}); + const { outputs, stderr, exitCode } = await runRepl( + [ + "class A { accessor x = 1 }", + "const a = new A(); a.x", + "class A { accessor x = 2 }", + "[a.x, new A().x]", + ".exit", + ], + { cwd: String(dir) }, + ); + expect(outputs).toEqual(["[class A]", "1", "[class A]", "[ 1, 2 ]"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + describe("an input gets the decorators that a TypeScript file gets in the same directory", () => { + // `calls` gets what each decorator received, and each metadata key that a class emitted. + const program = [ + `const calls = []; Reflect.metadata = (key, value) => () => { calls.push(key === "design:type" ? key + " " + value.name : key) }; calls.length`, + "function dec(...args) { calls.push(args.length + ' ' + args[1]?.kind) }", + "class Q { @dec m() {} @dec when: Date }", + "@dec class D {}", + ]; + const calls = "calls.join(', ')"; + // With emitDecoratorMetadata, the parser once lost its place after this conditional (#46270). + const conditional = "typeof (true ? (): new () => object => { class A { @dec p: Date } return A } : null)"; + const file = [...program, `console.log(JSON.stringify(${calls}))`, `console.log(JSON.stringify(${conditional}))`]; + const legacy = { compilerOptions: { experimentalDecorators: true } }; + + test.each([ + // TC39 decorators: (value, context). + ["no tsconfig.json", {}, "", '"2 method, 2 field, 2 class"'], + // The legacy decorators: (prototype, name, descriptor) for a member, (constructor) for a class. + ["experimentalDecorators", { "tsconfig.json": legacy }, "", '"3 undefined, 3 undefined, 1 undefined"'], + // emitDecoratorMetadata implies the legacy decorators. + [ + "emitDecoratorMetadata", + { "tsconfig.json": { compilerOptions: { emitDecoratorMetadata: true } } }, + "", + '"design:returntype, design:paramtypes, design:type Function, 3 undefined, design:type Date, 3 undefined, 1 undefined"', + ], + // A tsconfig.json that extends another loses its flag (#30477), and one above the working + // directory is not read (#28605). Whatever a file gets there, the prompt gets. + [ + "a tsconfig.json that extends another", + { "tsconfig.json": { extends: "./base.json", ...legacy }, "base.json": { compilerOptions: {} } }, + "", + undefined, + ], + ["a tsconfig.json above the working directory", { "tsconfig.json": legacy }, "src", undefined], + ] as [string, Record, string, string | undefined][])( + "%s", + async (_, files, subdirectory, expected) => { + using dir = tempDir("repl-decorator-tsconfig", { + ...Object.fromEntries(Object.entries(files).map(([name, json]) => [name, JSON.stringify(json)])), + [subdirectory ? `${subdirectory}/program.ts` : "program.ts"]: file.join("\n"), + }); + const cwd = path.join(String(dir), subdirectory); + await using proc = Bun.spawn({ + cmd: [bunExe(), "program.ts"], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [prompt, stdout, stderr, exitCode] = await Promise.all([ + runRepl([...program, calls, conditional, ".exit"], { cwd }), + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + const asFile = stdout.trim().split(/\r?\n/); + expect({ outputs: asFile, stderr, exitCode }).toEqual({ + outputs: [expected ?? expect.any(String), '"function"'], + stderr: "", + exitCode: 0, + }); + expect({ outputs: prompt.outputs, stderr: prompt.stderr, exitCode: prompt.exitCode }).toEqual({ + // A class declaration reports the class, whichever decorators it has. + outputs: ["0", "undefined", "[class Q]", "[class D]", ...asFile], + stderr: "", + exitCode: 0, + }); + }, + ); + }); + + test("a decorator imported from a module is applied at the prompt as it is in the module", async () => { + using dir = tempDir("repl-imported-decorator", { + "logged.ts": ` + export function logged(method: Function, context: ClassMethodDecoratorContext) { + return function (this: unknown, ...args: unknown[]) { + return "logged:" + String(context.name) + ":" + method.apply(this, args); + }; + } + export class InModule { + @logged greet() { + return "hi"; + } + } + `, + }); + const url = JSON.stringify(Bun.pathToFileURL(path.join(String(dir), "logged.ts")).href); + const { outputs, stderr, exitCode } = await runRepl( + [ + `import { logged, InModule } from ${url}`, + "class AtPrompt { @logged greet(): string { return 'hi' } }", + "[new InModule().greet(), new AtPrompt().greet()]", + ".exit", + ], + { cwd: String(dir) }, + ); + expect(outputs).toEqual(["[class InModule]", "[class AtPrompt]", '[ "logged:greet:hi", "logged:greet:hi" ]']); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test(".load and .copy lower an accessor field", async () => { + using dir = tempDir("repl-accessor-commands", { + "counter.ts": "class Counter { accessor count: number = 7 }\n", + }); + const { stdout, outputs, stderr, exitCode } = await runRepl( + [".load ./counter.ts", ".copy new (class { accessor n = 7 })().n", ".exit"], + { cwd: String(dir) }, + ); + expect(outputs).toEqual(["Loading ./counter.ts...\n[class Counter]", "Copied 1 characters to clipboard"]); + expect(stdout).toContain(`\x1b]52;c;${btoa("7")}\x07`); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); describe("-e / --eval and -p / --print", () => { - async function runReplWith(args: string[]) { + async function runReplWith(args: string[], cwd?: string) { await using proc = Bun.spawn({ cmd: [bunExe(), "repl", ...args], stdout: "pipe", stderr: "pipe", + cwd, env: { ...bunEnv, NO_COLOR: "1" }, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); @@ -975,6 +1149,16 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("-p lowers an accessor field", async () => { + // A directory with no tsconfig.json: the one of this repository asks for the legacy decorators. + using dir = tempDir("repl-print-accessor", {}); + const { stdout, stderr, exitCode } = await runReplWith( + ["-p", "class E { accessor a = 1 }; new E().a"], + String(dir), + ); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1\n", stderr: "", exitCode: 0 }); + }); + test("-e with thrown error writes to stderr and exits with code 1", async () => { const { stdout, stderr, exitCode } = await runReplWith(["-e", "throw new Error('boom')"]); expect(stdout).toBe("");