Skip to content

feat(minifier): inline const value for read-only vars#22593

Merged
graphite-app[bot] merged 1 commit into
mainfrom
feat/minifier-inline-readonly-vars
Jun 16, 2026
Merged

feat(minifier): inline const value for read-only vars#22593
graphite-app[bot] merged 1 commit into
mainfrom
feat/minifier-inline-readonly-vars

Conversation

@Dunqing

@Dunqing Dunqing commented May 19, 2026

Copy link
Copy Markdown
Member

Summary

The minifier skipped constant-inlining for every var, blaming TDZ. The real constraint is narrower — var hoisting — and most vars are still safe to inline. This lifts the blanket skip and inlines a var's constant whenever no read can observe its hoisted undefined. Closes #13051.

Why

A var x = <literal>; binds x to undefined at the top of its scope and assigns the literal only in source order. A read in that window sees undefined, so inlining the literal everywhere would change behavior there — which is why the old code skipped all vars. But that also blocked a common, perfectly safe pattern from optimizing in DCE mode:

var DEBUG = false;
function check() { if (DEBUG) console.log('debug'); }
console.log(check());

vite 8's topLevelVar: true rewrites top-level let/const to var, so users' flag patterns silently stopped tree-shaking — vitejs/vite#22363rolldown/rolldown#9279.

The safety principle

Inline only when the value at the read is provably the assigned literal, never the hoisted undefined. Concretely, no user code may run between scope entry and the assignment that could read x. A var x = <literal>; qualifies when all of:

  • It is in a still-declarative body. It sits directly in the current function/program body, and every preceding statement is declarative — runs no user code (is_declarative_body_statement: function/empty/type-only declarations, module re-exports, and literal-initialised simple vars). The first non-declarative statement ends the body's "declarative prelude"; this is also enforced per-declarator for multi-declarator statements.
  • Its initializer is a ConstantValue.
  • It is not a top-level script var (those alias the global object).
  • At program scope, the module loads no other module (import / export … from / export * from) — a cyclic importer could call into our exports and observe a captured var before assignment, regardless of export status.
  • It has exactly one read, crossing a function boundary — the gap that substitute_single_use_symbol (same call frame) and the small-value inliner don't fill. This also keeps the change byte-positive: dropping var x = L; and replacing one read with L always saves bytes.

The prelude flag is a per-body stack pushed/popped at function boundaries, so the analysis runs independently for nested functions. Reassignment is handled by the existing write_references_count guard, and direct-eval scopes are excluded.

Verification. A battery of hoisting / closure / reassignment programs was run through the minifier and then executed, checking every output reproduces the original's observable behavior. All pass — output is semantically equal to source on each, matching Terser.

Comparison with other minifiers

Behaviour checked against Terser 5.48, esbuild 0.24, and SWC 1.15:

  • esbuild only propagates const primitives — it never inlines a var cross-scope. So const DEBUG = false; … folds to console.log(void 0) but var DEBUG = false; … stays. That is exactly the regression path: topLevelVar: true rewrites constvar, defeating const-only propagation. oxc had the same blanket-var skip; this PR lifts it for the provably-safe subset.
  • Terser / SWC reach the same result (and more — flow-sensitive reassignment, call inlining) via multi-pass reduce_vars/collapse_vars. This PR recovers the common flag pattern with one syntactic prelude check instead of that machinery, staying single-pass.

Results

minsize shows small gzip improvements on lodash / victory / antd / typescript; jquery is byte-identical.

Closes #13051

Dunqing commented May 19, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of the merge queue

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions github-actions Bot added the A-minifier Area - Minifier label May 19, 2026
@codspeed-hq

codspeed-hq Bot commented May 19, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 52 untouched benchmarks
⏩ 19 skipped benchmarks1


Comparing feat/minifier-inline-readonly-vars (729741a) with minifier-stack/incremental-scoping (db7b22e)

Open in CodSpeed

Footnotes

  1. 19 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Dunqing

Dunqing commented May 19, 2026

Copy link
Copy Markdown
Member Author

Verification: oxc (this PR) vs esbuild 0.27.4

Ran the same 7 edge cases through both minifiers — covers the motivating case, the four hoisting hazards the predicate is supposed to block, and the two holes Codex flagged during review.

