Repository: oxc-project/oxc (2026-04-18T23:51:27Z)

Issue:
Oxc minifier removes `if` guard and changes runtime behavior
### Describe the bug

Since upgrading to Vite 8, the default Oxc minifier appears to incorrectly optimize one of my functions and changes its runtime behavior. I reported the bug downstream in https://github.com/vitejs/vite/issues/22234 but was told to report it here.

Here is a stripped down version of my code:

```ts
let rafId: number | undefined;

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

Minified build output using oxc:

```js
function e(){requestAnimationFrame(()=>{console.log(`callback`)})}
```

Minified build output using esbuild:

```js
function c(){n==null&&(n=requestAnimationFrame(()=>{console.log("callback")}))}
```

The `if (rafId == null)` guard is gone entirely (when using oxc), which changes the behavior of the program. The original function schedules at most one pending animation frame at a time, while the minified output schedules a new one on every call.

### Reproduction

The bug only happens when using "Transformer" with target >= 2021 and "Minify Syntax" checked.

https://playground.oxc.rs/?options=%7B%22run%22%3A%7B%22lint%22%3Afalse%2C%22formatter%22%3Afalse%2C%22transform%22%3Atrue%2C%22isolatedDeclarations%22%3Afalse%2C%22whitespace%22%3Afalse%2C%22mangle%22%3Afalse%2C%22compress%22%3Atrue%2C%22scope%22%3Atrue%2C%22symbol%22%3Atrue%2C%22cfg%22%3Afalse%7D%2C%22parser%22%3A%7B%22extension%22%3A%22tsx%22%2C%22allowReturnOutsideFunction%22%3Atrue%2C%22preserveParens%22%3Atrue%2C%22allowV8Intrinsics%22%3Atrue%2C%22semanticErrors%22%3Atrue%7D%2C%22linter%22%3A%7B%7D%2C%22formatter%22%3A%7B%22useTabs%22%3Afalse%2C%22tabWidth%22%3A2%2C%22endOfLine%22%3A%22lf%22%2C%22printWidth%22%3A80%2C%22singleQuote%22%3Afalse%2C%22jsxSingleQuote%22%3Afalse%2C%22quoteProps%22%3A%22as-needed%22%2C%22trailingComma%22%3A%22all%22%2C%22semi%22%3Atrue%2C%22arrowParens%22%3A%22always%22%2C%22bracketSpacing%22%3Atrue%2C%22bracketSameLine%22%3Afalse%2C%22objectWrap%22%3A%22preserve%22%2C%22singleAttributePerLine%22%3Afalse%7D%2C%22transformer%22%3A%7B%22target%22%3A%22es2022%22%2C%22useDefineForClassFields%22%3Atrue%2C%22experimentalDecorators%22%3Atrue%2C%22emitDecoratorMetadata%22%3Atrue%7D%2C%22isolatedDeclarations%22%3A%7B%22stripInternal%22%3Afalse%7D%2C%22codegen%22%3A%7B%22normal%22%3Atrue%2C%22jsdoc%22%3Atrue%2C%22annotation%22%3Atrue%2C%22legal%22%3Atrue%7D%2C%22compress%22%3A%7B%7D%2C%22mangle%22%3A%7B%22topLevel%22%3Atrue%2C%22keepNames%22%3Afalse%7D%2C%22controlFlow%22%3A%7B%22verbose%22%3Afalse%7D%2C%22inject%22%3A%7B%22inject%22%3A%7B%7D%7D%2C%22define%22%3A%7B%22define%22%3A%7B%7D%7D%7D&code=let+rafId%3A+number+%7C+undefined%3B%0A%0Afunction+foo%28%29+%7B%0A++if+%28rafId+%3D%3D+null%29+%7B%0A++++rafId+%3D+requestAnimationFrame%28%28%29+%3D%3E+%7B%0A++++++console.log%28%27callback%27%29%3B%0A++++%7D%29%3B%0A++%7D%0A%7D

Minimal Vite 8 project

https://stackblitz.com/edit/vitejs-vite-byj9asxu?file=src%2Fmain.ts

### Steps to reproduce

`npm run build:oxc` to minify using oxc
`npm run build:esbuild` to minify using esbuild

You can check the build output in `dist/assets/`

### System Info

```shell
System:
    OS: macOS 26.3
    CPU: (12) arm64 Apple M2 Max
    Memory: 1.55 GB / 32.00 GB
    Shell: 5.3.9 - /opt/homebrew/bin/bash
  Binaries:
    Node: 24.13.0 - /Users/dominique/.local/state/fnm_multishells/19928_1775543807061/bin/node
    Yarn: 1.22.22 - /Users/dominique/.local/state/fnm_multishells/19928_1775543807061/bin/yarn
    npm: 11.6.2 - /Users/dominique/.local/state/fnm_multishells/19928_1775543807061/bin/npm
    pnpm: 10.33.0 - /opt/homebrew/bin/pnpm
    bun: 1.3.11 - /opt/homebrew/bin/bun
    Deno: 2.7.11 - /opt/homebrew/bin/deno
  Browsers:
    Brave Browser: 144.1.86.142
    Chrome: 147.0.7727.56
    Firefox: 149.0
    Safari: 26.3
  npmPackages:
    @vitejs/plugin-react: 6.0.1 => 6.0.1
    vite: 8.0.1 => 8.0.1
