fix(minifier): normalize { x: x } shorthand so adjacent-if merge is idempotent (#22401)

## Why

The merge pass for consecutive `if (..) return X;` statements decides whether two jump bodies are equal via `content_eq`, and `ObjectProperty::content_eq` includes the `shorthand` flag. Codegen, in contrast, always prints `{ x: x }` as `{ x }` whenever the value is an identifier matching the key. The flag disagreement made the merge non-idempotent. Given:

```js
function _(body) {
  if (a) return { body };
  if (b) return { body };
  if (c) return { body };
  if (d) return { body: body };
}
```

pass 1 merged only the first three (their `shorthand` matched) and left the trailing if alone:

```js
function _(body) {
  if (a || b || c) return { body };
  if (d) return { body };
}
```

pass 2 then merged all four, because the printed `{ body }` reparses with `shorthand = true`. The fixed-point loop converges, but a second call to `Compressor::build` would still mutate the AST — the contract `monitor-oxc` checks.

## Fix

Extend `substitute_object_property` (already called once per `ObjectProperty` exit in the peephole loop, so no new traversal dispatch) to set `shorthand = true` whenever the value is an identifier with the same name as a non-computed, non-method, non-`__proto__` init key. The AST now matches what codegen would emit, so subsequent `content_eq`-based merges converge in a single pass. Checks are ordered to bail on the common case first (already shorthand, or non-identifier value); output text and the `minsize` / `allocs` snapshots are unchanged.

Originally surfaced by `monitor-oxc` CI on `@typespec/ts-http-runtime`'s `sendRequest.js`: https://github.com/oxc-project/monitor-oxc/actions/runs/25841541741/job/75927903765
This commit is contained in:
Dunqing committed 2026-05-18 08:45:39 +00:00
1 parent dcc6d38330
commit c27a8cfa4c
2 files changed
+75 -1

No files matched your search

@@ -37,7 +37,38 @@ impl<'a> PeepholeOptimizations {
}
}
// Normalise the key before checking shorthand: `try_compress_property_key`
// can turn `{ "x": x }` into `{ x: x }`, which then becomes a candidate
// for shorthand normalisation in the same visit.
Self::try_compress_property_key(&mut prop.key, &mut prop.computed, ctx);
Self::normalize_object_property_shorthand(prop);
}
/// `{ x: x }` is observationally equivalent to `{ x }`, and codegen always
/// prints it as `{ x }`. Set `shorthand = true` so the AST matches the
/// printed output; otherwise content-equality checks (e.g. when merging
/// adjacent `if` statements with identical jump bodies) treat the two
/// forms as different on the first pass and only converge on the second.
///
/// Output text is unchanged, so we deliberately do not flip
/// `ctx.state.changed`.
fn normalize_object_property_shorthand(prop: &mut ObjectProperty<'a>) {
if prop.shorthand {
return;
}
let Expression::Identifier(value) = &prop.value else { return };
if prop.computed || prop.method || prop.kind != PropertyKind::Init {
return;
}
let PropertyKey::StaticIdentifier(key) = &prop.key else { return };
// `{ __proto__: __proto__ }` triggers the Annex B.3.1 proto setter
// (literal `__proto__` key, non-computed, non-shorthand, non-method),
// but `{ __proto__ }` is a plain shorthand `IdentifierReference` that
// creates a regular own data property and does NOT set `[[Prototype]]`.
// Converting would change observable behaviour, so bail out.
if key.name == value.name && key.name != "__proto__" {
prop.shorthand = true;
}
}
pub fn substitute_assignment_target_property_property(
@@ -1,6 +1,6 @@
use std::fmt::Write;
use crate::test;
use crate::{test, test_same};
#[test]
fn test_for_variable_declaration() {
@@ -80,3 +80,46 @@ fn test_max_conditional_depth_caps_return_ternary_chain() {
test(&input, &output);
}
// https://github.com/oxc-project/monitor-oxc/actions/runs/25841541741/job/75927903765
// `{ body }` and `{ body: body }` are observationally equivalent, so adjacent
// jump statements returning either form should collapse into a single `if`.
// The bug was that ObjectProperty's structural equality compares the `shorthand`
// flag, leaving the trailing `{ body: body }` outside the merged chain on the
// first pass.
#[test]
fn test_merge_adjacent_ifs_with_shorthand_object_property() {
test(
"function _(body) {
if (a) return { body };
if (b) return { body };
if (c) return { body };
if (d) return { body: body };
}",
"function _(body) { if (a || b || c || d) return { body }; }",
);
// String-literal key normalises to identifier first, then to shorthand —
// both transforms must land in a single Compressor::build call.
test(
"function _(body) {
if (a) return { body };
if (b) return { 'body': body };
}",
"function _(body) { if (a || b) return { body }; }",
);
test_same(
"function _(body, other) {
if (a) return { body };
if (b) return { other };
}",
);
}
// `{ __proto__: __proto__ }` sets `[[Prototype]]` via the Annex B.3.1 proto
// setter, while `{ __proto__ }` is a plain shorthand that creates a regular
// own data property. Normalising the former into the latter would change
// observable behaviour, so it must be left alone.
#[test]
fn test_object_property_shorthand_normalisation_skips_proto_setter() {
test_same("function _(__proto__) { return { __proto__: __proto__ }; }");
}