# Case oxc esbuild Verdict
1 var used = false; function test() { if (used) return 123; return 321; } console.log(test()) Inlinesfunction test(){return 321} var used=!1; … used?123:321 (no inline) oxc folds the dead branch
2 output(); var foo = true; function output() { if (!foo) console.log('foo') } (preceding-call hazard) Preserves foo Preserves foo both correct
3 var y = foo; var foo = 1; console.log(y) (same-scope pre-init read) Preserves Preserves both correct
4 var foo = 1; foo = 2; console.log(foo) (reassigned) Preserves Preserves both correct
5 var [x = inner()] = ''; var flag = true; function inner() { return flag }; console.log(x) (destructuring default — Codex finding 1) Preserves flag Preserves flag both correct ✓
6 import './b.js'; var flag = true; export function check() { return flag } (exported closure under imports — Codex finding 2) Preserves flag Preserves flag both correct ✓
7 function outer() { var flag = false; function inner() { return flag ? 1 : 2 } return inner() } (function-body single-use cross-function) Inlinesfunction inner(){return 2} var n=!1; … n?1:2 (no inline) oxc folds inside the function body

Findings

  1. Every hazard case (2–6) behaves identically — same observably-correct output by different mechanisms (esbuild doesn't inline var at all; oxc's predicate proves safety case-by-case).
  2. Both Codex-flagged hazards are correctly avoided — neither tool falls into the destructuring-default trap or the cyclic-closure trap.
  3. oxc is more aggressive on cases 1 and 7 — these are bytes esbuild leaves on the table, and they're exactly what esbuild's open feature request evanw/esbuild#1981 is asking for.
  4. oxc's behavior is a strict superset of esbuild's: every elision esbuild produces, oxc also produces, plus the two safe-to-fold cases above.

Reproduction

# install / locate esbuild 0.27.4 and run with --minify --bundle=false
# run oxc with `cargo run -p oxc_minifier --example dce -- <file>`

Each row above was generated from a fresh .mjs file containing only that snippet.

@Dunqing Dunqing force-pushed the feat/minifier-inline-readonly-vars branch from 462eaa9 to 06d1393 Compare June 13, 2026 14:48
@Dunqing Dunqing changed the base branch from main to minifier-stack/incremental-scoping June 13, 2026 14:48
@Dunqing Dunqing force-pushed the feat/minifier-inline-readonly-vars branch 2 times, most recently from 0db7634 to c560f12 Compare June 15, 2026 09:04
@Dunqing Dunqing force-pushed the minifier-stack/incremental-scoping branch from d357321 to db7b22e Compare June 15, 2026 09:04
@Dunqing Dunqing added the run-monitor-oxc Add to a PR to dispatch oxc-project/monitor-oxc CI against it label Jun 15, 2026
@oxc-guard

oxc-guard Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

@oxc-guard oxc-guard Bot removed the run-monitor-oxc Add to a PR to dispatch oxc-project/monitor-oxc CI against it label Jun 15, 2026
@Dunqing Dunqing marked this pull request as ready for review June 15, 2026 14:57
@Dunqing Dunqing requested a review from Boshen June 15, 2026 15:05
@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label Jun 15, 2026

Dunqing commented Jun 15, 2026

Copy link
Copy Markdown
Member Author

Merge activity

@graphite-app graphite-app Bot force-pushed the minifier-stack/incremental-scoping branch from db7b22e to 14f0081 Compare June 15, 2026 15:12
@graphite-app graphite-app Bot requested a review from overlookmotel as a code owner June 15, 2026 15:12
@graphite-app graphite-app Bot changed the base branch from minifier-stack/incremental-scoping to graphite-base/22593 June 15, 2026 15:13
@Dunqing Dunqing force-pushed the feat/minifier-inline-readonly-vars branch from 729741a to 06c82aa Compare June 16, 2026 01:56
@Dunqing Dunqing force-pushed the graphite-base/22593 branch from db7b22e to 0cc8c0b Compare June 16, 2026 01:56
@Dunqing Dunqing changed the base branch from graphite-base/22593 to minifier-stack/incremental-scoping June 16, 2026 01:56
@graphite-app graphite-app Bot changed the base branch from minifier-stack/incremental-scoping to graphite-base/22593 June 16, 2026 01:58
@graphite-app graphite-app Bot force-pushed the graphite-base/22593 branch from 0cc8c0b to bcb3894 Compare June 16, 2026 02:03
@graphite-app graphite-app Bot force-pushed the feat/minifier-inline-readonly-vars branch from 06c82aa to d251662 Compare June 16, 2026 02:03
@graphite-app graphite-app Bot changed the base branch from graphite-base/22593 to main June 16, 2026 02:04
@graphite-app graphite-app Bot force-pushed the feat/minifier-inline-readonly-vars branch from d251662 to 8fcaf80 Compare June 16, 2026 02:04
## Summary

