Skip to content
15 changes: 11 additions & 4 deletions src/bundler/transpiler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Comment thread
robobun marked this conversation as resolved.
// 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;
}
}
}
Expand Down Expand Up @@ -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);
}

Expand Down
30 changes: 24 additions & 6 deletions src/resolver/resolver.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 5 additions & 2 deletions src/resolver/tsconfig_json.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ pub const TSConfigJSON = struct {

preserve_imports_not_used_as_values: ?bool = false,
Comment thread
robobun marked this conversation as resolved.

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;
Expand Down
8 changes: 4 additions & 4 deletions src/runtime/api/JSTranspiler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
213 changes: 213 additions & 0 deletions test/regression/issue/30477.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
robobun marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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<object, any>();
(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);
});
Loading