Oxc can shorten variable names and private class names, but it cannot shorten property names such as `obj._field`. This change adds optional property mangling. It replaces #23772 and gives bundlers a way to use the same property names across several files.
### Feature
- `include` selects names to mangle. `exclude` and `reserved` keep names unchanged.
- The cache can give a property a fixed output name or keep it unchanged.
- The mangler supports member access, object and class keys, destructuring, optional chains, the left side of `in`, and JSX property names.
- `mangle_quoted` also handles strings and template literals used directly as property keys. `/* @__KEY__ */` and `/* #__KEY__ */` can mark one other string or template literal as a property key.
- Short names are assigned in a stable order. More common properties get shorter names. Existing and reserved names are avoided.
- Bundlers can collect names from several programs, create one shared map, and apply it to every program.
- Numeric keys, `__proto__`, `constructor`, `prototype`, TypeScript-only keys, and private names are never changed by this pass.
### Design
Property names are part of JavaScript syntax, not local scope. For this reason, the code lives in `oxc_minifier`. It only reuses the short-name generator from `oxc_mangler`.
For one program, the order is:
```text
collect names → assign names → rewrite once → compress → mangle variables and private names
```
Property names are rewritten before compression because compression can remove information about quotes and comments.
For several programs, a bundler collects names from each program, merges the results, assigns one shared map, and rewrites each program once. Collection can run in parallel. All programs must use the same options.
The shared map keeps property names consistent inside one build. The cache has a different job: it keeps fixed names and reservations across separate minify calls.
### Example
With `include: "^_"` and `mangle_quoted: true`:
```js
obj[`_field`] = 1;
obj._field = 2;
```
becomes:
```js
obj[`e`] = 1;
obj.e = 2;
```
### Limitation
- Property mangling is unsafe when other code still uses the old property name. Public APIs and names shared with external code, host APIs, imported namespace objects, or separate builds must be excluded or reserved.
- Quote handling is per use. Without `mangle_quoted`, `obj._field` may change while `obj["_field"]` does not.
- The mangler cannot find names built at runtime or stored in arbitrary strings. Code that uses `eval`, `Function`, or `with` must reserve any affected names.
- Compression may create a matching property after this pass has finished. For example, `'f' + 'oo_'` may later become `foo_` and will not be mangled.
- TypeScript enum reverse mappings are not supported because the transform creates quoted strings.
- Multi-program support is a caller API. The caller must collect every program that shares mangled properties before assigning names.
The public `oxc-minify` API is stacked in #24741. The downstream multi-chunk integration is shown in rolldown/rolldown#10374.
Supersedes #23772.
AI assistance: OpenAI Codex and Claude were used for research, implementation, review, and testing. The contributor is responsible for reviewing and understanding the changes before submission.
- dispatcher
- string_embedder
- tailwind_sorter
Group these 3 together as `SessionServices`, make them available to `FormatSession`, and pass them around.
Adds three heap-side metrics to the allocation snapshots, using the metric machinery from #24587:
- `sys deallocs` (exact count) — system deallocations per stage; read against `sys allocs` it shows whether a stage churns or retains heap.
- `sys alloc bytes` — cumulative bytes requested from the system allocator per stage (allocation sizes plus reallocation growth).
- `sys peak growth` — high-water mark of live heap during a stage relative to the live bytes at stage start, via live-bytes accounting in the `GlobalAlloc` wrapper. Captures transient heap a stage needs even when it frees it before finishing.
Arena chunk operations are excluded from all `sys` metrics at the source: `oxc_allocator` marks each chunk allocation/deallocation immediately before calling the system allocator (task-only `track_allocations` feature, compiled out everywhere else), and the task's tracking allocator consumes the marker and skips the call. This replaces the previous global chunk counter + per-stage subtraction: a subtraction can correct counts but not a high-water mark, and chunk sizes are quantized and platform-dependent (whether an arena needs one more chunk depends on byte totals that vary across architectures with type layout — the #22621 mechanism). The first CI run demonstrated this concretely: kitchen-sink's minifier peak differed deterministically by 720 kB between aarch64 and x86_64 because the mangler's temporary arena crossed a chunk-growth boundary only on x86_64; and antd's formatter "peak" was 762 MB of main-arena chunk-doubling history rather than formatter behavior (now 13.8 MB of actual heap use).
Cross-platform tolerances: the cumulative byte metrics still drift with the number of heap hashbrown tables allocated (their control-group width is architecture-dependent), so they use a relative tolerance (1%, 4 kB floor) via the committed-value comparison from #24565; `arena size` keeps its tight 1024-byte tolerance.
Sample values for checker.ts (2.9 MB source): parser needs only 2.4 kB of transient heap (fully arena-based), semantic peaks at 3.9 MB, the minifier is the pipeline's largest heap consumer at 10.2 MB, and the formatter requests 5.9 MB total while peaking at 3 MB (heavy alloc/free churn).
🤖 Implemented with [Claude Code](https://claude.com/claude-code).
Follow-up to #24565. Restructures the task so that adding a new heap or arena metric is a small, local change instead of edits scattered across measurement, diffing, rendering, and committed-value lookup:
- `HeapTracker` groups the global-allocator atomics into one static `HEAP` with `read()`/`reset()`; its doc comment describes how to add a heap counter.
- `Counters { heap: HeapCounters, arena: ArenaCounters }` replaces the flat `AllocatorStats`, with `record()` / `diff_since()` (the arena-chunk exclusion logic from #22621 is unchanged, just relocated).
- `StageStats::metrics()` is now the single declaration of what a snapshot contains: an ordered list of `Metric`s, each a label plus a `MetricKind`.
- `MetricKind` encodes the per-metric snapshot policy: `Count` (platform-exact), `ExactBytes` (exact, human-readable comment), `ApproxBytes` (tolerates cross-architecture layout noise by keeping the committed value within `APPROX_BYTES_TOLERANCE`, renamed from `ARENA_SIZE_TOLERANCE` since it is no longer specific to arena size).
- `write_snapshot` / `render_file_stats` are fully metric-driven; nothing downstream needs touching when a metric is added.
Adding e.g. a `sys deallocs` count is now: one `AtomicCounter` in `HeapTracker`, one field in `HeapCounters`, one `Metric::count(...)` entry. A new byte gauge (heap peak, arena capacity) is one gauge read plus one `Metric::approx_bytes(...)` entry.
Behavior is unchanged: the committed snapshots are byte-identical when regenerated with the refactored code.
🤖 Implemented with [Claude Code](https://claude.com/claude-code).
The `Allocations` CI job fails whenever the snapshots are regenerated on a different CPU architecture than CI (e.g. https://github.com/oxc-project/oxc/actions/runs/29420345285/job/87368903834): every `arena size` value differs by 32–128 bytes between aarch64 and x86_64. The cause is target-dependent type layout — hashbrown's table control groups are 16 bytes wide on x86_64 (SSE2) but 8 bytes on aarch64 (NEON) — so each live `HashMap` in the arena shifts `used_bytes()` slightly. This is the same root cause that #22621 handled for chunk counts; the `arena size` gauge added in #24553 re-exposed it. Allocation counts are unaffected and stay exact.
Exact byte equality across architectures isn't achievable (the arena genuinely occupies a different number of bytes), so instead the snapshot writer tolerates layout noise:
- Snapshots are now YAML (`allocs_*.yaml`) with the same layout as before: one value per line, file names at column 0 for git hunk headers, exact numbers as the diffed values, and human-readable sizes moved to trailing `#` comments.
- When regenerating, `cargo allocs` parses the committed snapshot (with `saphyr`, already a workspace dependency used by `oxc_coverage`) and keeps the committed `arena size` when the measured value is within `ARENA_SIZE_TOLERANCE` (1024 bytes, ~8× the observed worst-case cross-arch drift). Differences beyond the tolerance — real regressions such as growing an AST node type change sizes by orders of magnitude more — rewrite the value and fail the `git diff --exit-code` check as before.
- The trailing comments are rendered from the recorded value, so a snapshot regenerated on any platform stays byte-identical while within tolerance. No CI workflow changes are needed, and drift can't accumulate silently: each run compares against the committed value, so creeping sub-tolerance changes eventually cross the threshold and surface.
The committed values here are aarch64-measured; x86_64 CI measures 32–128 bytes higher and keeps them. Open PRs that touch the old `.snap` files will need a rebase and a `cargo allocs` rerun.
🤖 Implemented with [Claude Code](https://claude.com/claude-code).
First of three PRs splitting the byte-metrics work (followed by chunk-alloc-bytes and heap-alloc-bytes).
Adds an `arena size` value to each per-file block: the measured `Allocator`'s used bytes (`Allocator::used_bytes()`) at the end of the stage. It is a point-in-time gauge read after the operation, not a diffed counter — the arena is not reset between stages, so e.g. the transformer's value includes the AST the parser built.
```
checker.ts
file size: 2.92 MB
sys allocs: 1818
sys reallocs: 10
arena allocs: 264366
arena reallocs: 22860
arena size: 12985208 (12.99 MB)
```
Structurally, this introduces the counter/gauge split (`StageStats` wraps the diffed `AllocatorStats` counters plus point-in-time gauges), and byte values print as the exact number plus a human-readable size.
**Platform note**: byte totals vary between platforms (type layouts differ, e.g. hashbrown tables are wider on x86_64 than aarch64) while counts are identical everywhere, so snapshots record byte values from CI's platform (Linux x64). Regenerating on another platform diffs byte-value lines only; take those lines from the CI job's output.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Replaces the wide table in `tasks/track_memory_allocations/allocs_*.snap` with one vertical block per file, one `label: value` line per metric:
```
checker.ts
file size: 2.92 MB
sys allocs: 1818
sys reallocs: 10
arena allocs: 264366
arena reallocs: 22860
```
Motivation:
- A changed counter now produces a one-line diff for exactly that metric, instead of a rewritten table row where you have to spot which of six columns moved. File names stay at column 0 so they show up in git hunk headers.
- No column alignment means adding a metric never reformats existing lines. New values are a single entry in the `(label, value)` array in `format_stats`, so the format extends without churn — more values coming later.
Snapshot values are byte-identical to before; only the layout changed. The header/width plumbing in `lib.rs` is gone (net −30 lines).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Rust 1.95 stabilized [`Atomic*::update`](https://doc.rust-lang.org/std/sync/atomic/struct.AtomicUsize.html#method.update) (MSRV bumped in #24359), the infallible sibling of `fetch_update` for closures that always produce a new value.
Both `fetch_update` call sites (`oxc_allocator/src/tracking.rs` and `tasks/track_memory_allocations`) used an always-`Some` closure, so they gain nothing from the fallible API — switching to `update` drops the ignored `Result` (`let _ =`) and the "cannot be `Err`" comments. Same CAS-loop semantics; `saturating_add` behavior unchanged.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Issue
The `Allocations` CI job can fail on PRs with a 1-allocation `Sys allocs` difference that local `cargo allocs` cannot reproduce. #22621 fixed one instance of this in the transformer stage. #24112 and #24017 hit the same failure again in the minifier stage: `kitchen-sink.tsx` measures 2309 Sys allocs on macOS/arm64 but 2310 on Linux/x64, so whichever platform regenerates the snapshot last breaks CI for the other.
## Why
The tracker counts every call to the system allocator. That includes the chunks bump arenas request. Whether an arena needs one more chunk depends on the total number of bytes in the arena, and byte totals are not the same on every target. For example, hashbrown tables use 16-byte control groups on x86_64 but 8-byte groups on aarch64. When an arena's content size sits close to a chunk boundary, one target crosses it and the other does not.
In the observed failure, the minifier's mangler-phase semantic build creates a fresh `Scoping`. Its arena holds symbol names, resolved-reference vectors, and the per-scope `bindings` hash tables, which are slightly larger on x86_64. The traced Linux-only allocation happened under `Scoping::add_resolved_reference -> ArenaVec::push -> Arena::new_chunk`, when the push overflowed the current chunk and requested a new 720880-byte chunk. On aarch64 the same content fit in the existing chunk, so the count stayed one lower.
Every other allocation class in the measurement grows on element counts, which are identical across platforms. Logging every allocation size during the minifier stage on macOS/arm64, Linux/arm64, and Linux/x64 showed the three sequences match one-to-one except for arena chunk requests.
Any PR that shifts arena content near a chunk boundary can trip this again in any stage, so fixing one stage at a time does not close the class.
## Fix
Count chunk allocations in `oxc_allocator` under the tracker-only `track_allocations` feature, and subtract them from the reported Sys allocs in the tracker. The counter is global rather than per-arena because short-lived arenas (like the one backing `Scoping`) are created and dropped inside the measured operation.
Arena usage is still measured by the `Arena allocs` / `Arena reallocs` columns; only the platform-sensitive chunk-request count is excluded from `Sys allocs`.
Parser and transformer rows are unchanged (the main arena is pre-warmed, and #22621 already removed transformer scoping growth). Semantic, minifier, and formatter rows drop by their per-stage chunk counts.
## Validation
`cargo allocs` produces byte-identical snapshots on macOS/arm64, Linux/arm64 (Docker), and Linux/x64 (Docker), and is idempotent across reruns. `cargo test -p oxc_allocator` passes; clippy is clean with `track_allocations` both on and off, and the counter compiles out entirely without the feature.
AI usage: AI assistance was used to investigate, implement, and validate this change.
## Summary
Introduces `oxc_diagnostics::Diagnostics` — a `Vec<OxcDiagnostic>` newtype that knows the difference between errors and warnings — and makes it the standard diagnostics return value across oxc.
- **New type**: `Diagnostics` with `has_errors()`, `has_warnings()`, `errors()`, `warnings()`, plus `Vec`-like ergonomics (Deref, `IntoIterator`, `Extend`, `From`/`Into<Vec>`) so most call sites are unchanged.
- **Return types**: `ParserReturn`, `SemanticBuilderReturn`, `IsolatedDeclarationsReturn`, the React Compiler `TransformResult`/`LintResult`, and `TransformerReturn` now expose `diagnostics: Diagnostics` instead of `errors: Vec<OxcDiagnostic>` (the React Compiler's separate `errors`/`warnings` are merged into one severity-tagged list).
- **Behaviour**: a React Compiler **error** now stops the transform (no output), while **warnings** flow through and the transform continues. `compile()` aborts codegen only when `diagnostics.has_errors()` — warnings alone no longer abort (previously any diagnostic did).
## Breaking change
Consumers reading `*.errors` on these return values must read `*.diagnostics`. React Compiler consumers that read `.errors`/`.warnings` separately should use `diagnostics.has_errors()` / `diagnostics.errors()` / `diagnostics.warnings()`.
Parser/semantic/isolated-declarations fatality is otherwise unchanged. Builds on the React Compiler transform feature (#23201).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Align `oxc_formatter` with `oxc_formatter_json`, both now expose `format(...) -> Result<Formatted<'a, _>, OxcDiagnostic>`.
- Add text-in `format` and `format_fragment`, also `format_program`, `parse_for_format` and the `FragmentContext` enum
- Parse options, JSX enabling, js-in-xxx context aware handlings are applied internally
- Make the `Formatter` struct and AST/IR primitives crate-internal
- Fold `get_parse_options` / `enable_jsx_source_type` and the `service` module into `parse_for_format`
Split `oxc_formatter` into:
- `oxc_formatter(_js)`: JS/TS specific formatter, comment handling, sortImports, jsdoc, etc
- `oxc_formatter_core`: Language agnostic IR, printer, etc
The naming convention is different from Biome or Ruff (they do not have `_core`, and `_formatter` is equivalent to the core), but this is because in Oxc, items without `_js` are the default.
As a basic task, I have added `Context` as a generic to all relevant parts to ensure support for multiple languages.
There might still be other components that could be refactored into the core, but I would like to finish them as I implement support for other languages.
Also moved some shared utilities to the core in advance.
## Issue
The `Allocations` CI job can fail on PRs that do not touch the transformer because `cargo allocs` can rewrite `allocs_transformer.snap` differently across platforms. In the observed failure, Linux x64 reported one extra transformer system allocation for `App.tsx`, while macOS/arm64 still reported the old count.
## Why
The allocation tracker was passing transformer a `Scoping` built differently from the production compiler path. Production builds semantic data with `SemanticBuilder::with_excess_capacity(2.0)` before running transforms, because transforms may add scopes, symbols, and references. The allocation tracker did not reserve that extra semantic capacity.
That made the transformer snapshot accidentally include growth of semantic scoping storage. In this case, JSX automatic runtime import generation creates a new root-scope binding. The traced Linux-only allocation happened under `JsxImpl::get_create_element -> AutomaticModuleBindings::import_jsx -> TraverseScoping::add_binding -> Scoping::create_symbol`, when pushing the generated symbol forced the semantic `Scoping` arena to allocate another chunk.
Linux got a different allocation count because this is a capacity-boundary effect, not different transformer behavior. The `Scoping` arena layout depends on target layout and alignment. On Linux x64, the generated binding crossed the arena chunk boundary and allocated a new 16-byte-aligned chunk, so the measured transformer count became 27. On macOS/arm64, the same push still fit in the existing semantic arena capacity, so the measured count stayed 26.
So the root cause is that transformer allocation tracking was measuring platform-sensitive semantic storage growth. Any unrelated PR could expose the same stale snapshot whenever CI regenerated allocations on a platform that lands on the other side of that capacity boundary.
## Fix
Keep `allocs_semantic.snap` measuring regular semantic analysis, but build a separate production-like semantic `Scoping` for transformer measurement using `with_excess_capacity(2.0)`. This matches the compiler and transformer benchmarks, and removes the semantic arena capacity cliff from the transformer allocation snapshot.
The transformer allocation snapshot is regenerated after the measurement fix.
## Validation
Verified `cargo allocs` leaves the branch clean on macOS/arm64 and in Linux amd64 Docker.
AI usage: I used AI assistance to investigate, implement, and validate this change.
## Summary
\`format_table_header\` advertised seven columns but \`format_table_row\` only emitted six, so the \`Arena bytes\` column was always blank across every \`allocs_*.snap\` file.
Drop the column from the header rather than add tracking for it. The file's leading comment establishes the existing stance:
> The original version of this tool did track the number of bytes, but there was some variance between platforms that made it less reliable as a measurement. In general, the number of allocations is closely correlated with the size of allocations, so just tracking the number of allocations is sufficient for our purposes.
The empty column header was just a leftover from that earlier byte-tracking version.
## Diff
Each snapshot's header line shortens by one column; data rows unchanged:
\`\`\`
-File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs | Arena bytes
+File | File size || Sys allocs | Sys reallocs || Arena allocs | Arena reallocs
\`\`\`
All five snapshots regenerated (\`just allocs\`), each is pure header-line change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Pre-requisite for #22252.
## Why
#22252 adds a `debug_assert!` in the transformer that requires `Scoping` to be built with `SemanticBuilder::with_enum_eval(true)` whenever a `TSEnumDeclaration` reaches the transform pass. The internal `Compiler` (`crates/oxc/src/compiler.rs`) and the playground already enable it; the CI run on #22252 surfaced a few internal callers that don't.
Land that fix on its own first, so the assert PR doesn't trip CI on these tasks.
## Sites updated
Every `SemanticBuilder` site that feeds `Transformer::build_with_scoping`:
- `tasks/track_memory_allocations/src/lib.rs` — both call sites (`cargo allocs`).
- `tasks/benchmark/benches/transformer.rs`
- `tasks/benchmark/benches/pipeline.rs`
- `tasks/benchmark/benches/minifier.rs`
- `tasks/benchmark/benches/codegen.rs`
Other semantic builders in these files that feed only the minifier / codegen / compressor are left alone — they don't go through enum lowering.
## Test plan
- [x] `cargo check -p oxc_track_memory_allocations` — clean
- [x] `cargo check --benches -p oxc_benchmark` — clean
## Summary
- Transformer allocations were previously invisible — they ran between semantic and minifier measurement, attributed to neither
- Adds a dedicated `allocs_transformer.snap` snapshot to `tasks/track_memory_allocations`
- Reveals that JSX/TSX transformation is allocation-heavy (e.g. cal.com.tsx: 38,933 arena allocs) while pure JS files have near-zero transformer work
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Maybe there is a reason we didn't have this before 🤔 ?
I think it's a useful metric to track though, so this PR adds support for tracking allocations in semantic
#13408 introduced allocation tracking for minifier. However, it's
causing panics when minifying TS files.
I think problem is that minifier cannot handle TS.
Probably a better solution would be to put TS code through transformer
before minifying, to remove TS syntax. But doing this easier fix for
now, just to get CI green again, and allow other PRs to be merged.
cc @Boshen I saw lots of changes going on in the minifier. Thought this might be helpful for tracking any allocation changes in the minifier that might regress/improve performance.
Pure refactor, just allowing for the ability to start capturing more stats for each part of the oxc ecosystem. Ideally, we measure separate stats for parser, semantic, linter, minifier, transformer, formatter, etc.
Allocation tracker did not deal with possibility that counters could reach `usize::MAX`. In this case, addition would wrap around back to 0.
This problem was pointed out by @aapoalas in https://github.com/oxc-project/oxc/pull/13043#pullrequestreview-3119426135.
Introduce an `AtomicCounter` type which uses saturating addition, to prevent this happening.
Reaching such a high allocation count is highly unlikely on 64-bit systems, but might just about be possible on 32-bit.
Maybe this is overkill, but since `cargo allocs` is just an internal tool, performance is not the highest priority. So I figure on balance probably better to handle this edge case. It doesn't seem to hurt perf much.
```
# Before
% hyperfine -i --warmup 3 'cargo allocs'
Benchmark 1: cargo allocs
Time (mean ± σ): 299.9 ms ± 2.4 ms [User: 132.8 ms, System: 31.0 ms]
Range (min … max): 297.6 ms … 303.9 ms 10 runs
# After
% hyperfine -i --warmup 3 'cargo allocs'
Benchmark 1: cargo allocs
Time (mean ± σ): 301.3 ms ± 3.4 ms [User: 133.3 ms, System: 31.5 ms]
Range (min … max): 297.8 ms … 310.0 ms 10 runs
```
Note: The same problem in arena allocation tracker is addressed in #13043.
Follow-on after #12555. Pure refactor.
Move code related to allocation tracking into its own module, and introduce an `AllocationStats` struct.
This reduces noise in `allocator.rs` and `alloc.rs`, with less repetitions of `#[cfg(all(feature = "track_allocations", not(feature = "disable_track_allocations")))]` etc.
Also bulk out the doc comments explaining the unsoundness, and how we must take care to avoid allocation tracking code being compiled in production code.
- related: https://github.com/oxc-project/backlog/issues/5
This PR adds a reliable method of tracking the number of allocations made while running the parser. This includes both arena allocations (via a new feature), as well as system allocations (by creating a new global allocator). Reductions in these numbers should correlate to real-world performance improvements that aren't quantified in CodSpeed currently. This will also help prevent regressions where we accidentally allocate lots more memory than we expect.
Originally I had included total system bytes allocated, but it was tricky to get this number to match exactly between platforms. I opted not try and make this perfect but instead use the total number of allocations which is a good proxy for bytes anyway.