```

### Used Package Manager

npm


Discussion:
The problem seems to be the following:

`if (rafId == null) { rafId = ... }` is first minified to `rafId == null && (rafId = ...)`:

https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/minimize_if_statement.rs#L25-L26

Then `rafId == null && (rafId = ...)` is minified to `rafId ??= ...`:

https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/remove_unused_expression.rs#L126
https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/remove_unused_expression.rs#L154-L157

Because we don't mark `rafId` as read from, it's only considered written to (default assignment behavior, even though `??=` both reads from and conditionally writes to `rafId`). This means that there are no read references to `rafId` (`rafId ??= ...` is the only part reading it, but as mentioned it's not treated as such), so it's considered unused and the assignment will be removed (meaning that we remove everything to the left of the `=` symbol) by `remove_unused_assignment_expr`:

https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/remove_unused_expression.rs#L641-L644

So we're left with the right side of the assignment (in this case, ```requestAnimationFrame(()=>{console.log(`callback`)})```).

## Fix 1

We can remove the `if` block for the second step (converting to `??=`):

```diff
diff --git a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
index fd297230a..b651afbff 100644
--- a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
+++ b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
@@ -136,27 +136,6 @@ impl<'a> PeepholeOptimizations {
                             None
                         };
                         if let Some(new_left_hand_expr) = new_left_hand_expr {
-                            if ctx.supports_feature(ESFeature::ES2021LogicalAssignmentOperators)
-                                    && let Expression::AssignmentExpression(assignment_expr) =
-                                        logical_right
-                                    && assignment_expr.operator == AssignmentOperator::Assign
-                                    && Self::has_no_side_effect_for_evaluation_same_target(
-                                        &assignment_expr.left,
-                                        new_left_hand_expr,
-                                        ctx,
-                                    )
-                                    // Don't transform `x.y != null || (x = {}, x.y = 3)` to `x.y ??= (x = {}, 3)` because
-                                    // `??=` evaluates `x.y` (capturing `x`) before the RHS reassigns `x`.
-                                    // https://github.com/oxc-project/oxc/pull/16802#discussion_r2619369597
-                                    && !Self::member_object_may_be_mutated(&assignment_expr.left, ctx)
-                            {
-                                assignment_expr.span = *logical_span;
-                                assignment_expr.operator = AssignmentOperator::LogicalNullish;
-                                *e = logical_right.take_in(ctx.ast);
-                                ctx.state.changed = true;
-                                return false;
-                            }
-
                             *e = ctx.ast.expression_logical(
                                 *logical_span,
                                 new_left_hand_expr.take_in(ctx.ast),
```

which will result in `rafId == null && (rafId = ...)` being converted to `rafId ?? (rafId = ...)`:

https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/remove_unused_expression.rs#L160-L165

which will be converted to `rafId ??= ...` by `try_compress_logical_expression_to_assignment_expression`:

https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/minimize_logical_expression.rs#L205

This assignment won't be removed, because `try_compress_logical_expression_to_assignment_expression` marks `rafId` as read:

https://github.com/oxc-project/oxc/blob/9b4d9f619e6accebe1356d0498c99cc3fece66b1/crates/oxc_minifier/src/peephole/minimize_logical_expression.rs#L247

## Fix 2

If that `if` is necessary, we could mark `rafId` as read so it won't be considered unused:

```diff
diff --git a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
index fd297230a..987a178fc 100644
--- a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
+++ b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
@@ -150,6 +150,7 @@ impl<'a> PeepholeOptimizations {
                                     // https://github.com/oxc-project/oxc/pull/16802#discussion_r2619369597
                                     && !Self::member_object_may_be_mutated(&assignment_expr.left, ctx)
                             {
+                                Self::mark_assignment_target_as_read(&assignment_expr.left, ctx);
                                 assignment_expr.span = *logical_span;
                                 assignment_expr.operator = AssignmentOperator::LogicalNullish;
                                 *e = logical_right.take_in(ctx.ast);
```



Patch:
diff --git a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
index fd297230aac5c..26a4cd561ba2d 100644
--- a/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
+++ b/crates/oxc_minifier/src/peephole/remove_unused_expression.rs
@@ -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;


Test patch:
diff --git a/crates/oxc_minifier/tests/peephole/remove_unused_expression.rs b/crates/oxc_minifier/tests/peephole/remove_unused_expression.rs
index 2af8c727487ce..b0b2b0b049537 100644
--- a/crates/oxc_minifier/tests/peephole/remove_unused_expression.rs
+++ b/crates/oxc_minifier/tests/peephole/remove_unused_expression.rs
@@ -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() {