mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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() {
|
||||
|
||||
Reference in new issue
Block a user