## Summary
- Remove `NodeFlags::HasYield` and its accessor now that `require-yield`
detects yields locally.
- Delete the semantic-builder state that existed only to populate the
flag.
- Remove `AstNodeStore::current_node_flags`, which has no remaining
writers or non-empty values.
## Summary
- move the JSX entity decoder from `oxc_transformer` to `oxc_syntax`
- keep the existing accumulator-based API and transformer call sites unchanged
- move the stray-ampersand regression test with the decoder
This is a behavior-neutral preparation for handling JSX entity decoding closer to parsing in a follow-up.
Follow-on after #24422.
Make cloning semantic IDs (`NodeId`, `ScopeId` etc) use branchless arithmetic.
* Cloning `NodeId` now just uses a single OR operation to take into account whether to preserve the existing ID (`clone_in_with_semantic_ids`) or substitute a dummy ID (`clone_in`).
* Cloning `Cell<Option<ScopeId>>` is 2 instructions (NOT + AND) on x86_64, or just 1 on aarch64 (NOT-AND).
https://godbolt.org/z/1KWdc5hnb
The mechanism is a type `CloneInSemanticIds` which is used as `with_semantic_ids` instead of a `bool`:
```rs
#[repr(u32)]
pub enum CloneInSemanticIds {
With = 0,
Without = u32::MAX,
}
```
This is 4 bytes, rather than `bool`'s 1 byte, but that makes no difference - either way it takes 1 register to pass between functions. The choice of discriminants is what enables the cheap arithmetic.
This is even smaller in terms of binary size than the previous solution of marking `Cell::clone_in_impl` as `#[inline(never)]` (OR instruction is smaller than function call), and it's more performant as it loses the function call overhead. It also keeps `Cell`'s blanket implementation of `CloneIn` generalized.
Between #24422 and this PR, merging `CloneIn` with/without semantic IDs into a single path has gained us a large binary size reduction, and lost us nothing - or may even have gained us a marginal perf improvement.
No visible change on our benchmarks, as we don't use `CloneIn` much, but it may well be a measurable improvement for Rolldown, which clones whole ASTs.
## Summary
The `CloneIn` derive generated **two near-identical traversals over the whole AST** — `clone_in` (resets semantic ids to their default) and `clone_in_with_semantic_ids` (preserves them). Almost every node carries an id field, so the two bodies diverge nearly everywhere and the pair roughly doubled the clone codegen.
This threads *"preserve semantic ids?"* as a **runtime `bool`** through a **single** traversal:
- **`CloneIn::clone_in_impl(&self, with_semantic_ids: bool, allocator)` is the one required method.** `clone_in` / `clone_in_with_semantic_ids` are `#[inline(always)]` default wrappers that call it with `false` / `true`. Hand-written impls provide only `clone_in_impl`.
- The **derive emits a single `clone_in_impl` body per type** — no per-type `clone_in` / `clone_in_with_semantic_ids` wrappers.
- Semantic ids live in `Cell<NodeId>` / `Cell<Option<{Scope,Symbol,Reference}Id>>`. **`Cell<T: Copy + Default>::clone_in_impl` threads the flag**: preserve (`Cell::new(self.get())`) when `with_semantic_ids`, otherwise reset to the default (`Cell::new(T::default())` = `NodeId::DUMMY` / `None`). So the derive emits a plain `clone_in_impl(&self.node_id, …)` per id field — no per-field `if/else`.
- **`Cell::clone_in_impl` is `#[inline(never)]`** so the (for some consumers dead) preserve branch isn't inlined at every id-field site — see Results.
## Results
Measured `f736d87e77` → `db9f2ab41a` on this branch (release, stripped, fat-LTO):
| | before | after | Δ |
|---|--:|--:|--:|
| `oxc_ast` generated `CloneIn` (source lines) | 7,180 | 5,581 | **−1,599 (−22%)** |
| oxlint binary | 14,192,816 B | 14,126,656 B | **−66,160 B (−64.6 KiB)** |
| napi transform cdylib (`--features allocator`) | 3,633,312 B | 3,633,312 B | **0 (neutral)** |
The two binaries move differently because they clone differently:
- **oxlint / rolldown clone *with* semantic ids** (react compiler on `TSType`; whole-`Program` clones). They already carried chunks of both traversals; merging dedups them → **−64.6 KiB**.
- **napi transform / parse clone *without* ids** — `oxc_transformer` never calls `clone_in_with_semantic_ids`, so before this PR `--gc-sections` dropped that whole half. The merged traversal would force the preserve branch live at every id field (~+16 KiB); `#[inline(never)]` on the `Cell` leaf outlines it to one function per `Cell<T>`, keeping these binaries **flat**.
## Behavior & perf
Behavior is identical by construction — `clone_in` resets ids (`Cell::new(None)` / `Cell::new(DUMMY)`), `clone_in_with_semantic_ids` preserves them. The runtime flag is loop-invariant and well predicted; the `#[inline(never)]` leaf adds one outlined call per id-cell on the clone path (expected negligible — worth a benchmark spot-check). Generated files were produced by `just ast`.
---
🤖 AI-assisted (Claude Code), reviewed and measured by the author.
## Summary
- track TypeScript ambient contexts using a nesting depth in `SemanticBuilder`
- enter ambient context for declaration files and `declare` declarations, including declarations without their own scope
- replace namespace-based and ancestry-based ambient detection
- use the ambient context for reserved keywords, global augmentations, class heritage, computed class members, and bodyless accessors
- extend conformance coverage for ambient variables, properties, functions, and classes
This approach was discussed with overlookmotel yesterday after some brainstorming!
## Summary
- remove the unused `NodeFlags::ExportSpecifier` bit and `has_export_specifier` accessor
- stop maintaining unread export-specifier state in the semantic builder
Stacked on #24363.
## Summary
- remove the unused `NodeFlags::Class` bit and `has_class` accessor
- stop maintaining unread class ancestry state in the semantic builder
- preserve the remaining flag bit values
## Summary
- The single-use-variable inliner could merge `let num = await f(); g(v, num)` into `g(v, await f())`, moving the read of a closed-over lexical `v` ahead of the `await`. When the enclosing function runs while `v` is still in its Temporal Dead Zone (called before `let v` executes), the merged form throws `ReferenceError: Cannot access 'v' before initialization` while the original does not — a Svelte production-only crash surfaced via rolldown ([rolldown/rolldown#9959](https://github.com/rolldown/rolldown/issues/9959), [sveltejs/svelte#18454](https://github.com/sveltejs/svelte/issues/18454)).
- The fix adds a guard, `is_tdz_closed_over_read`, that blocks the reorder only for a **block-scoped binding closed over from an enclosing function** — the one shape that can observe the TDZ across an `await`/`yield`. Same-function lexicals, `var`, and parameters still inline; the member assignment-target path (`v.x = await f()`) gets the same guard.
- Covered by `inline_single_use_variable.rs::test_inline_read_before_await_tdz` (closed-over `let`/`const`/`using`/`class`, generator `yield`, member targets, two-level nesting, plus the safe `var` / parameter / same-function cases). Full `oxc_minifier` suite passes; `minsize` unchanged except `bundle.min.js` (+~10 bytes).
## The bug
The crash needs `init()` to run *before* `let v` initializes — here it is called in `p`'s initializer (circular imports cause the same shape), so `v` is in its TDZ while `init` runs:
```js
let p = init(), v = ext();
async function init() {
let num = await foo();
bar(v, num); // reads `v` AFTER the await: the await suspends, module
// eval runs `v = ext()`, then `init` resumes → `v` is set
}
export { p };
```
Inlining merges the temp into the argument, moving the read *before* the await:
```js
// before this PR (incorrect)
async function init() {
bar(v, await foo()); // reads `v` before the await → still in TDZ →
} // ReferenceError: Cannot access 'v' before initialization
// after (correct): the temp is preserved, the read stays after the await
async function init() {
let num = await foo();
bar(v, num);
}
```
(With a *normal* late call — `init` imported and invoked after this module finishes evaluating — `v` is already initialized and there is no crash. The inliner still keeps the temp because it cannot prove the call is late.)
## How it works
A reorder turns a **working** program into a **crashing** one in exactly one shape: the moved read is a **block-scoped binding** (`let`/`const`/`using`/`class`/`enum`) that is **closed over from an enclosing function**. The mechanism:
1. The enclosing `async`/generator function is called *before* the binding's declaration has run, so the binding is in its TDZ.
2. The original reads it *after* an `await`/`yield`. During that suspension, outer code runs the declaration and initializes the binding — so the read succeeds.
3. Inlining moves the read *before* the `await` (before the suspension), so it now observes the TDZ and throws.
A binding declared in the **same** function can't trigger this: the body runs top-to-bottom on every call, so the binding is always initialized before the read — it can't be initialized "mid-suspension" by the function's own later code. `var` / parameters have no TDZ at all.
So the guard blocks the reorder exactly when the read is **block-scoped and closed over**, and detects "closed over" structurally — walking from the read's scope out to the binding's declaration scope and checking whether a **function boundary** is crossed first (suspensions only live inside function scopes):
```rust
// unsafe to reorder iff: block-scoped binding AND the read crosses a
// function boundary before reaching the binding's declaration scope
is_block_scoped(symbol) && read_crosses_function_boundary(read_scope, decl_scope)
```
### Blocked — closed over (kept)
Each is kept because `v` is a closed-over block-scoped binding and the inliner can't prove `init` is never called early (see **The bug** above). For the call-argument case, with that early call spelled out:
```js
let p = init(), v = ext(); // init() runs before `v = ext()` → `v` in TDZ
async function init() {
let num = await foo();
bar(v, num); // kept: merging would read `v` before the await → ReferenceError
}
```
The same guard covers a member assignment target (the object is read before the write) and generators (`yield` is a suspension point too):
```js
// member target
let v = ext();
export async function init() { let num = await foo(); v.x = num; } // kept
// generator
let v = ext();
export function* init() { let num = yield foo(); bar(v, num); } // kept
```
### Still inlined — no TDZ hazard
```js
// parameter: not block-scoped, no TDZ
export async function init(v) {
let num = await foo();
bar(v, num); // → bar(v, await foo())
}
// `var`: function-scoped, no TDZ
export async function init() {
var v = ext();
let num = await foo();
bar(v, num); // → bar(ext(), await foo())
}
// same-function lexical: initialized before the read
export function outer() {
const v = ext();
return bar(v, ext2()); // → return bar(ext(), ext2())
}
```
## Comparison with other minifiers
Verified by minifying each case and running the output (early-call repro where `init()` runs before `let v` initializes):
| Case | Sound to fold? | this PR | esbuild | terser | SWC |
|---|---|---|---|---|---|
| closed-over `let v` as call arg `g(v, num)` | no (TDZ) | keep ✓ | keep ✓ | keep ✓ | fold ✗ |
| closed-over `let v` as member target `v.x = num` | no (TDZ) | keep ✓ | keep ✓ | keep ✓ | fold ✗ |
| plain assign `v = num` | yes | fold | fold | keep | fold |
| same-function `const` skipped past | yes | inline | keep | inline | inline |
| parameter `v` as call arg | yes | fold | keep | keep | fold |
esbuild and terser already decline the unsound reorder; this PR aligns oxc with them on the two TDZ cases while keeping oxc's existing (sound) folds on the rest. SWC currently performs the same unsound reorder and reproduces the `ReferenceError`.
Part of #23043.
`Vec::new_in`, `Vec::with_capacity_in`, `Vec::from_array_in` etc previously took an `&'a Allocator`. Instead make them take an `&A where A: GetAllocator<'a>`.
This makes them more flexible - you can pass a reference to any type which implements `GetAllocator` e.g. `ParserImpl`, `TraverseCtx` etc.
```rust
// Before (`ctx` here is `&mut TraverseCtx`)
let vec = ArenaVec::new_in(ctx.ast.allocator);
// After
let vec = ArenaVec::new_in(ctx);
```
The main motivation is to make them workable replacements for what are currently methods on `AstBuilder`, but will be removed in the new `AstBuilder`:
```rust
// Before (`ctx` here is `&mut TraverseCtx`)
let vec = ctx.ast.vec();
// After
let vec = ArenaVec::new_in(ctx);
// Instead of
let vec = ArenaVec::new_in(ctx.ast.allocator);
```
This is still longer code than the original, which is not ideal, but it's as short as can make them.
The main downside of this change is that when calling these methods with an `&Allocator`, you have to double reference:
```rust
let allocator: &'a Allocator = get_allocator_somehow();
// Passing `&&Allocator`
let vec = ArenaVec::new_in(&allocator);
```
This has no perf impact - these methods are inlined, and compiler collapses the double-reference like it wasn't there, but it's just ugly. Unfortunately, there's no way to implement `GetAllocator` so it'll accept both `&Allocator` and `&mut TraverseCtx`, and the latter is the much more common use case.
Pure refactor.
Use `ArenaBox` and `ArenaVec` everywhere for the arena-allocated types from `oxc_allocator`, instead of plain `Box` and `Vec`.
This disambiguates when `Vec` is `oxc_allocator::Vec` and when it's `std::vec::Vec`, making code clearer to follow.
Previously it was easy to confuse the two, and such confusion led to UB in `Traverse` (fixed in #23745).
Ditto other arena types - `ArenaHashMap`, `ArenaHashSet`, `ArenaStringBuilder`.
The only exceptions are:
1. Inside `oxc_allocator` crate itself.
2. AST type definitions.
3. Generated code (migrated separately in #23749).
Encode whether a function binding is a named function expression or an `async`/generator function as two internal `SymbolFlags` bits (`FunctionExpression`, `AsyncOrGeneratorFunction`), set at bind time.
The redeclaration checks now read these flags instead of the AST node:
- `check_redeclaration` checks `FunctionExpression` rather than fetching the declaration node to ask `Function::is_expression`.
- `check_redeclared_function` (Annex B.3.3) checks `AsyncOrGeneratorFunction` on each previous declaration, replacing the build-only `async_or_generator_function_node_ids` Vec and its linear scan.
The transformer keeps the flags accurate when it rewrites functions, via a new `sync_function_symbol_flags` helper applied at the function-rewriting sites in `async_to_generator` and class private-method extraction.
Behaviour-neutral: semantic conformance snapshots are unchanged.
`CommentNodeId` is a type we added some time ago, but never used. We now have `NodeId`s stored in all AST structs, so we can use that for comment attachment if we choose.
Remove `CommentNodeId` type, and all code referencing it.
Only code remaining that references it is in `ast_tools` codegen. I've left that code so that it can be repurposed for `NodeId` in next PR (#21680).
Simplify `ESTree` generator in `ast_tools`.
Previously it had "special case" code for `NodeId`. Instead, add a `#[estree(skip)]` attr to `NodeId` type, and then `node_id: Cell<NodeId>` fields can be treated the same as any other field.
Does not alter generated code, only the means by which it's generated.
Simplify `ContentEq` generator in `ast_tools`.
Previously it had "special case" code for `NodeId`. Instead, add a `#[content_eq(skip)]` attr to `NodeId` type, and then `node_id: Cell<NodeId>` fields can be treated the same as any other field.
Does not alter generated code, only the means by which it's generated.
Simplify `Dummy` generator in `ast_tools`.
Previously it had "special case" code for `NodeId`. Instead, implement `Dummy` on `NodeId`, and then `node_id: Cell<NodeId>` fields can be treated the same as any other field.
Simplify `CloneIn` generator in `ast_tools`.
Previously it had "special case" code for `NodeId`. Instead, implement `CloneIn` and `Default` on `NodeId`, and then `node_id: Cell<NodeId>` fields can be treated the same as any other field.
Simplify treatment of `NodeId` in `ast_tools` codegen.
Add `#[ast]` attr to the type, and allow codegen to discover it, rather than hard-coding it into codegen as a "primitive" type.
This matches how `SymbolId`, `ScopeId` and `ReferenceId` are treated.
Does not alter generated code, only the means by which it's generated.
## Summary
- Extend `MemberWriteTarget` flag to cover compound assignments (`+=`), update expressions (`++/--`), and `delete` — previously only simple `=` assignments were covered
- Add `visit_unary_expression` override to set `Write` for `delete` on member expressions
- Change `is_write_only()` to `is_write()` in `visit_member_expression` so all write contexts trigger the flag
- Fix `MemberWriteTarget` leaking into conditional test positions (`(a ? x : y).foo = 1` was incorrectly marking `a`)
- Add snapshot test covering compound assignments, delete, and conditional expressions
- Update doc comments to reflect broader scope
## Test plan
- [x] New snapshot test `member-write-target.js` covering compound assignments, delete, conditional test/consequent/alternate
- [x] Existing semantic snapshot tests updated
- [x] `just ready` passes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
For historical reasons, `oxc_span` crate was re-exporting string types which now live in `oxc_str` crate.
Remove these re-exports, and import these types directly from `oxc_str`.
Main motivation is just that it makes sense, but it should also have a minor positive effect on compile times - `oxc_span` and `oxc_str` crates can now be compiled in parallel.
## Summary
Part of https://github.com/oxc-project/oxc/issues/6073 and #11844
- Add `ConstantValue` type in `oxc_syntax` for representing enum member values (number/string)
- Implement enum member value evaluation in `oxc_semantic` supporting numeric auto-increment, string values, binary/unary expressions, cross-member and cross-enum references, template literals, and merged enum declarations
- Store evaluated enum member values in `Scoping` via `EnumData` struct with getter/setter APIs and enum body scope tracking
- All enum evaluation code is gated behind the `enum_eval` Cargo feature (enabled by `oxc_transformer`)
### Feature gate
The `enum_eval` feature controls:
- `ts_enum` module (evaluator + data types)
- `EnumData` field and enum-related methods on `Scoping`
- `oxc_syntax/to_js_string` dependency
Consumers that don't need enum values (linter, language server) pay zero cost.
### Babel alignment
Unary operators on strings follow Babel's JS-coercion semantics (`+"s"` → `"s"`, `-"s"` → `NaN`, `~"s"` → `-1`), which differs from TypeScript (unevaluated). This is documented in the source.
## Test plan
- [x] Integration tests in `crates/oxc_semantic/tests/integration/enum_values.rs`
- [x] Semantic TypeScript conformance snapshot updated
- [x] `cargo check -p oxc_semantic` passes (without feature)
- [x] `cargo check -p oxc_semantic --features enum_eval` passes (with feature)
- [x] `cargo check -p oxc_transformer` passes (enables feature transitively)
## Summary
Add a new `MemberWriteTarget` bit to `ReferenceFlags` ~~Store member write target references in a separate `FxHashSet<ReferenceId>` on `Semantic` rather than adding a flag to `ReferenceFlags`.~~
This marks identifier references read as the object of a member expression in a simple assignment target position (e.g., `A` in `A.foo = 1`).
- Set during `visit_member_expression` when flags indicate write-only context (simple `=`, not compound `+=` or update `++`)
- Stored in `Semantic::member_write_references` (`FxHashSet<ReferenceId>`)
- Queryable via `Semantic::is_member_write_reference(reference_id)` and `Semantic::member_write_references()`
- Does not affect existing `ReferenceFlags` — no changes to the shared bitflags type
## Motivation
Distinguishing "reads that exist only to serve a property write" from "real reads" is broadly useful:
### Minifier: dead code elimination of property-write-only symbols
```js
function A() {}
A.from = () => {} // A's only reference is a member write target
// → both can be eliminated when property_write_side_effects: false
```
Without this info, the minifier sees `A` as "referenced" and cannot remove the function declaration. With it, the minifier knows all of `A`'s reads are property-write targets and can safely drop them. (See stacked PR #20773 — will be updated to use the new API in a follow-up)
### Linter: `no_unused_vars` improvement
```js
const config = {};
config.debug = true;
config.verbose = false;
// config is never read — only mutated via property writes
```
Currently `config` is considered "used" because `config.debug = ...` creates a read reference. With member write target tracking, `no_unused_vars` could detect that `config`'s only reads are property-write targets and report it as unused.
### Linter: simplify `no_param_reassign` property detection
`no_param_reassign` has a complex `is_modifying_property()` function that manually walks the ancestor chain to detect property modifications like `param.foo = 1`. With member write target tracking, this can be checked directly on the reference.
### Rolldown: replace `MemberExprIsWrite` traversal state
Rolldown already maintains its own [`MemberExprIsWrite` flag](https://github.com/rolldown/rolldown/blob/eec7d7320cc991e1efd5ed8264d02c6889a223c3/crates/rolldown/src/ast_scanner/mod.rs#L130-L131) as local traversal state in the AST scanner to [detect member expressions in write context](https://github.com/rolldown/rolldown/blob/eec7d7320cc991e1efd5ed8264d02c6889a223c3/crates/rolldown/src/ast_scanner/impl_visit.rs#L55-L73). Having this on `Semantic` means Rolldown can consume it directly instead of re-detecting it during scanning. There's also a [TODO in Rolldown's treeshake options](https://github.com/rolldown/rolldown/blob/main/crates/rolldown_common/src/inner_bundler_options/types/treeshake.rs#L255-L259) to wire `property_write_side_effects` to oxc_minifier — our stacked PR #20773 provides that.
### General: escape analysis for local objects
Any analysis that needs to know "is this object only mutated locally, never passed elsewhere?" benefits from knowing which reads are purely for property writes vs. reads that pass the value to other functions.
## Test plan
- [x] Semantic snapshot tests updated
- [x] Conformance snapshots updated
- [x] Clippy clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Pure refactor.
Rename `Atom` to `Str`. The name "Atom" is a leftover from when we used to intern strings (years ago), and is misleading now.
Just call it `Str`. This follows the naming convention we have for arena-allocated versions of other types e.g. `Box`, `Vec`, `HashMap` - which we name the same as the native equivalents but with a lifetime (`Box<'a>` not `ArenaBox<'a>`, so `Str<'a>` not `ArenaStr<'a>`).
Rename all variables that contained "atom", and methods which relate to `Str`s to match the new type name.
Rename `as_atom` methods to `as_arena_str`. The longer name is required here to disambiguate from `as_str` methods which return a `&str`.
Some variables called "atom" still exist in `oxc_regular_expression` crate - that's a different meaning of "atom" - the meaning defined in regexp spec.
The diff of this PR is large but it's 100% renaming. No substantive or unrelated changes whatsoever.
Refactor. Generate assertions for type sizes in the order which they are in memory. This makes it easier to see how these types are laid out in memory.
One annoyance with `ast_tools`, our codegen, has been that when you want it to generate code from types in some file, you had to manually add the file paths to a list in `ast_tools` itself.
This PR removes that list and instead finds crates in the monorepo which depend on `oxc_ast_macros` crate, via `cargo metadata`, and searches all files in those crates for types with `#[ast]` attributes etc - types that it need to act on.
There is no longer a list to keep updated, you just mark types `#[ast]` and the codegen will find them.
This produces some churn in generated files. None of the generated code changes, it just gets re-ordered in some places, due to the different order that files get found in now. This order is deterministic, and it should not change again.