What
---
Fix two O(n²) bottlenecks in the compressor that cause severe slowdowns
on bundler output (esbuild, webpack CJS) containing thousands of
root-level `var import_X = __toESM(require_Y())` declarations, the
pattern reported in #17369, where a file only 6% larger took 2.5x longer
to minify.
Fixes#17369
Specifically:
Batch reference retention in `exit_program`
---
After each compressor iteration, stale references are cleaned up by
diffing live references against the symbol table. The per-reference
`delete_resolved_reference` method uses `.position()` internally, and
that's a linear scan of the symbol's reference list, so repeated
deletions are O(n²) when many references to the same symbol are removed.
Replace with a new `Scoping::retain_resolved_references` method that
iterates each symbol's reference list once, retaining only references
present in the live set. Total cost: O(total_refs).
Cached `is_symbol_mutated` helper
---
`Scoping::symbol_is_mutated(symbol_id)` scans all resolved references
for the symbol looking for a write. For heavily-referenced symbols like
`__toESM` (10k+ refs in the issue's bad.js) each call is O(num_refs),
and when it is called repeatedly during compression the total becomes
O(n²).
Add `PeepholeOptimizations::is_symbol_mutated`, which reads the
`write_references_count` already cached in `SymbolValue` (populated
during `exit_variable_declarator` → `init_symbol_value`). This is O(1).
It falls back to the full scan for symbols without cached values
(function declarations and other non-variable bindings).
Use this helper instead of `Scoping::symbol_is_mutated` in two places:
- Directly in `substitute_single_use_symbol_in_expression`, which walks
target expressions looking for single-use symbols to inline. When it
encounters an identifier it checks whether reordering the replacement
past that identifier is safe. With ~10k substitution attempts per
iteration on bad.js, this was the dominant O(n²) contributor.
- Via `is_expression_that_reference_may_change`, which checks whether
the base object of a member-expression assignment target can be mutated.
It is called from:
- `substitute_single_use_symbol_in_expression`, when the assignment
target is `foo.bar = …`
- `member_object_may_be_mutated`, which is called from
`minimize_logical_expression` and `remove_unused_expression`
Performance
---
On the real-world files from #17369, on my machine, the time taken by
```
Compressor::new(&allocator).build_with_scoping(
&mut program,
scoping,
CompressOptions::smallest(),
)
```
changes thus:
```
good.js (10.4 MB): 48.61 → 47.56 ms (2.2% faster, or maybe that's just noise)
bad.js (11.1 MB): 389.18 → 57.18 ms (6.8x faster)
ratio bad/good: 8.0x → 1.2x
```
The total minification time (parse + compress + mangle) changes thus:
```
good.js (10.4 MB): 91.50 → 91.04 ms (0.5% faster, probably mostly noise!)
bad.js (11.1 MB): 436.28 → 104.40 ms (4.2x faster)
ratio bad/good: 4.8x → 1.15x
```
For both compress and total, the bad/good ratio is now much closer to
the ratio between the file sizes.
## Summary
Fixes#21150
When a single-parameter generic arrow function in a `.tsx` file has a
**default type** (e.g. `<Options = any>`), the formatter was incorrectly
removing the trailing comma. Prettier preserves it as `<Options =
any,>`.
The existing `should_force_trailing_comma_for_arrow_function` logic
handles the simple case (`<T,>`) correctly, but it also skipped forcing
the comma when the parameter had a default type. Prettier's equivalent
only skips the forced comma when a **constraint** (`extends` clause) is
present — not for default types. This PR aligns with that behavior.
### Input
```tsx
const AppWrapper = <Options = any,>({
appName,
}: {
appName: string;
}) => null;
const getProps = <T = Record<string, unknown>,>(
option: T | undefined,
): Partial<T> => ({});
```
### Before (incorrect)
```tsx
const AppWrapper = <Options = any>({ appName }: { appName: string }) => null;
const getProps = <T = Record<string, unknown>>(
option: T | undefined,
): Partial<T> => ({});
```
### After (matches Prettier)
```tsx
const AppWrapper = <Options = any,>({ appName }: { appName: string }) => null;
const getProps = <T = Record<string, unknown>,>(
option: T | undefined,
): Partial<T> => ({});
```
## Root cause
In `should_force_trailing_comma_for_arrow_function`
(`type_parameters.rs`), the early-return condition checked for both a
constraint **and** a default type:
```rust
// Before
if params.first().is_some_and(|t| t.constraint().is_some() || t.default().is_some()) {
return false;
}
```
Prettier's equivalent only skips forcing the comma when a **constraint**
is present. See [Prettier
source](https://github.com/prettier/prettier/blob/070c89bba46235f4948560ed612a11e89ccd2da9/src/language-js/print/type-parameters.js#L33-L42).
## Fix
Removed `|| t.default().is_some()` from the condition:
```rust
// After
if params.first().is_some_and(|t| t.constraint().is_some()) {
return false;
}
```
Prettier conformance: **no regressions** (746/753 JS, 591/601 TS —
unchanged).
## AI Disclosure
This PR was co-authored with Claude Code (AI assistant), as noted in the
commit. The fix was reviewed, tested against the full Prettier
conformance suite, and verified to produce no regressions.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- Use raw property/identifier names instead of `get_var_name_from_node` (which strips leading underscores) when generating private backing field names for accessor properties
- For non-computed keys, use `PropertyKey::name()` which preserves leading underscores
- For computed keys, match on `Expression::Identifier` to get the raw name directly
- No counters, maps, or helper functions needed — collisions are avoided at the source
This prevents `accessor prop` and `accessor _prop` from producing the same `#_prop_accessor_storage` name, which caused a runtime `TypeError: Cannot initialize the same private elements twice on an object`.
Closes#20946
Supersedes #21023
## Test plan
- [x] Added test fixture covering both non-computed (`prop`/`_prop`) and computed (`[prop]`/`[_prop]`) accessor name collisions
- [x] All existing accessor tests pass unchanged
- [x] `cargo test -p oxc_transformer` passes
- [x] Full `cargo run -p oxc_transform_conformance` passes
- [x] `cargo clippy` clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Fixes#21107
Messages that were previously omitted when `no_error_on_unmatched_pattern` is specified will now be displayed, resulting in more consistent behavior.
Small perf optimization to raw transfer. When calculating a `u32` offset, shift first then add (`(pos >> 2) + 4`) instead of adding first (`(pos + 16) >> 2`). If a function contains multiple `pos >> 2` calculations, V8 may be able to combine them into a single calculation. Previously it couldn't prove that `pos` is a multiple of 4, so couldn't make this optimization.
Continuation of #21132. All uses of the `Uint32Array` view of the buffer in raw transfer code have been removed in preceding PRs. So we can now remove it entirely.
Continuation of #21132. Use `Int32Array` view of the buffer for all code in lazy deserialization. Lazy deserialization is not currently in use, but keeping it in line with the main raw transfer deserializer allows removing the `Unint32Array` view of the buffer entirely in #21140 without breaking the lazy deserializer tests.
Small perf optimization. When deserializing tokens in `oxc-parser`, use only the `Int32Array` view of the buffer, instead of both `Int32Array` and `Uint8Array`. Less values to keep in registers, and `int32` doesn't need to be obtained with a property lookup on each call.
When auto-fixing unused `eslint-disable` / `oxlint-disable` directives, only the comment *content* was stripped — leaving behind empty comment tokens (`//` or `/**/`) instead of removing the line entirely.
**Root cause:** The `comment_span` stored in `DisabledRule` was set to `comment.content_span()` (the text inside the delimiters), so `Fix::delete(span)` left the `//` prefix and `*/` suffix in place.
## Changes
- **`disable_directives.rs`** — Added `DisableDirectivesBuilder::compute_comment_fix_span` static helper and a `fix_span` field alongside `comment_span` in both `DisabledRule::All` and `DisabledRule::Single`:
- `comment_span` = `comment.span` (full outer comment span, used for **diagnostic labels** — no line extension)
- `fix_span` = line-aware span: the **entire line** (indentation + newline included) when the comment is the only non-whitespace content on its line; otherwise the full outer comment span (including `//` / `/* */` delimiters)
- `DisableRuleComment` gains a matching `fix_span` field; its `span` field remains the original comment span for diagnostics
- **`context/host.rs`** and **`apps/oxlint/src/lsp/error_with_position.rs`** — use `span` for `.with_label` (diagnostic highlighting) and `fix_span` for `Fix::delete` (the actual edit)
**Before / after:**
```js
// Before fix applied:
// eslint-disable-next-line no-console → //
/* eslint-disable no-console */ → /**/
// After fix applied:
// eslint-disable-next-line no-console → (line removed)
/* eslint-disable no-console */ → (line removed)
```
Multi-rule comments where only *some* rules are unused are unaffected — the existing per-rule comma-aware removal logic (`RuleCommentType::Single`) continues to work correctly.
Continuation of #21132. Get span `start` and `end` from the `Int32Array` view of the buffer, instead of the `Uint32Array`view. This allows V8 to statically see that they are SMIs, and skip type checks.
Utilize the `#[estree(raw_deser_inline)]` attribute introduced in #21134 to mark all custom deserializers which do not depend on `parent` as inline-able.
This reduces deserializer code size, as well as being more performant in some cases, by avoiding write boundary checks.
Add an `#[estree(raw_deser_inline)]` attribute which can be used on custom deserializers. Like automatically-generated deserializers, where the value does not require `parent`, it can be set as an object property inline, rather than assigned after constructing the AST node object.
Use `int32` (`Int32Array`) instead of `uint32`(`Uint32Array`) for getting data from buffer in raw transfer deserializer. The buffer is 2 GiB in size, so all offsets within the buffer can be represented as a `u31` which can be stored in an `i32` without any loss, and with no risk of being interpreted as negative numbers.
Same as in #21129, the advantage of fetching offsets from an `Int32Array` is that V8 statically knows the value can be stored in an SMI, its native integer type, without any range checks.
`>>` is cheaper than `>>>` because `>>` produces a 32-bit _signed_ integer which is V8's native number type (SMI). `>>>` produces a 32-bit _unsigned_ integer, which needs to be boxed and stored on the heap.
Source text in raw transfer is limited to 1 GiB, and therefore source offsets, number of lines, number of tokens, and number of comments after all less than `1 << 30`. Therefore even the sum of 2 of them cannot reach `1 << 31` (the maximum positive integer which can be stored as a positive signed 31-bit int. Therefore it's safe to use `>>` in these binary loops.
## 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)
- Before: Resolved beforehand and treated as a simple list of paths
- = 2-pass
- After: Walked through like a standard path, with processing handled by a custom matcher
- = 1-pass
- To support per-directory config, we need to locate the config files during the directory walk; glob is no exception
This is an internal change and generally has no impact on users.
(In niche cases, the content of the log output will change, but it will result in more consistent.)
## 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)