Skip to content

fix(minifier): keep reads of never-assigned uninitialized bindings#24452

Merged
graphite-app[bot] merged 1 commit into
mainfrom
minifier-keep-uninitialized-binding-reads
Jul 13, 2026
Merged

fix(minifier): keep reads of never-assigned uninitialized bindings#24452
graphite-app[bot] merged 1 commit into
mainfrom
minifier-keep-uninitialized-binding-reads

Conversation

@Dunqing

@Dunqing Dunqing commented Jul 13, 2026

Copy link
Copy Markdown
Member

inline_identifier_reference counts undefined among the constants that are always worth substituting into a read. But it prints as void 0 — six characters — while a kept read mangles to one or two, and for a binding declared without an initializer (let x;) there is no initializer whose elimination pays the difference, so the substitution can only grow mangled output. rolldown hits this on bundled chunks (minify: true always mangles): the chunk in rolldown/rolldown#10174 minified to 58 bytes where keeping the binding gives 55.

SymbolValue now records that a tracked undefined is the implicit value of an initializer-less declaration, and the textual inline skips such reads. Only the byte substitution is affected: constant-driven folds (if (x) dead branches, x === void 0, return x elision) resolve the value through SymbolValue during evaluation and behave as before. An explicit const x = undefined initializer still inlines — there the substitution deletes the = void 0 text along with the declaration, so it pays for itself.

The known tradeoff is compress-without-mangle: with mangle: false the kept binding prints its full source name, where the old behavior emitted the shorter void 0. The small-value thresholds in this heuristic already assume mangled read sites (numbers up to 3 digits, strings up to 3 chars), so the gate follows the same assumption; a CompressOptions hint for compress-only pipelines can be added later if anyone hits it. For comparison, esbuild never models implicit undefined (its const-value inlining requires a const with a literal initializer) and wins this shape by keeping the binding; terser substitutes like the old behavior and produces the same 58 bytes.

Example

The rolldown chunk from the issue (minify: false output, then minified with mangling):

let undefinedVar;
let value;
function reset() {
	value = undefinedVar;
}
export { reset, value };

Before:

let e;function t(){e=void 0}export{t as reset,e as value};

After:

let e,t;function n(){t=e}export{n as reset,t as value};

Verified with the minifier unit suite (two expectation updates where the kept binding is now smaller or equal), just minsize and allocation snapshots unchanged, --twice idempotency on the repro, and old-vs-new byte-identical output on real rolldown chunks of vue, svelte, and rxjs.

Fixes rolldown/rolldown#10174.

Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.

Dunqing commented Jul 13, 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 Jul 13, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 13, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 52 untouched benchmarks
⏩ 19 skipped benchmarks1


Comparing minifier-keep-uninitialized-binding-reads (4e5fa28) with main (7f80cac)

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 requested a review from sapphi-red July 13, 2026 10:15
@Dunqing
Dunqing marked this pull request as ready for review July 13, 2026 10:15
@Dunqing

Dunqing commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

I've reviewed the code and understood it.

@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label Jul 13, 2026

Dunqing commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Merge activity

…24452)

`inline_identifier_reference` counts `undefined` among the constants that are always worth substituting into a read. But it prints as `void 0` — six characters — while a kept read mangles to one or two, and for a binding declared without an initializer (`let x;`) there is no initializer whose elimination pays the difference, so the substitution can only grow mangled output. rolldown hits this on bundled chunks (`minify: true` always mangles): the chunk in rolldown/rolldown#10174 minified to 58 bytes where keeping the binding gives 55.

`SymbolValue` now records that a tracked `undefined` is the implicit value of an initializer-less declaration, and the textual inline skips such reads. Only the byte substitution is affected: constant-driven folds (`if (x)` dead branches, `x === void 0`, `return x` elision) resolve the value through `SymbolValue` during evaluation and behave as before. An explicit `const x = undefined` initializer still inlines — there the substitution deletes the `= void 0` text along with the declaration, so it pays for itself.

