Skip to content

resolver: carry the decorator options of an extending tsconfig across extends - #48569

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/ccdec5fd/tsconfig-extends-decorators
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/ccdec5fd/tsconfig-extends-decorators

Conversation

@robobun

@robobun robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #48558

Problem

  • experimentalDecorators: true in a tsconfig.json that has an extends line is ignored when the base file does not set it. bun a.ts then lowers @legacy x = 1 as a TC39 field decorator (the decorator gets 2 arguments instead of 3). MobX reports Please use @observable accessor for such projects.
  • The cause is the extends merge loop in Resolver::load_tsconfig (src/resolver/resolver.rs:6845). It copies emitDecoratorMetadata, useDefineForClassFields, baseUrl, jsx and paths from the extending file, and never experimentalDecorators. The flag was added to TSConfigJSON in feat(transpiler): implement TC39 standard ES decorators lowering #26436 and the loop was not updated.
  • A second gap in the same loop: both decorator flags are plain bool, so an extending file cannot turn off a flag its base turns on. tsc applies each compilerOptions key of the extending file over the base.

Fix

  • TSConfigJSON::emit_decorator_metadata and experimental_decorators are Option<bool>: unset, or the value the file writes. Readers call emit_decorator_metadata() and experimental_decorators(), which return false when unset.
  • The merge loop body moves into TSConfigJSON::apply_extended_config(&mut self, child). It destructures every field of child without .., so a field added to the struct does not compile until the merge says how it crosses extends. A value the extending file sets replaces the value of the base (child.or(base)), as for useDefineForClassFields today.
  • ParsedConfig::inherit in src/sema/resolve/code/config.rs, the copy of this merge that bun check and --aot use, gets the same tri-state fields and the same rule. Its tests that pinned the old behaviour are rewritten for the new one.
  • Verified: test/js/bun/resolve/tsconfig-extends-leak.test.ts (10 new tests, 5 fail on bun 1.4.3). Also cargo test -p bun_resolver -- tsconfig_json, cargo test -p bun_sema -- resolve::code::config, and the decorator, useDefineForClassFields, tsconfig-override and aot tsconfig suites under test/bundler/transpiler, test/cli/run and test/aot.

Background

  • load_tsconfig walks the extends chain to its base, then folds each extending file onto the base, nearest file last. The folded TSConfigJSON is stored once per directory and read on every resolve, so the merge runs once per project and the readers pay nothing new.
  • bun check and bun run --aot read tsconfig through bun_sema, which has its own ParsedConfig and its own inherit. That copy mirrored the old runtime behaviour on purpose (a comment said so), so it changes in the same PR.
  • Considered the one-line fix (mc.experimental_decorators ||= child.experimental_decorators, like emitDecoratorMetadata): it fixes the reported case and keeps false in the extending file from clearing true in the base, for both flags. typescript: match tsc for experimentalDecorators and emitDecoratorMetadata #46553 (a larger, open rewrite) moves to the same Option<bool> and .or() shape as this PR, so the two do not disagree on semantics.
  • Two open PRs, resolver: support the tsconfig extends array form #43110 and resolver: resolve tsconfig extends like tsc (packages, exports, arrays, configDir) #40823, fold the same merge fix into larger extends work (array form, package specifiers). This PR is the fix on its own.

Downsides

  • Behaviour change: a project with experimentalDecorators: false or emitDecoratorMetadata: false in an extending file over a base with true now gets the value of the extending file, as under tsc. Before, true won for emitDecoratorMetadata, and the base's value for experimentalDecorators.
  • Size and time: size_of::<TSConfigJSON>() is 312 bytes before and after (Option<bool> uses the niche). The merge runs once per directory that has a tsconfig.json with extends. For a 3-deep chain the debug allocation log shows 3 new(TSConfigJSON) and 2 destroy(TSConfigJSON), the same counts as before. Release binary size not measured: bloaty is not installed here.
  • Syscalls: none added. The merge reads no file. strace is not installed here, so this is from the code, not a count.
Notes
  • Reproduced on bun 1.4.3 and on a debug build of main at 89452d2 with the files from the issue. The table in the issue (flag in the extending file, in the base, in both, in neither) is the first test.each block of the new tests.
  • The merged config now takes the abs_path of the extending file, the file the directory has. Only a resolver debug note reads it.
  • preserve_imports_not_used_as_values keeps its merge rule. Its default is Some(false), so the extending file always wins. Nothing outside the resolver reads it.
  • test/cli/check/check.test.ts and test/cli/run/run-aot.test.ts were run too. Their failures in this container are timeouts at 5 s under the ASAN build when run beside another file, and one --aot stack trace case that lacks a require (native) frame. That case reads no tsconfig option. The tsconfig cases of both files pass alone.

… extends

TSConfigJSON::experimental_decorators and emit_decorator_metadata are
tri-state. The extends merge lives in TSConfigJSON::apply_extended_config,
which names every field, so the config that extends another replaces the
options it sets and inherits the rest. The sema twin ParsedConfig::inherit
does the same.
@robobun

robobun commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:43 AM PT - Nov 11th, 2026

@robobun, your commit d40a37d is building: #140102

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

experimentalDecorators in a tsconfig with extends is ignored when the base doesn't set it

2 participants