Skip to content

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

Merged
sapphi-red merged 5 commits intooxc-project:mainfrom
gthb:worktree-oxc-21457
Apr 20, 2026
Merged

fix(minifier): mark LHS of ??= as read when converting from == null &&#21546
sapphi-red merged 5 commits intooxc-project:mainfrom
gthb:worktree-oxc-21457

Conversation

@gthb
Copy link
Copy Markdown
Contributor

@gthb gthb commented Apr 18, 2026

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

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

was being minified down to roughly:

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.

gthb added 2 commits April 18, 2026 16:23
The `if (x == null) { x = expr; }` guard is incorrectly stripped by the
minifier when `CompressOptions::smallest()` (unused removal) is active.
After `a == null && (a = b)` is converted to `a ??= b`, the LHS
reference is still flagged as Write-only; on the next iteration the
unused-removal pass sees `read_references_count == 0` and strips the
assignment, leaving just the RHS.

Covers both `== null &&` and `!= null ||` shapes, the lowered logical
form, and member-LHS (which goes through a different path and is
unaffected). Each case is a separate test so one regression doesn't
mask the others.
…l &&` (oxc-project#21457)

When converting `a == null && (a = b)` to `a ??= b`, the LHS reference
flags were not updated to include Read. This caused the unused removal
pass to see `read_references_count == 0` on the next iteration and
strip the assignment, removing the nullish guard entirely — e.g.
`let rafId; function foo() { if (rafId == null) { rafId = raf(cb); } }`
would be minified to `function foo() { raf(cb); }`, losing the
"schedule at most once" semantics.

The equivalent `a = a || b` → `a ||= b` and `a = a + b` → `a += b`
conversions already update reference flags via
`mark_assignment_target_as_read`; this patch applies the same treatment
to the `??=` path.
@github-actions github-actions Bot added A-minifier Area - Minifier C-bug Category - Bug labels Apr 18, 2026
Conflict
===

`crates/oxc_minifier/src/peephole/remove_unused_expression.rs`

Both sides modified the same `a == null && (a = b)` → `a ??= b` branch:

- HEAD (this PR) added a `Self::mark_assignment_target_as_read(&assignment_expr.left, ctx)` call right after switching the operator to `LogicalNullish`, so the next unused-removal pass doesn't see the LHS as unread and strip the whole assignment.
- `origin/main` restructured the surrounding match so the `ES2020NullishCoalescingOperator` check is an arm guard (`if ... =>`) instead of a nested `if`, which dedented the inner block by one level.

Resolution
===

Took `origin/main`'s dedented indentation and kept HEAD's new `mark_assignment_target_as_read` call (collapsed to a single line since it now fits at the shallower indent). Full minifier test suite passes (482 tests, including the five new regression tests).
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 19, 2026

Merging this PR will not alter performance

✅ 44 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing gthb:worktree-oxc-21457 (450fc99) with main (0ec6ab2)2

Open in CodSpeed

Footnotes

  1. 7 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.

  2. No successful run was found on main (65f9195) during the generation of this report, so 0ec6ab2 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@gthb
Copy link
Copy Markdown
Contributor Author

gthb commented Apr 19, 2026

Implemented with Claude Code, then reviewed and iterated.

@gthb gthb marked this pull request as ready for review April 19, 2026 01:52
Comment thread crates/oxc_minifier/tests/peephole/remove_unused_expression.rs Outdated
Signed-off-by: 翠 <green@sapphi.red>
Copy link
Copy Markdown
Member

@sapphi-red sapphi-red left a comment

Choose a reason for hiding this comment

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

Thanks!

@sapphi-red sapphi-red merged commit d676e0c into oxc-project:main Apr 20, 2026
33 checks passed
@gthb gthb deleted the worktree-oxc-21457 branch April 20, 2026 15:45
camc314 added a commit that referenced this pull request Apr 20, 2026
### 🐛 Bug Fixes

- 48967e8 isolated_declarations: Drop required type check for private
parameter properties on private constructors (#21515) (Dunqing)
- 91e5bde transformer/typescript: Preserve computed-key static block
when class has an empty constructor (#21562) (Dunqing)
- 50e9d26 mangler: Assign correct slot to shadowed function-expression
names (#21535) (Dunqing)
- 065ce47 isolated_declarations: Collect types from private accessors
for paired inference (#21516) (Dunqing)
- 00fc136 codegen: Preserve coverage comments before object properties
(#21312) (bab)
- d676e0c minifier: Mark LHS of `??=` as read when converting from `==
null &&` (#21546) (Gunnlaugur Thor Briem)

### ⚡ Performance

- e45efc5 parser: Reduce `try_parse` usage in favour of `lookahead`
(#21532) (Boshen)
- ddb1bf8 parser: Avoid redundant `IdentifierReference` clone in
shorthand property (#21511) (Boshen)
- be2b392 allocator: Store pointers directly in `Arena` (#21483)
(overlookmotel)

Co-authored-by: Boshen <1430279+Boshen@users.noreply.github.com>
Co-authored-by: Cameron <cameron.clark@hey.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier C-bug Category - Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Oxc minifier removes if guard and changes runtime behavior

2 participants