The known tradeoff is compress-without-mangle: with `mangle: false` the kept binding prints its full source name, where the old behavior emitted the shorter `void 0`. The small-value thresholds in this heuristic already assume mangled read sites (numbers up to 3 digits, strings up to 3 chars), so the gate follows the same assumption; a CompressOptions hint for compress-only pipelines can be added later if anyone hits it. For comparison, esbuild never models implicit `undefined` (its const-value inlining requires a `const` with a literal initializer) and wins this shape by keeping the binding; terser substitutes like the old behavior and produces the same 58 bytes.

## Example

The rolldown chunk from the issue (`minify: false` output, then minified with mangling):

```js
let undefinedVar;
let value;
function reset() {
	value = undefinedVar;
}
export { reset, value };
```

Before:

```js
let e;function t(){e=void 0}export{t as reset,e as value};
```

After:

```js
let e,t;function n(){t=e}export{n as reset,t as value};
```

Verified with the minifier unit suite (two expectation updates where the kept binding is now smaller or equal), `just minsize` and allocation snapshots unchanged, `--twice` idempotency on the repro, and old-vs-new byte-identical output on real rolldown chunks of vue, svelte, and rxjs.

Fixes rolldown/rolldown#10174.

Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
@graphite-app
graphite-app Bot force-pushed the minifier-keep-uninitialized-binding-reads branch from 4e5fa28 to edf781d Compare July 13, 2026 15:22
@graphite-app
graphite-app Bot merged commit edf781d into main Jul 13, 2026
29 checks passed
@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label Jul 13, 2026
@graphite-app
graphite-app Bot deleted the minifier-keep-uninitialized-binding-reads branch July 13, 2026 15:26

@sapphi-red sapphi-red left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Dunqing Does this mean let a; console.log(a * 2); is no longer transformed to console.log(0/0)?

@Dunqing

Dunqing commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

@Dunqing Does this mean let a; console.log(a * 2); is no longer transformed to console.log(0/0)?

I may be missing something; it seems never to be inlined to console.log(0/0) either before or after. However, it is indeed preventing inline.See Oxc Playground

@sapphi-red

Copy link
Copy Markdown
Member

Ah, my bad. I assumed void 0 * 2 is transformed to 0/0 (NaN). But I guess it will still be console.log(0/0) if that's implemented, as let a;console.log((a + "").length); is transformed to console.log(9);.

@Dunqing

Dunqing commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

@sapphi-red Correct that a is no longer replaced with void 0 there — but oxc never output console.log(0/0) for that input. Right before this PR the result was console.log(void 0 * 2); there's no void 0 * 2NaN fold (and 0/0 in source prints as NaN, not the other way around).

Only the textual substitution is gated. Folds that consume the tracked value still work: let a; if (a === void 0) f() still folds the branch, a ?? x still folds to x. If we ever add arithmetic folding over tracked undefined values, it would resolve through SymbolValue and wouldn't be affected by this gate either.

@Dunqing

Dunqing commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Ah, my bad. I assumed void 0 * 2 is transformed to 0/0 (NaN). But I guess it will still be console.log(0/0) if that's implemented, as let a;console.log((a + "").length); is transformed to console.log(9);.

I will create a issue to track this

Dunqing added a commit that referenced this pull request Jul 14, 2026
`extract_numeric_values` required two numeric literals, so arithmetic
over values with no literal form never folded: `Infinity` and `NaN`
identifiers normalize to non-finite numeric literals and fold, but
`undefined` normalizes to `void 0` — a unary expression — so
`void 0 * 2` stayed as-is, eight characters where `NaN` is three. The
same gate kept tracked constants from folding: `let a;
console.log(a * 2)` kept the read (and with it the declaration) alive
where `console.log(NaN)` is smaller and lets the binding die. terser
folds all of these; esbuild folds none. Surfaced by the review
question on #24452.

