From 6a2de3fa6a99b1a7eed1c4bd8553ded8ab1ba050 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:08:54 +0000 Subject: [PATCH 1/2] transpiler: derive decorator options from tsconfig in scanImports and the REPL scanImports() and the REPL built their parser options from scratch and left features.standard_decorators at its false default, so both rejected `accessor` fields and (outside TypeScript) standard decorators that transformSync() and scan() accept. For TypeScript they also parsed the legacy decorator grammar even when the tsconfig asked for the standard one. Add RuntimeFeatures::standard_decorators_for() as the single source of truth for the decorator dialect and use it at every option-building site. --- src/bundler/ParseTask.rs | 10 +++--- src/bundler/transpiler.rs | 11 +++--- src/js_parser/parser.rs | 11 ++++++ src/runtime/api/JSTranspiler.rs | 8 +++++ src/runtime/cli/repl.rs | 6 ++++ test/bundler/transpiler/transpiler.test.js | 42 ++++++++++++++++++++++ test/js/bun/repl/repl.test.ts | 38 +++++++++++++++++++- 7 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 1329ab2bbbdb..dd691f816584 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2448,11 +2448,11 @@ pub mod parse_worker { opts.features.minify_keep_names = topts.keep_names; opts.features.minify_whitespace = topts.minify_whitespace; opts.features.emit_decorator_metadata = task.emit_decorator_metadata; - // emitDecoratorMetadata implies legacy/experimental decorators, as it only - // makes sense with TypeScript's legacy decorator system (reflect-metadata). - // TC39 standard decorators have their own metadata mechanism. - opts.features.standard_decorators = !loader.is_typescript() - || !(task.experimental_decorators || task.emit_decorator_metadata); + opts.features.standard_decorators = js_parser::RuntimeFeatures::standard_decorators_for( + loader, + task.experimental_decorators, + task.emit_decorator_metadata, + ); opts.features.unwrap_commonjs_packages = topts.unwrap_commonjs_packages; // Modeled as // `Option>` on both sides, so we deep-clone (small — diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 2ae6a1d809f2..00d38f3cc472 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1595,11 +1595,12 @@ impl<'a> Transpiler<'a> { }; opts.features.emit_decorator_metadata = this_parse.emit_decorator_metadata; - // emitDecoratorMetadata implies legacy/experimental decorators, as it only - // makes sense with TypeScript's legacy decorator system (reflect-metadata). - // TC39 standard decorators have their own metadata mechanism. - opts.features.standard_decorators = !loader.is_typescript() - || !(this_parse.experimental_decorators || this_parse.emit_decorator_metadata); + opts.features.standard_decorators = + js_ast::RuntimeFeatures::standard_decorators_for( + loader, + this_parse.experimental_decorators, + this_parse.emit_decorator_metadata, + ); opts.features.allow_runtime = self.options.allow_runtime; opts.features.set_breakpoint_on_first_line = this_parse.set_breakpoint_on_first_line; diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index a39c7d155942..c9291e30fe47 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -363,6 +363,17 @@ pub mod Runtime { self.runtime_transpiler_cache.map(|p| unsafe { &mut *p }) } + /// Whether to parse TC39 standard decorators (and the `accessor` keyword). + /// Only TypeScript files that opted into legacy decorators use the old grammar; + /// `emitDecoratorMetadata` implies them, as it only makes sense with that system. + pub fn standard_decorators_for( + loader: bun_ast::Loader, + experimental_decorators: bool, + emit_decorator_metadata: bool, + ) -> bool { + !loader.is_typescript() || !(experimental_decorators || emit_decorator_metadata) + } + /// Initialize bundler feature flags for dead-code elimination via `import { feature } from "bun:bundle"`. /// Returns an owned `Box`, or `None` if no flags are provided. /// Keys are kept sorted so iteration order is deterministic (for RuntimeTranspilerCache hashing). diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 0f9cd0ff6a27..b0ff6aa0e8eb 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -1757,6 +1757,14 @@ impl JSTranspiler { }; let mut opts = bun_js_parser::ParserOptions::init(jsx, loader); + // Keep the scanner's grammar in sync with `parse()` (see `get_parse_result`), + // or the fast path rejects decorators and `accessor` fields the transpiler accepts. + let tsconfig = self.config.get().tsconfig.as_deref(); + opts.features.standard_decorators = bun_js_parser::RuntimeFeatures::standard_decorators_for( + loader, + tsconfig.is_some_and(|ts| ts.experimental_decorators), + tsconfig.is_some_and(|ts| ts.emit_decorator_metadata), + ); // SAFETY: see `transpiler_mut`. The `&mut Transpiler` is reborrowed // disjointly for `macro_context` (stored in `opts`) and `options.define` // (raw-addr read) below; both end when `opts` is consumed by `scan()`. diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 989a93e7f005..49408c5bd924 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -1799,6 +1799,12 @@ impl<'a> Repl<'a> { opts.repl_mode = true; opts.features.dead_code_elimination = false; // REPL needs all code opts.features.top_level_await = true; // Enable top-level await in REPL + opts.features.emit_decorator_metadata = vm.transpiler.options.emit_decorator_metadata; + opts.features.standard_decorators = bun_js_parser::RuntimeFeatures::standard_decorators_for( + bun_ast::Loader::Tsx, + vm.transpiler.options.experimental_decorators, + vm.transpiler.options.emit_decorator_metadata, + ); // 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 diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index bf2b3db6485c..431699e35ac7 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2074,6 +2074,48 @@ console.log(
);`), expect(imports.filter(({ path }) => path === "react")).toHaveLength(1); expect(imports).toHaveLength(2); }); + + describe.each(["ts", "tsx", "js", "jsx"])("with the %s loader", loader => { + const scanner = new Bun.Transpiler({ loader }); + const expected = [{ kind: "import-statement", path: "./dep" }]; + + it("accepts auto-accessor fields", () => { + const source = `import { a } from "./dep";\nclass C { accessor x = a; }`; + expect(scanner.scanImports(source)).toEqual(expected); + expect(scanner.scan(source).imports).toEqual(expected); + }); + + it("accepts standard decorators on a class", () => { + const source = `import { dec } from "./dep";\n@dec class C {}`; + expect(scanner.scanImports(source)).toEqual(expected); + expect(scanner.scan(source).imports).toEqual(expected); + }); + + it("accepts standard decorators on class members", () => { + const source = `import { dec } from "./dep";\nclass C { @dec accessor x = 1; @dec foo() {} }`; + expect(scanner.scanImports(source)).toEqual(expected); + expect(scanner.scan(source).imports).toEqual(expected); + }); + }); + + // `@dec()()` parses under TypeScript's legacy decorator grammar but not under the + // TC39 standard one, so it tells the two apart. + const legacyOnlyDecorator = `import { dec } from "./dep";\n@dec()() class C {}`; + + it("uses the standard decorator grammar by default, like transformSync", () => { + const standard = new Bun.Transpiler({ loader: "ts" }); + expect(() => standard.scanImports(legacyOnlyDecorator)).toThrow("Failed to scan imports"); + expect(() => standard.transformSync(legacyOnlyDecorator)).toThrow("Parse error"); + }); + + it("honors experimentalDecorators, like transformSync", () => { + const legacy = new Bun.Transpiler({ + loader: "ts", + tsconfig: { compilerOptions: { experimentalDecorators: true } }, + }); + expect(legacy.scanImports(legacyOnlyDecorator)).toEqual([{ kind: "import-statement", path: "./dep" }]); + expect(legacy.transformSync(legacyOnlyDecorator)).toContain("class C"); + }); }); const parsed = (code, trim = true, autoExport = false, transpiler_ = transpiler) => { diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 0d8e2ce79af9..44a49a4339f8 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -9,16 +9,18 @@ async function runRepl( input: string | string[], options: { env?: Record; + cwd?: string; } = {}, ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const inputStr = Array.isArray(input) ? input.join("\n") + "\n" : input; - const { env = {} } = options; + const { env = {}, cwd } = options; await using proc = Bun.spawn({ cmd: [bunExe(), "repl"], stdin: Buffer.from(inputStr), stdout: "pipe", stderr: "pipe", + cwd, env: { ...bunEnv, TERM: "dumb", @@ -751,6 +753,40 @@ describe.concurrent("Bun REPL", () => { expect(stripAnsi(stdout)).toContain("test"); expect(exitCode).toBe(0); }); + + test("auto-accessor fields are lowered", async () => { + using dir = tempDir("repl-accessor", {}); + const { stdout, exitCode } = await runRepl( + ["class Counter { accessor count = 7 }", "console.log('count is ' + new Counter().count)", ".exit"], + { cwd: String(dir) }, + ); + // The REPL echoes typed input back, so assert on a string only evaluation can produce. + const out = stripAnsi(stdout); + expect(out).toContain("count is 7"); + expect(out).not.toContain("SyntaxError"); + expect(exitCode).toBe(0); + }); + + test("tsconfig selects the legacy decorator grammar and metadata", async () => { + using dir = tempDir("repl-legacy-decorators", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true }, + }), + }); + const { stdout, exitCode } = await runRepl( + [ + "Reflect.metadata = (k, v) => t => { console.log('got ' + k); return t }", + "function dec(t) { return t }", + "class Legacy { @dec foo(a: string) {} }", + ".exit", + ], + { cwd: String(dir) }, + ); + // Only the legacy decorator lowering emits design-time metadata, so this also + // proves the REPL did not blanket-enable standard decorators. + expect(stripAnsi(stdout)).toContain("got design:type"); + expect(exitCode).toBe(0); + }); }); describe("welcome message", () => { From f9ce1f1e2ca4964d39033914ad57a9ad8c96bfc2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:57:04 +0000 Subject: [PATCH 2/2] ci: retrigger