The minifier skipped constant-inlining for **every** `var`, blaming TDZ. The real constraint is narrower — `var` *hoisting* — and most `var`s are still safe to inline. This lifts the blanket skip and inlines a `var`'s constant whenever no read can observe its hoisted `undefined`. Closes #13051.

## Why

A `var x = <literal>;` binds `x` to `undefined` at the top of its scope and assigns the literal only in source order. A read in that window sees `undefined`, so inlining the literal everywhere would change behavior there — which is why the old code skipped all `var`s. But that also blocked a common, perfectly safe pattern from optimizing in DCE mode:

```js
var DEBUG = false;
function check() { if (DEBUG) console.log('debug'); }
console.log(check());
```

vite 8's `topLevelVar: true` rewrites top-level `let`/`const` to `var`, so users' flag patterns silently stopped tree-shaking — vitejs/vite#22363rolldown/rolldown#9279.

## The safety principle

Inline only when the value at the read is provably the assigned literal, never the hoisted `undefined`. Concretely, no user code may run between scope entry and the assignment that could read `x`. A `var x = <literal>;` qualifies when all of:

- **It is in a still-declarative body.** It sits directly in the current function/program body, and every preceding statement is *declarative* — runs no user code (`is_declarative_body_statement`: function/empty/type-only declarations, module re-exports, and literal-initialised simple `var`s). The first non-declarative statement ends the body's "declarative prelude"; this is also enforced per-declarator for multi-declarator statements.
- **Its initializer is a `ConstantValue`.**
- **It is not a top-level script var** (those alias the global object).
- **At program scope, the module loads no other module** (`import` / `export … from` / `export * from`) — a cyclic importer could call into our exports and observe a captured var before assignment, regardless of export status.
- **It has exactly one read, crossing a function boundary** — the gap that `substitute_single_use_symbol` (same call frame) and the small-value inliner don't fill. This also keeps the change byte-positive: dropping `var x = L;` and replacing one read with `L` always saves bytes.

The prelude flag is a per-body stack pushed/popped at function boundaries, so the analysis runs independently for nested functions. Reassignment is handled by the existing `write_references_count` guard, and direct-eval scopes are excluded.

**Verification.** A battery of hoisting / closure / reassignment programs was run through the minifier and then *executed*, checking every output reproduces the original's observable behavior. All pass — output is semantically equal to source on each, matching Terser.

## Comparison with other minifiers

Behaviour checked against Terser 5.48, esbuild 0.24, and SWC 1.15:

- **esbuild** only propagates `const` primitives — it never inlines a `var` cross-scope. So `const DEBUG = false; …` folds to `console.log(void 0)` but `var DEBUG = false; …` stays. That is exactly the regression path: `topLevelVar: true` rewrites `const`→`var`, defeating const-only propagation. oxc had the same blanket-`var` skip; this PR lifts it for the provably-safe subset.
- **Terser / SWC** reach the same result (and more — flow-sensitive reassignment, call inlining) via multi-pass `reduce_vars`/`collapse_vars`. This PR recovers the common flag pattern with one syntactic prelude check instead of that machinery, staying single-pass.

## Results

`minsize` shows small gzip improvements on lodash / victory / antd / typescript; jquery is byte-identical.

Closes #13051
@graphite-app graphite-app Bot force-pushed the feat/minifier-inline-readonly-vars branch from 8fcaf80 to 09762d9 Compare June 16, 2026 02:09
@graphite-app graphite-app Bot merged commit 09762d9 into main Jun 16, 2026
29 checks passed
@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label Jun 16, 2026
@graphite-app graphite-app Bot deleted the feat/minifier-inline-readonly-vars branch June 16, 2026 02:13
Boshen added a commit that referenced this pull request Jun 18, 2026
### 💥 BREAKING CHANGES