The extraction now falls back to the constant evaluator, which
applies ToNumber (`undefined` → NaN, `null` → 0, `'2'` → 2) and
refuses side-effectful operands; each arm's small-result size filters
are unchanged. A cheap operand-kind gate keeps the fallback away from
allocation-prone operands (a bigint literal heap-allocates only for
ToNumber to bail; a call expression attempts string-method folds) —
with it, the allocation snapshots are byte-identical to main.
Addition already behaved this way (`void 0 + 1` folded), so this
aligns the gated arms with it.

Widening NaN-producing folds exposed a pre-existing miscompile that
had to be fixed first: a NaN numeric literal prints as the identifier
`NaN`, so `function f() { let NaN = 1; return 0 / 0; }` minified to
`return NaN` — returning 1. `eval_binary` now bails when `NaN` /
`Infinity` is shadowed at the fold site; bailing rather than emitting
`0 / 0` keeps the fixed-point loop convergent, since replacing
`0 / 0` with itself would set the changed flag on every pass. For
non-binary producers (e.g. `parseInt` folds), `value_to_expr`
materializes non-finite constants in shadowed scopes as `0/0` /
`1/0` / `-1/0`, which the division fold then leaves alone.

## Example

```js
let a;
console.log(a * 2);
console.log(void 0 * 2);
```

Before:

```js
let a;
console.log(a * 2), console.log(void 0 * 2);
```

After:

```js
console.log(NaN), console.log(NaN);
```

Verified with the minifier unit suite (555 passing; the only
expectation changes are the closure-compiler ports this enables: the
commented-out `x = null * 1` → `x = 0` family now passes and
`x = null ** 0` folds to `x = 1`), `--twice` idempotency, and
byte-identical minsize and allocation snapshots.

Implemented with AI assistance (Claude Code); design, review, and
verification driven by the author per the repo AI-usage policy.
Dunqing added a commit that referenced this pull request Jul 14, 2026
`extract_numeric_values` required two numeric literals, so arithmetic
over values with no literal form never folded: `Infinity` and `NaN`
identifiers normalize to non-finite numeric literals and fold, but
`undefined` normalizes to `void 0` — a unary expression — so
`void 0 * 2` stayed as-is, eight characters where `NaN` is three. The
same gate kept tracked constants from folding: `let a;
console.log(a * 2)` kept the read (and with it the declaration) alive
where `console.log(NaN)` is smaller and lets the binding die. terser
folds all of these; esbuild folds none. Surfaced by the review
question on #24452.

The extraction now falls back to the constant evaluator, which
applies ToNumber (`undefined` → NaN, `null` → 0, `'2'` → 2) and
refuses side-effectful operands; each arm's small-result size filters
are unchanged. A cheap operand-kind gate keeps the fallback away from
allocation-prone operands (a bigint literal heap-allocates only for
ToNumber to bail; a call expression attempts string-method folds) —
with it, the allocation snapshots are byte-identical to main.
Addition already behaved this way (`void 0 + 1` folded), so this
aligns the gated arms with it.

Widening NaN-producing folds exposed a pre-existing miscompile that
had to be fixed first: a NaN numeric literal prints as the identifier
`NaN`, so `function f() { let NaN = 1; return 0 / 0; }` minified to
`return NaN` — returning 1. `eval_binary` now bails when `NaN` /
`Infinity` is shadowed at the fold site; bailing rather than emitting
`0 / 0` keeps the fixed-point loop convergent, since replacing
`0 / 0` with itself would set the changed flag on every pass. For
non-binary producers (e.g. `parseInt` folds), `value_to_expr`
materializes non-finite constants in shadowed scopes as `0/0` /
`1/0` / `-1/0`, which the division fold then leaves alone.

## Example

```js
let a;
console.log(a * 2);
console.log(void 0 * 2);
```

Before:

```js
let a;
console.log(a * 2), console.log(void 0 * 2);
```

After:

