fix(minifier): keep reads of never-assigned uninitialized bindings#24452
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via 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. |
Merging this PR will not alter performance
Comparing Footnotes
|
|
I've reviewed the code and understood it. |
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.
4e5fa28 to
edf781d
Compare
sapphi-red
left a comment
There was a problem hiding this comment.
@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 |
|
Ah, my bad. I assumed |
|
@sapphi-red Correct that Only the textual substitution is gated. Folds that consume the tracked value still work: |
I will create a issue to track this |
`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.
`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.
`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.
) `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.
) `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.

inline_identifier_referencecountsundefinedamong the constants that are always worth substituting into a read. But it prints asvoid 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: truealways mangles): the chunk in rolldown/rolldown#10174 minified to 58 bytes where keeping the binding gives 55.SymbolValuenow records that a trackedundefinedis 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 xelision) resolve the value throughSymbolValueduring evaluation and behave as before. An explicitconst x = undefinedinitializer still inlines — there the substitution deletes the= void 0text along with the declaration, so it pays for itself.The known tradeoff is compress-without-mangle: with
mangle: falsethe kept binding prints its full source name, where the old behavior emitted the shortervoid 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 implicitundefined(its const-value inlining requires aconstwith 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: falseoutput, then minified with mangling):Before:
After:
Verified with the minifier unit suite (two expectation updates where the kept binding is now smaller or equal),
just minsizeand allocation snapshots unchanged,--twiceidempotency 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.