diff --git a/src/bundler/transpiler.zig b/src/bundler/transpiler.zig index 4368fd159ba8..aa906a051f97 100644 --- a/src/bundler/transpiler.zig +++ b/src/bundler/transpiler.zig @@ -244,13 +244,17 @@ pub const Transpiler = struct { if (auto_jsx) { // Most of the time, this will already be cached if (transpiler.resolver.readDirInfo(transpiler.fs.top_level_dir) catch null) |root_dir| { - if (root_dir.tsconfig_json) |tsconfig| { + // Prefer the tsconfig that lives in this directory, but fall back + // to the enclosing one (e.g. `--tsconfig-override` attaches the + // override to the filesystem root; children inherit via + // `enclosing_tsconfig_json` only). + if (root_dir.tsconfig_json orelse root_dir.enclosing_tsconfig_json) |tsconfig| { // If we don't explicitly pass JSX, try to get it from the root tsconfig if (transpiler.options.transform_options.jsx == null) { transpiler.options.jsx = tsconfig.jsx; } - transpiler.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata; - transpiler.options.experimental_decorators = tsconfig.experimental_decorators; + transpiler.options.emit_decorator_metadata = tsconfig.emit_decorator_metadata orelse false; + transpiler.options.experimental_decorators = tsconfig.experimental_decorators orelse false; } } } @@ -281,7 +285,10 @@ pub const Transpiler = struct { // env vars were already loaded above. const dir_info = this.resolver.readDirInfo(this.fs.top_level_dir) catch return orelse return; - if (dir_info.tsconfig_json) |tsconfig| { + // Same fallback as configureLinkerWithAutoJSX above: when + // top_level_dir has no tsconfig of its own (ancestor-only or + // --tsconfig-override), inherit from the enclosing chain. + if (dir_info.tsconfig_json orelse dir_info.enclosing_tsconfig_json) |tsconfig| { this.options.jsx = tsconfig.mergeJSX(this.options.jsx); } diff --git a/src/resolver/resolver.zig b/src/resolver/resolver.zig index 71c953035bb2..5fcf0f46f8b8 100644 --- a/src/resolver/resolver.zig +++ b/src/resolver/resolver.zig @@ -1063,8 +1063,11 @@ pub const Resolver = struct { if (dir.enclosing_tsconfig_json) |tsconfig| { result.jsx = tsconfig.mergeJSX(result.jsx); - result.flags.emit_decorator_metadata = result.flags.emit_decorator_metadata or tsconfig.emit_decorator_metadata; - result.flags.experimental_decorators = result.flags.experimental_decorators or tsconfig.experimental_decorators; + // The `?bool` on TSConfigJSON is collapsed to bool here — only + // an explicit `true` in the (already-merged) tsconfig enables + // the feature. + result.flags.emit_decorator_metadata = result.flags.emit_decorator_metadata or (tsconfig.emit_decorator_metadata orelse false); + result.flags.experimental_decorators = result.flags.experimental_decorators or (tsconfig.experimental_decorators orelse false); } // If you use mjs or mts, then you're using esm @@ -4204,9 +4207,16 @@ pub const Resolver = struct { } if (tsconfig_path) |tsconfigpath| { + // Pass .invalid for --tsconfig-override: the override path may + // live outside the directory we're iterating, so `fd` isn't its + // parent. Using it would trip the `openat(dirname_fd, basename(path))` + // path in cache.zig and log an "Internal error: directory mismatch" + // warning before falling back to an absolute-path open. + const is_override = r.opts.tsconfig_override != null and parent == null; + const tsconfig_dir_fd: FD = if (FeatureFlags.store_file_descriptors and !is_override) fd else .invalid; info.tsconfig_json = r.parseTSConfig( tsconfigpath, - if (FeatureFlags.store_file_descriptors) fd else .zero, + tsconfig_dir_fd, ) catch |err| brk: { const pretty = tsconfigpath; if (err == error.ENOENT or err == error.FileNotFound) { @@ -4241,10 +4251,18 @@ pub const Resolver = struct { } var merged_config = parent_configs.pop().?; - // starting from the base config (end of the list) - // successively apply the inheritable attributes to the next config + // parent_configs was built child-first and then walked up + // the extends chain, so `merged_config` starts as the + // deepest base and each iteration folds in a progressively + // more-specific (closer-to-child) config on top. TypeScript's + // extends semantics are per-key override: a child's explicit + // value wins over the parent's, even when the value is + // `false`. A plain `or` on `bool` can't express that, so + // these fields are `?bool` and we overwrite only when the + // more-specific config set the key explicitly. while (parent_configs.pop()) |parent_config| { - merged_config.emit_decorator_metadata = merged_config.emit_decorator_metadata or parent_config.emit_decorator_metadata; + if (parent_config.emit_decorator_metadata) |v| merged_config.emit_decorator_metadata = v; + if (parent_config.experimental_decorators) |v| merged_config.experimental_decorators = v; if (parent_config.base_url.len > 0) { merged_config.base_url = parent_config.base_url; } diff --git a/src/resolver/tsconfig_json.zig b/src/resolver/tsconfig_json.zig index cf020f23648f..13c856b1edcd 100644 --- a/src/resolver/tsconfig_json.zig +++ b/src/resolver/tsconfig_json.zig @@ -40,8 +40,11 @@ pub const TSConfigJSON = struct { preserve_imports_not_used_as_values: ?bool = false, - emit_decorator_metadata: bool = false, - experimental_decorators: bool = false, + // Optional so the extends-merge can distinguish "child didn't say" from + // "child explicitly set false". TypeScript's `extends` semantics are + // per-key override: a child's `false` wipes out a parent's `true`. + emit_decorator_metadata: ?bool = null, + experimental_decorators: ?bool = null, pub fn hasBaseURL(tsconfig: *const TSConfigJSON) bool { return tsconfig.base_url.len > 0; diff --git a/src/runtime/api/JSTranspiler.zig b/src/runtime/api/JSTranspiler.zig index 7d231902efeb..a3a2d5565802 100644 --- a/src/runtime/api/JSTranspiler.zig +++ b/src/runtime/api/JSTranspiler.zig @@ -519,8 +519,8 @@ pub const TransformTask = struct { .path = source.path, .virtual_source = &source, .replace_exports = this.replace_exports, - .experimental_decorators = if (this.tsconfig) |ts| ts.experimental_decorators else false, - .emit_decorator_metadata = if (this.tsconfig) |ts| ts.emit_decorator_metadata else false, + .experimental_decorators = if (this.tsconfig) |ts| (ts.experimental_decorators orelse false) else false, + .emit_decorator_metadata = if (this.tsconfig) |ts| (ts.emit_decorator_metadata orelse false) else false, }; const parse_result = this.transpiler.parse(parse_options, null) orelse { @@ -817,8 +817,8 @@ fn getParseResult(this: *JSTranspiler, allocator: std.mem.Allocator, code: []con .virtual_source = source, .replace_exports = this.config.runtime.replace_exports, .macro_js_ctx = macro_js_ctx, - .experimental_decorators = if (this.config.tsconfig) |ts| ts.experimental_decorators else false, - .emit_decorator_metadata = if (this.config.tsconfig) |ts| ts.emit_decorator_metadata else false, + .experimental_decorators = if (this.config.tsconfig) |ts| (ts.experimental_decorators orelse false) else false, + .emit_decorator_metadata = if (this.config.tsconfig) |ts| (ts.emit_decorator_metadata orelse false) else false, }; return this.transpiler.parse(parse_options, null); diff --git a/test/regression/issue/30477.test.ts b/test/regression/issue/30477.test.ts new file mode 100644 index 000000000000..4f1766d526b2 --- /dev/null +++ b/test/regression/issue/30477.test.ts @@ -0,0 +1,213 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/30477 +// experimentalDecorators was silently dropped across tsconfig extends chains. + +// The probe decorator distinguishes legacy vs stage-3 emit at runtime: +// legacy decorators pass exactly one argument (the target class); +// stage-3 decorators pass two (value + context). +const PROBE_SOURCE = ` +function probe(...args: unknown[]) { + if (args.length === 1) { + console.log("legacy"); + } else { + console.log("stage-3"); + } +} + +@probe +class Foo {} + +console.log("OK"); +`; + +test.concurrent("experimentalDecorators: true is preserved through an extends chain", async () => { + using dir = tempDir("bun-30477-extends", { + "base-tsconfig.json": JSON.stringify({ + compilerOptions: { target: "esnext" }, + }), + "tsconfig.json": JSON.stringify({ + extends: "./base-tsconfig.json", + compilerOptions: { module: "esnext", experimentalDecorators: true }, + }), + "index.ts": PROBE_SOURCE, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("legacy\nOK\n"); + if (exitCode !== 0) { + expect(stderr).toBe(""); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("experimentalDecorators inherited from the base tsconfig still wins", async () => { + using dir = tempDir("bun-30477-base", { + "base-tsconfig.json": JSON.stringify({ + compilerOptions: { target: "esnext", experimentalDecorators: true }, + }), + "tsconfig.json": JSON.stringify({ + extends: "./base-tsconfig.json", + compilerOptions: { module: "esnext" }, + }), + "index.ts": PROBE_SOURCE, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("legacy\nOK\n"); + if (exitCode !== 0) { + expect(stderr).toBe(""); + } + expect(exitCode).toBe(0); +}); + +// The following two cases pin down TypeScript's per-key override semantics for +// `extends`: a child's explicit value wins over the parent's, even when the +// child's value is `false`. Without this, `or`-merging made `true` sticky — +// a base config could force legacy decorators on every child that extended it. +test.concurrent("child experimentalDecorators: false overrides parent true (disables legacy)", async () => { + using dir = tempDir("bun-30477-child-false-exp", { + "base-tsconfig.json": JSON.stringify({ + compilerOptions: { target: "esnext", experimentalDecorators: true }, + }), + "tsconfig.json": JSON.stringify({ + extends: "./base-tsconfig.json", + compilerOptions: { module: "esnext", experimentalDecorators: false }, + }), + "index.ts": PROBE_SOURCE, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Child explicitly opts out of legacy decorators — stage-3 should win. + expect(stdout).toBe("stage-3\nOK\n"); + if (exitCode !== 0) { + expect(stderr).toBe(""); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("child emitDecoratorMetadata: false overrides parent true", async () => { + // When emitDecoratorMetadata is true, Bun imports reflect-metadata and + // emits __metadata() calls — the decorator receives argument-type info + // reflected into `design:paramtypes`. A child that sets the flag back to + // false must disable that emission, leaving the legacy decorator call + // with no reflect-metadata lookup at all. + // + // We verify via runtime observation (is Reflect.getMetadata reachable?) + // rather than scanning bundler output — the test is then insensitive to + // how the bundled runtime helpers happen to be named. + const META_SOURCE = ` +// Report whether the decorated class saw design:paramtypes metadata. +// If emitDecoratorMetadata is on, the decorator calls below would have +// invoked Reflect.metadata("design:paramtypes", [Number]) and we'd read +// it back via Reflect.getMetadata. +(globalThis as any).Reflect = (globalThis as any).Reflect ?? {}; +const metaMap = new WeakMap(); +(globalThis as any).Reflect.metadata = (k: string, v: unknown) => (t: object, p: string) => { + let entry = metaMap.get(t); + if (!entry) metaMap.set(t, entry = {}); + entry[p] = entry[p] ?? {}; + entry[p][k] = v; +}; + +function probe(target: unknown, key: unknown) {} + +class Foo { + @probe + foo(a: number) {} +} + +// If the child's emitDecoratorMetadata: false was respected, our +// Reflect.metadata shim was never called and the WeakMap is empty. +console.log(metaMap.has(Foo.prototype) ? "HAS_METADATA" : "NO_METADATA"); +`; + using dir = tempDir("bun-30477-child-false-meta", { + "base-tsconfig.json": JSON.stringify({ + compilerOptions: { + target: "esnext", + experimentalDecorators: true, + emitDecoratorMetadata: true, + }, + }), + "tsconfig.json": JSON.stringify({ + extends: "./base-tsconfig.json", + compilerOptions: { module: "esnext", emitDecoratorMetadata: false }, + }), + "index.ts": META_SOURCE, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "./index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // Child opted out of decorator metadata — the runtime shim must not have + // been called, so the map stays empty. + expect(stdout).toBe("NO_METADATA\n"); + if (exitCode !== 0) { + expect(stderr).toBe(""); + } + expect(exitCode).toBe(0); +}); + +test.concurrent("--tsconfig-override picks up experimentalDecorators via extends", async () => { + using dir = tempDir("bun-30477-override", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { target: "esnext" }, + }), + "tsconfig.bun.json": JSON.stringify({ + extends: "./tsconfig.json", + compilerOptions: { + module: "esnext", + experimentalDecorators: true, + }, + }), + "index.ts": PROBE_SOURCE, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--tsconfig-override", "./tsconfig.bun.json", "./index.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("legacy\nOK\n"); + // The bogus "Internal error: directory mismatch" warning from the + // override-fd path must not appear — the resolver now passes an + // invalid dirname_fd when the override path isn't a child of the + // directory being iterated. + expect(stderr).not.toContain("directory mismatch"); + expect(exitCode).toBe(0); +});