```js
console.log(NaN), console.log(NaN);
```

Verified with the minifier unit suite (555 passing; the only
expectation changes are the closure-compiler ports this enables: the
commented-out `x = null * 1` → `x = 0` family now passes and
`x = null ** 0` folds to `x = 1`), `--twice` idempotency, and
byte-identical minsize and allocation snapshots.

Implemented with AI assistance (Claude Code); design, review, and
verification driven by the author per the repo AI-usage policy.
Dunqing added a commit that referenced this pull request Jul 15, 2026
`extract_numeric_values` required two numeric literals, so arithmetic
over values with no literal form never folded: `Infinity` and `NaN`
identifiers normalize to non-finite numeric literals and fold, but
`undefined` normalizes to `void 0` — a unary expression — so
`void 0 * 2` stayed as-is, eight characters where `NaN` is three. The
same gate kept tracked constants from folding: `let a;
console.log(a * 2)` kept the read (and with it the declaration) alive
where `console.log(NaN)` is smaller and lets the binding die. terser
folds all of these; esbuild folds none. Surfaced by the review
question on #24452.

The extraction now falls back to the constant evaluator, which
applies ToNumber (`undefined` → NaN, `null` → 0, `'2'` → 2) and
refuses side-effectful operands; each arm's small-result size filters
are unchanged. A cheap operand-kind gate keeps the fallback away from
allocation-prone operands (a bigint literal heap-allocates only for
ToNumber to bail; a call expression attempts string-method folds) —
with it, the allocation snapshots are byte-identical to main.
Addition already behaved this way (`void 0 + 1` folded), so this
aligns the gated arms with it.

Widening NaN-producing folds exposed a pre-existing miscompile that
had to be fixed first: a NaN numeric literal prints as the identifier
`NaN`, so `function f() { let NaN = 1; return 0 / 0; }` minified to
`return NaN` — returning 1. `eval_binary` now bails when `NaN` /
`Infinity` is shadowed at the fold site; bailing rather than emitting
`0 / 0` keeps the fixed-point loop convergent, since replacing
`0 / 0` with itself would set the changed flag on every pass. For
non-binary producers (e.g. `parseInt` folds), `value_to_expr`
materializes non-finite constants in shadowed scopes as `0/0` /
`1/0` / `-1/0`, which the division fold then leaves alone.

## Example

```js
let a;
console.log(a * 2);
console.log(void 0 * 2);
```

Before:

```js
let a;
console.log(a * 2), console.log(void 0 * 2);
```

After:

```js
console.log(NaN), console.log(NaN);
```

Verified with the minifier unit suite (555 passing; the only
expectation changes are the closure-compiler ports this enables: the
commented-out `x = null * 1` → `x = 0` family now passes and
`x = null ** 0` folds to `x = 1`), `--twice` idempotency, and
byte-identical minsize and allocation snapshots.

Implemented with AI assistance (Claude Code); design, review, and
verification driven by the author per the repo AI-usage policy.
graphite-app Bot pushed a commit that referenced this pull request Jul 15, 2026
)

`extract_numeric_values` required two numeric literals, so arithmetic over values with no literal form never folded: `Infinity` and `NaN` identifiers normalize to non-finite numeric literals and fold, but `undefined` normalizes to `void 0` — a unary expression — so `void 0 * 2` stayed as-is, eight characters where `NaN` is three. The same gate kept tracked constants from folding: `let a; console.log(a * 2)` kept the read (and with it the declaration) alive where `console.log(NaN)` is smaller and lets the binding die. terser folds all of these; esbuild folds none. Surfaced by the review question on #24452.

The extraction now falls back to the constant evaluator, which applies ToNumber (`undefined` → NaN, `null` → 0, `'2'` → 2) and refuses side-effectful operands; each arm's small-result size filters are unchanged. A cheap operand-kind gate keeps the fallback away from allocation-prone operands (a bigint literal heap-allocates only for ToNumber to bail; a call expression attempts string-method folds) — with it, the allocation snapshots are byte-identical to main. Addition already behaved this way (`void 0 + 1` folded), so this aligns the gated arms with it.