- 7a76cd3 estree: [**BREAKING**] Make whether to include TS fields a
runtime option (#23574) (overlookmotel)
- e7b6b68 estree: [**BREAKING**] `ESTree` config use methods not consts
(#23573) (overlookmotel)

### 🚀 Features

- 556cc6d data_structures: Add `CodeBuffer::as_str` method (#23571)
(overlookmotel)
- 38c4b06 parser: Add friendly error for adjacent JSX elements (#23378)
(sapphi-red)
- 53509a8 minifier: Treeshake pure typed arrays and Set/Map array
literals (#23469) (Dunqing)
- 09762d9 minifier: Inline const value for read-only vars (#22593)
(Dunqing)

### 🐛 Bug Fixes

- 20375f9 react_compiler: Keep imports referenced only by a computed key
(#23586) (Boshen)
- 31bfd9b minifier: Keep Object introspection calls on a possible Proxy
(#23483) (Dunqing)
- 837a395 parser: Treat a line comment after ':' as leading, not
trailing (#23515) (Dunqing)
- e409fe0 minifier: Keep `new Map`/`WeakSet`/`WeakMap` with a string
argument (#23470) (Dunqing)
- ae02b4e ci/parser: Use `minimal` for vitest reporter (#23457)
(camc314)

### ⚡ Performance

- cf24329 mangler: Compile slot sort once instead of per CAPACITY
(#23577) (Boshen)
- 4058a6a parser: Reduce code bloat from verify_modifiers
monomorphization (#23576) (Boshen)
- 053b0c1 estree: Remove pointless `mem::take` (#23572) (overlookmotel)
- dfb52b6 transformer: Pre-size statement vecs in TS enum & namespace
lowering (#23516) (Yunfei He)
- 970e09a minifier: Compute template-literal inline checks in a single
pass (#23467) (Yunfei He)
- 3170c0e semantic,mangler,minifier: Fix `Semantic::stats` node count
and reuse stats in mangler builds (#23352) (Boshen)
- d1fa6e0 minifier: Evaluate ternary branches once in
minimize_conditional_expression (#23479) (Yunfei He)
- 3fa8051 transformer: Pre-size JSX props vec to attribute count
(#23466) (Yunfei He)
- 488b382 react_compiler: Borrow binding names in prefilter instead of
allocating (#23471) (Yunfei He)
- bcb3894 minifier: Incremental scoping refresh, delete
LiveUsageCollector (#23197) (Dunqing)

### 📚 Documentation

- f68641e data_structures: Improve docs on safety contract (#23575)
(overlookmotel)

Co-authored-by: Boshen <1430279+Boshen@users.noreply.github.com>
camc314 pushed a commit that referenced this pull request Jul 3, 2026
## Summary

The minifier skipped constant-inlining for **every** `var`, blaming TDZ. The real constraint is narrower — `var` *hoisting* — and most `var`s are still safe to inline. This lifts the blanket skip and inlines a `var`'s constant whenever no read can observe its hoisted `undefined`. Closes #13051.

## Why

A `var x = <literal>;` binds `x` to `undefined` at the top of its scope and assigns the literal only in source order. A read in that window sees `undefined`, so inlining the literal everywhere would change behavior there — which is why the old code skipped all `var`s. But that also blocked a common, perfectly safe pattern from optimizing in DCE mode:

```js
var DEBUG = false;
function check() { if (DEBUG) console.log('debug'); }
console.log(check());
```

vite 8's `topLevelVar: true` rewrites top-level `let`/`const` to `var`, so users' flag patterns silently stopped tree-shaking — vitejs/vite#22363rolldown/rolldown#9279.

## The safety principle

Inline only when the value at the read is provably the assigned literal, never the hoisted `undefined`. Concretely, no user code may run between scope entry and the assignment that could read `x`. A `var x = <literal>;` qualifies when all of:

- **It is in a still-declarative body.** It sits directly in the current function/program body, and every preceding statement is *declarative* — runs no user code (`is_declarative_body_statement`: function/empty/type-only declarations, module re-exports, and literal-initialised simple `var`s). The first non-declarative statement ends the body's "declarative prelude"; this is also enforced per-declarator for multi-declarator statements.
- **Its initializer is a `ConstantValue`.**
- **It is not a top-level script var** (those alias the global object).
- **At program scope, the module loads no other module** (`import` / `export … from` / `export * from`) — a cyclic importer could call into our exports and observe a captured var before assignment, regardless of export status.
- **It has exactly one read, crossing a function boundary** — the gap that `substitute_single_use_symbol` (same call frame) and the small-value inliner don't fill. This also keeps the change byte-positive: dropping `var x = L;` and replacing one read with `L` always saves bytes.

The prelude flag is a per-body stack pushed/popped at function boundaries, so the analysis runs independently for nested functions. Reassignment is handled by the existing `write_references_count` guard, and direct-eval scopes are excluded.

**Verification.** A battery of hoisting / closure / reassignment programs was run through the minifier and then *executed*, checking every output reproduces the original's observable behavior. All pass — output is semantically equal to source on each, matching Terser.

## Comparison with other minifiers

Behaviour checked against Terser 5.48, esbuild 0.24, and SWC 1.15:

- **esbuild** only propagates `const` primitives — it never inlines a `var` cross-scope. So `const DEBUG = false; …` folds to `console.log(void 0)` but `var DEBUG = false; …` stays. That is exactly the regression path: `topLevelVar: true` rewrites `const`→`var`, defeating const-only propagation. oxc had the same blanket-`var` skip; this PR lifts it for the provably-safe subset.
- **Terser / SWC** reach the same result (and more — flow-sensitive reassignment, call inlining) via multi-pass `reduce_vars`/`collapse_vars`. This PR recovers the common flag pattern with one syntactic prelude check instead of that machinery, staying single-pass.

## Results

`minsize` shows small gzip improvements on lodash / victory / antd / typescript; jquery is byte-identical.

Closes #13051
camc314 pushed a commit that referenced this pull request Jul 3, 2026
### 💥 BREAKING CHANGES

- 7a76cd3 estree: [**BREAKING**] Make whether to include TS fields a
runtime option (#23574) (overlookmotel)
- e7b6b68 estree: [**BREAKING**] `ESTree` config use methods not consts
(#23573) (overlookmotel)

### 🚀 Features

- 556cc6d data_structures: Add `CodeBuffer::as_str` method (#23571)
(overlookmotel)
- 38c4b06 parser: Add friendly error for adjacent JSX elements (#23378)
(sapphi-red)
- 53509a8 minifier: Treeshake pure typed arrays and Set/Map array
literals (#23469) (Dunqing)
- 09762d9 minifier: Inline const value for read-only vars (#22593)
(Dunqing)

### 🐛 Bug Fixes

- 20375f9 react_compiler: Keep imports referenced only by a computed key
(#23586) (Boshen)
- 31bfd9b minifier: Keep Object introspection calls on a possible Proxy
(#23483) (Dunqing)
- 837a395 parser: Treat a line comment after ':' as leading, not
trailing (#23515) (Dunqing)
- e409fe0 minifier: Keep `new Map`/`WeakSet`/`WeakMap` with a string
argument (#23470) (Dunqing)
- ae02b4e ci/parser: Use `minimal` for vitest reporter (#23457)
(camc314)

### ⚡ Performance

- cf24329 mangler: Compile slot sort once instead of per CAPACITY
(#23577) (Boshen)
- 4058a6a parser: Reduce code bloat from verify_modifiers
monomorphization (#23576) (Boshen)
- 053b0c1 estree: Remove pointless `mem::take` (#23572) (overlookmotel)
- dfb52b6 transformer: Pre-size statement vecs in TS enum & namespace
lowering (#23516) (Yunfei He)
- 970e09a minifier: Compute template-literal inline checks in a single
pass (#23467) (Yunfei He)
- 3170c0e semantic,mangler,minifier: Fix `Semantic::stats` node count
and reuse stats in mangler builds (#23352) (Boshen)
- d1fa6e0 minifier: Evaluate ternary branches once in
minimize_conditional_expression (#23479) (Yunfei He)
- 3fa8051 transformer: Pre-size JSX props vec to attribute count
(#23466) (Yunfei He)
- 488b382 react_compiler: Borrow binding names in prefilter instead of
allocating (#23471) (Yunfei He)
- bcb3894 minifier: Incremental scoping refresh, delete
LiveUsageCollector (#23197) (Dunqing)

### 📚 Documentation

- f68641e data_structures: Improve docs on safety contract (#23575)
(overlookmotel)

Co-authored-by: Boshen <1430279+Boshen@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

minifier: inline const for readonly vars.

2 participants