fix(minifier): mark LHS of ??= as read when converting from == null && (#21546)

What
---

Fix bug: minifier drops the guard and assignment from `if (x == null) {
x = expr; }`, making it just `expr` which gets evaluated
unconditionally.

Fixes #21457

Example
---

```js
let rafId;
export function foo() {
  if (rafId == null) {
    rafId = requestAnimationFrame(() => console.log('callback'));
  }
}
```

was being minified down to roughly:

```js
export function foo() {
  requestAnimationFrame(() => console.log('callback'));
}
```
... losing the "schedule at most once" semantics.

Root cause
---

In converting `a == null && (a = b)` to `a ??= b`, the LHS reference was
left flagged Write-only. So on the next unused-removal iteration the
variable appears unread. So the assignment (and thus the nullish guard
inherent in it) gets stripped, leaving just the RHS.

Fix
---

After swapping `AssignmentOperator::Assign` to
`AssignmentOperator::LogicalNullish` in `remove_unused_expression.rs`,
call the existing `mark_assignment_target_as_read` helper. The sibling
conversions in `minimize_logical_expression.rs` /
`minimize_conditions.rs` (`||=`, `&&=`, `+=`, …) already do this — only
the `??=` path in this file was missed.

Tests
---

Five regression tests covering the four source shapes that lower to
`??=` (`== null &&`, `!= null ||`, `if (x == null)`, `if (x != null) {}
else`), plus a test for the member-access LHS case (e.g. `o.y ??=
expr`), which goes through a different code path and was never affected
by the bug — included to lock in that behavior so it can't regress
silently. Each case is a separate `#[test]` so one regression does not
mask the others.

---------

Signed-off-by: 翠 <green@sapphi.red>
Co-authored-by: 翠 <green@sapphi.red>
This commit is contained in:
Gunnlaugur Thor Briemand翠 authored and GitHub committed 2026-04-20 13:45:31 +09:00
1 parent 7884ec6715
commit d676e0ca5b
2 files changed
+42

No files matched your search

@@ -152,6 +152,8 @@ impl<'a> PeepholeOptimizations {
{
assignment_expr.span = *logical_span;
assignment_expr.operator = AssignmentOperator::LogicalNullish;
// `??=` reads the LHS to check for nullish, so update reference flags.
Self::mark_assignment_target_as_read(&assignment_expr.left, ctx);
*e = logical_right.take_in(ctx.ast);
ctx.state.changed = true;
return false;
@@ -168,6 +168,46 @@ fn test_logical_expression() {
test("typeof x > 'u' || x", "");
}
// Regression tests for https://github.com/oxc-project/oxc/issues/21457.
//
// When `a == null && (a = b)` is converted to `a ??= b`, the LHS reference
// must be flagged as Read; otherwise unused-removal sees zero read references
// on the next iteration and strips the assignment, dropping the nullish guard.
//
// Each sub-case is a separate test so one regression doesn't mask the others.
#[test]
fn test_nullish_assign_preserves_guard() {
let options = CompressOptions::smallest();
test_options(
"let rafId; export function foo() { if (rafId == null) { rafId = requestAnimationFrame(() => { console.log('callback'); }); } }",
"let rafId; export function foo() { rafId ??= requestAnimationFrame(() => { console.log('callback'); }); }",
&options,
);
test_options(
"let rafId; export function foo() { if (rafId != null) {} else { rafId = requestAnimationFrame(() => { console.log('callback'); }); } }",
"let rafId; export function foo() { rafId ??= requestAnimationFrame(() => { console.log('callback'); }); }",
&options,
);
test_options(
"let a; export function foo() { a == null && (a = compute()); }",
"let a; export function foo() { a ??= compute(); }",
&options,
);
test_options(
"let a; export function foo() { a != null || (a = compute()); }",
"let a; export function foo() { a ??= compute(); }",
&options,
);
// Member LHS goes through `remove_unused_member_assignment`, not the
// identifier path, so it was never affected by the reference-flag bug.
// Still covered here to pin down expected behavior under `smallest()`.
test_options(
"export let o = {}; export function foo() { o.y == null && (o.y = compute()); }",
"export let o = {}; export function foo() { o.y ??= compute(); }",
&options,
);
}
#[expect(clippy::literal_string_with_formatting_args)]
#[test]
fn test_object_literal() {