fix(minifier): avoid duplicating large folded strings (#25532)

Constant evaluation can fold a string addition through a tracked constant even when direct identifier inlining rejects that value as too large. This materializes the large string at every reference and can double chunks containing embedded assets.

This change shares constant materialization eligibility between direct inlining and string-add folding. A fold that would grow the expression through a non-inlineable constant is skipped, while single-read and cheap constants remain foldable. The policy follows the conservative strategy used by esbuild and SWC; permanent source links are included beside the helper.

## Example

Input:

```js
const p = "…1 MB payload…";
export const a = atob(p);
export const b = "y" + p;
```

Before, Rolldown 1.2.3 emitted two copies of the payload, growing the output from about 1 MB to 2 MB. After, `p` remains a binding and the payload is stored once.

Verified with `cargo test -p oxc_minifier`, `just minsize` (no minsize or allocation snapshot changes), `cargo clippy -p oxc_minifier -- -D warnings`, and `just fmt`. Regression tests cover both full compression and DCE.

Fixes rolldown/rolldown#10656.

AI-assisted. The contributor remains responsible for reviewing, understanding, and submitting the changes under Oxc's AI usage policy.
This commit is contained in:
Dunqing committed 2026-08-13 00:53:00 +00:00
1 parent c5d406370d
commit 76b19f503b
5 files changed
+123 -19

No files matched your search

@@ -517,6 +517,41 @@ impl<'a> PeepholeOptimizations {
Some(Self::number_literal_source_len(result)? <= original_len)
}
/// Lower bound for the minified size of a string addition operand, along with
/// whether folding it would materialize a non-inlineable tracked constant.
fn string_expression_size_lower_bound(
expr: &Expression<'a>,
ctx: &TraverseCtx<'a>,
) -> Option<(usize, bool)> {
let result = match expr.get_inner_expression() {
Expression::StringLiteral(lit) => (lit.value.len() + 2, false),
Expression::NumericLiteral(lit) => (Self::number_literal_source_len(lit.value)?, false),
Expression::BooleanLiteral(_) => (2, false),
Expression::NullLiteral(_) => (4, false),
Expression::Identifier(ident) => (
ident.name.len(),
ident
.reference_id
.get()
.and_then(|reference_id| ctx.scoping().get_reference(reference_id).symbol_id())
.and_then(|symbol_id| ctx.state.symbols.value(symbol_id))
.is_some_and(|value| {
value.initialized_constant.is_some()
&& !value.can_inline_initialized_constant()
}),
),
Expression::BinaryExpression(e) if e.operator == BinaryOperator::Addition => {
let (left_size, left_has_non_inlineable) =
Self::string_expression_size_lower_bound(&e.left, ctx)?;
let (right_size, right_has_non_inlineable) =
Self::string_expression_size_lower_bound(&e.right, ctx)?;
(left_size + 1 + right_size, left_has_non_inlineable || right_has_non_inlineable)
}
_ => return None,
};
Some(result)
}
// Simplified version of `tryFoldAdd` from closure compiler.
fn try_fold_add(e: &mut BinaryExpression<'a>, ctx: &TraverseCtx<'a>) -> Option<Expression<'a>> {
if !e.may_have_side_effects(ctx)
@@ -527,6 +562,16 @@ impl<'a> PeepholeOptimizations {
{
return None;
}
if let ConstantValue::String(result) = &v
&& let Some((left_size, left_has_non_inlineable)) =
Self::string_expression_size_lower_bound(&e.left, ctx)
&& let Some((right_size, right_has_non_inlineable)) =
Self::string_expression_size_lower_bound(&e.right, ctx)
&& result.len() + 2 > left_size + 1 + right_size
&& (left_has_non_inlineable || right_has_non_inlineable)
{
return None;
}
return Some(ctx.value_to_expr(e.span, v));
}
debug_assert_eq!(e.operator, BinaryOperator::Addition);
+4 -19
View File
@@ -275,26 +275,11 @@ impl<'a> PeepholeOptimizations {
let Some(symbol_value) = ctx.state.symbols.value(symbol_id) else {
return;
};
// Skip if there are write references.
if symbol_value.references.has_writes() {
if !symbol_value.can_inline_initialized_constant() {
return;
}
let Some(cv) = &symbol_value.initialized_constant else { return };
// Textually inlining the implicit `undefined` of `let x;` can only grow
// the output; see `SymbolValue::implicit_undefined` (rolldown#10174).
if symbol_value.implicit_undefined {
return;
}
if symbol_value.references.has_single_read()
|| match cv {
ConstantValue::Number(n) => n.fract() == 0.0 && *n >= -99.0 && *n <= 999.0,
ConstantValue::BigInt(_) => false,
ConstantValue::String(s) => s.len() <= 3,
ConstantValue::Boolean(_) | ConstantValue::Undefined | ConstantValue::Null => true,
}
{
let new_expr = ctx.value_to_expr(expr.span(), cv.clone());
ctx.replace_expression(expr, new_expr);
}
let constant = symbol_value.initialized_constant.as_ref().unwrap();
let new_expr = ctx.value_to_expr(expr.span(), constant.clone());
ctx.replace_expression(expr, new_expr);
}
}
+27
View File
@@ -111,3 +111,30 @@ pub struct SymbolValue<'a> {
/// `false` there. See `minimize_expression_in_boolean_context` / #14001.
pub boolean_falsy: bool,
}
impl SymbolValue<'_> {
/// Constants with one read can always be inlined without duplication:
/// `const value = "a long string"; use(value)` -> `use("a long string")`.
///
/// With multiple reads, only integers in `-99..=999`, strings with `len() <= 3`,
/// booleans, `null`, and `undefined` can be inlined. Bindings with writes or an
/// implicit `undefined` cannot be inlined.
///
/// Inspired by [esbuild's constant inliner][esbuild] and [SWC's variable inliner][swc].
///
/// [esbuild]: https://github.com/evanw/esbuild/blob/f6058f8364fe7ab91ca57a83e02577ed74c9cae4/internal/js_ast/js_ast.go#L1650-L1685
/// [swc]: https://github.com/swc-project/swc/blob/6c778430811853d4feee2ab3af1473669deb7b2a/crates/swc_ecma_minifier/src/compress/optimize/inline.rs#L277-L295
pub fn can_inline_initialized_constant(&self) -> bool {
if self.references.has_writes() || self.implicit_undefined {
return false;
}
let Some(constant) = &self.initialized_constant else { return false };
self.references.has_single_read()
|| match constant {
ConstantValue::Number(n) => n.fract() == 0.0 && *n >= -99.0 && *n <= 999.0,
ConstantValue::BigInt(_) => false,
ConstantValue::String(s) => s.len() <= 3,
ConstantValue::Boolean(_) | ConstantValue::Undefined | ConstantValue::Null => true,
}
}
}
@@ -844,6 +844,30 @@ fn fold_coalesce_on_tracked_non_nullish_binding() {
);
}
// https://github.com/rolldown/rolldown/issues/10656
#[test]
fn does_not_duplicate_large_tracked_strings_when_folding_addition() {
test_same(
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const a = atob(p); export const b = 'y' + p;",
);
test_same(
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const a = atob(p); export const b = 'x' + ('y' + p);",
);
// A large string with one read can replace that read and drop the binding,
// so folding still reduces the total output size.
test(
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const b = 'y' + p;",
"export const b = 'yPAYLOADpayload0123456789PAYLOADpayload0123456789';",
);
// Small tracked strings remain cheap enough to duplicate.
test(
"const p = 'abc'; export const a = atob(p); export const b = 'y' + p;",
"const p = 'abc'; export const a = atob(p); export const b = 'yabc';",
);
}
// Convergence regression (monitor-oxc, bluebird.js): `try_fold_if` re-extracts
// the dead branch's `var` names via `KeepVar` on every pass and filters the
// synthesized statement through the unused-declarator removal. Dropping `x`
@@ -1313,6 +1313,29 @@ fn test_associative_fold_constants_with_variables() {
fold("alert(12 & x & 20)", "alert(x & 4)");
}
// https://github.com/rolldown/rolldown/issues/10656
#[test]
fn test_does_not_duplicate_large_tracked_strings_when_folding_addition() {
test_same(
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const a = atob(p); export const b = 'y' + p;",
);
test_same(
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const a = atob(p); export const b = 'x' + ('y' + p);",
);
// A large string with one read is still inlineable and foldable.
test(
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const b = 'y' + p;",
"const p = 'PAYLOADpayload0123456789PAYLOADpayload0123456789'; export const b = 'yPAYLOADpayload0123456789PAYLOADpayload0123456789';",
);
// Small tracked strings remain cheap enough to inline and fold.
test(
"const p = 'abc'; export const a = atob(p); export const b = 'y' + p;",
"const p = 'abc'; export const a = atob('abc'); export const b = 'yabc';",
);
}
#[test]
fn test_to_number() {
fold("x = +''", "x = 0");