Widening NaN-producing folds exposed a pre-existing miscompile that had to be fixed first: a NaN numeric literal prints as the identifier `NaN`, so `function f() { let NaN = 1; return 0 / 0; }` minified to `return NaN` — returning 1. `eval_binary` now bails when `NaN` / `Infinity` is shadowed at the fold site; bailing rather than emitting `0 / 0` keeps the fixed-point loop convergent, since replacing `0 / 0` with itself would set the changed flag on every pass. For non-binary producers (e.g. `parseInt` folds), `value_to_expr` materializes non-finite constants in shadowed scopes as `0/0` / `1/0` / `-1/0`, which the division fold then leaves alone.

## Example

```js
let a;
console.log(a * 2);
console.log(void 0 * 2);
```

Before:

```js
let a;
console.log(a * 2), console.log(void 0 * 2);
```

After:

```js
console.log(NaN), console.log(NaN);
```

Verified with the minifier unit suite (555 passing; the only expectation changes are the closure-compiler ports this enables: the commented-out `x = null * 1` → `x = 0` family now passes and `x = null ** 0` folds to `x = 1`), `--twice` idempotency, and byte-identical minsize and allocation snapshots.

Fixes #24474

Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
graphite-app Bot pushed a commit that referenced this pull request Jul 15, 2026
)

`extract_numeric_values` required two numeric literals, so arithmetic over values with no literal form never folded: `Infinity` and `NaN` identifiers normalize to non-finite numeric literals and fold, but `undefined` normalizes to `void 0` — a unary expression — so `void 0 * 2` stayed as-is, eight characters where `NaN` is three. The same gate kept tracked constants from folding: `let a; console.log(a * 2)` kept the read (and with it the declaration) alive where `console.log(NaN)` is smaller and lets the binding die. terser folds all of these; esbuild folds none. Surfaced by the review question on #24452.

The extraction now falls back to the constant evaluator, which applies ToNumber (`undefined` → NaN, `null` → 0, `'2'` → 2) and refuses side-effectful operands; each arm's small-result size filters are unchanged. A cheap operand-kind gate keeps the fallback away from allocation-prone operands (a bigint literal heap-allocates only for ToNumber to bail; a call expression attempts string-method folds) — with it, the allocation snapshots are byte-identical to main. Addition already behaved this way (`void 0 + 1` folded), so this aligns the gated arms with it.

Widening NaN-producing folds exposed a pre-existing miscompile that had to be fixed first: a NaN numeric literal prints as the identifier `NaN`, so `function f() { let NaN = 1; return 0 / 0; }` minified to `return NaN` — returning 1. `eval_binary` now bails when `NaN` / `Infinity` is shadowed at the fold site; bailing rather than emitting `0 / 0` keeps the fixed-point loop convergent, since replacing `0 / 0` with itself would set the changed flag on every pass. For non-binary producers (e.g. `parseInt` folds), `value_to_expr` materializes non-finite constants in shadowed scopes as `0/0` / `1/0` / `-1/0`, which the division fold then leaves alone.

## Example

```js
let a;
console.log(a * 2);
console.log(void 0 * 2);
```

Before:

```js
let a;
console.log(a * 2), console.log(void 0 * 2);
```

After:

```js
console.log(NaN), console.log(NaN);
```

Verified with the minifier unit suite (555 passing; the only expectation changes are the closure-compiler ports this enables: the commented-out `x = null * 1` → `x = 0` family now passes and `x = null ** 0` folds to `x = 1`), `--twice` idempotency, and byte-identical minsize and allocation snapshots.

Fixes #24474

Implemented with AI assistance (Claude Code); design, review, and verification driven by the author per the repo AI-usage policy.
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.

[Bug]: Inlining an undefined variable produces longer output

2 participants