Skip to content

fix(minifier): fold optional chains by base nullishness#22236

Merged
Dunqing merged 13 commits into
oxc-project:mainfrom
TheAlexLichter:fix/optional-chain-non-nullish-fold
May 18, 2026
Merged

fix(minifier): fold optional chains by base nullishness#22236
Dunqing merged 13 commits into
oxc-project:mainfrom
TheAlexLichter:fix/optional-chain-non-nullish-fold

Conversation

@TheAlexLichter

@TheAlexLichter TheAlexLichter commented May 7, 2026

Copy link
Copy Markdown
Member

fold_chain_expr only operated on the outermost element of a ChainExpression and only handled the nullish case, leaving two gaps:

The implemented fix rewrites the fold to walk inward to the deepest (= leftmost in source) optional and act there. A small ChainFoldResult enum encodes the three outcomes:

  • None: no fold applies.
  • Flipped: non-nullish base; optional flag was set to false. Caller unwraps the ChainExpression if no other optionals remain.
  • Collapse { base, has_side_effects }: nullish base; the chain short-circuits to void 0. The base is taken out so its side effects can be preserved via a sequence expression.

Cases now covered

Input Output
null?.foo void 0 (worked before)
null?.foo.bar void 0 (new — nested nullish)
null?.foo() void 0 (new)
(foo(), null)?.bar.baz (foo(), void 0) (new, side effects preserved)
({})?.foo ({}).foo (new — #21923)
(\"\")?.foo (\"\").foo (new)
[]?.foo.bar [].foo.bar (new, nested non-nullish)
(() => 0)?.() 0 (chains with IIFE inliner)
({})?.foo?.bar ({}).foo?.bar (inner flips, outer keeps the chain wrapped)
Number?.POSITIVE_INFINITY?.foo Number?.POSITIVE_INFINITY.foo
b?.foo, foo()?.bar, new Foo()?.bar unchanged (unknown bases)

Closes #21923

@codspeed-hq

codspeed-hq Bot commented May 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 44 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing TheAlexLichter:fix/optional-chain-non-nullish-fold (10a25f9) with main (e6090e7)

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.

Comment thread crates/oxc_minifier/src/peephole/fold_constants.rs Outdated
@armano2

armano2 commented May 7, 2026

Copy link
Copy Markdown
Contributor

what about handling nested cases like:

[]?.foo.bar

and

null?.foo.bar

Comment thread crates/oxc_minifier/tests/peephole/fold_constants.rs Outdated
@TheAlexLichter

TheAlexLichter commented May 7, 2026

Copy link
Copy Markdown
Member Author

Note that this PR is still in draft, but good call. Updated, so the fold now walks inward to the deepest (= leftmost in source) optional and operates there.

Both examples are handled:

  • null?.foo.barvoid 0
  • []?.foo.bar[].foo.bar

Plus the side-effect-preserving variant: (foo(), null)?.bar.baz(foo(), void 0).

@TheAlexLichter TheAlexLichter changed the title fix(minifier): drop optional chain on statically non-nullish base fix(minifier): fold optional chains by base nullishness May 7, 2026
@TheAlexLichter TheAlexLichter marked this pull request as ready for review May 7, 2026 21:42
@TheAlexLichter TheAlexLichter requested a review from armano2 May 7, 2026 21:42
@camc314 camc314 added the A-minifier Area - Minifier label May 8, 2026
@Dunqing Dunqing requested review from Dunqing and sapphi-red and removed request for armano2 May 8, 2026 08:58
@Dunqing Dunqing self-assigned this May 8, 2026
Comment thread crates/oxc_minifier/src/peephole/fold_constants.rs Outdated
@TheAlexLichter TheAlexLichter requested a review from sapphi-red May 14, 2026 07:54

@sapphi-red sapphi-red left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM but I would prefer @Dunqing to also have a look

TheAlexLichter and others added 13 commits May 18, 2026 10:56
Optional chains whose outermost element's base is statically non-nullish
were left untouched, leaving redundant `?.` accessors in the minified
output. For example `({})?.foo` is equivalent to `({}).foo` because the
object literal is never null/undefined.

Add a non-nullish branch to `fold_chain_expr`: when the base resolves to
`Number`/`String`/`Boolean`/`BigInt`/`Object`, flip the outermost
optional flag to `false` and unwrap the `ChainExpression` if no nested
optionals remain. A small `chain_element_has_optional` helper walks the
tree to keep the wrapper in place when an inner `?.` survives (e.g.
`Number?.POSITIVE_INFINITY?.foo` keeps the inner `?.`).

Scope is the outermost-element case, mirroring the existing nullish
branch. Multi-level chains where the optional is not the outermost
element (e.g. `a?.b.c`) are not handled here and remain a follow-up.

Closes oxc-project#21923
Generalise `fold_chain_expr` to walk inward through `.object` / `.callee`
and operate at the deepest (= leftmost in source) optional, not just the
outermost element. This fixes nested cases that previously kept their
`?.`:

- `null?.foo.bar` -> `void 0` (collapse — short-circuit at the inner
  `?.foo` skips the outer `.bar`)
- `[]?.foo.bar`   -> `[].foo.bar` (drop the inner `?.`; no other
  optionals remain so unwrap the `ChainExpression`)
- `(foo(), null)?.bar.baz` -> `(foo(), void 0)` (side effects on the
  base preserved via a sequence)

The walk returns one of three outcomes via a small `ChainFoldResult`
enum: `None`, `Flipped`, or `Collapse { base, has_side_effects }`. The
existing nullish/non-nullish logic moves into a single
`try_fold_at_optional` helper that's reused at every chain level.

Reported by armano2 on oxc-project#21923.
Address review notes:

- Extract `is_known_non_nullish` to name the `ValueType` policy. The
  transform's correctness depends on that exact set, so the predicate is
  worth a name to make accidental weakening (e.g. adding `Undetermined`)
  more visible.
- Dedupe the recurse-then-check pattern between
  `try_fold_chain_at_element` and `try_fold_chain_at_expr` via a local
  `recurse_then_fold!` macro. The four match arms in each function
  collapse to one line each.
- Document the iteration interaction with `substitute_chain_expression`:
  nested `ChainExpression`s aren't descended into; the compressor
  flattens them on a later iteration, after which this fold fires.
- Take `&TraverseCtx` (not `&mut`) in `try_fold_at_optional` since it
  only reads context — silences `needless_pass_by_ref_mut`.
Replace the `recurse_then_fold!` macro with explicit per-shape helpers
(`try_fold_call_expression`, `try_fold_member_expression`) and switch
`ChainFoldResult` to `Option<ChainFoldResult>` so the recurse-then-check
shape is expressed with `or_else`/`?` instead of a macro `return`.

Also dedupes the `_has_chain_optional` walkers via
`match_member_expression!`, drops a redundant `&mut` on `TraverseCtx`,
and adds a regression test pinning the nested-`ChainExpression`
flattening behaviour.
After the prior `is_null() || is_undefined()` short-circuit, the only
remaining `ValueType` variants are the concrete types and `Undetermined`,
so `!ty.is_undetermined()` expresses the actual gate ("we know the
concrete type") more directly than enumerating each known variant.

Co-authored-by: armano2 <625469+armano2@users.noreply.github.com>
Spell out why only the outer `?.` flips: the inner stays optional
because `Identifier("Number")` resolves to `Undetermined`, while the
outer's base `Number.POSITIVE_INFINITY` has a known `value_type`.
@Dunqing Dunqing force-pushed the fix/optional-chain-non-nullish-fold branch from 3517b79 to 10a25f9 Compare May 18, 2026 02:58

@Dunqing Dunqing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great work! I just sent a commit to refactor a little bit.

@Dunqing Dunqing added the 0-merge Merge with Graphite Merge Queue label May 18, 2026
@graphite-app

graphite-app Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Merge activity

  • May 18, 3:02 AM UTC: @TheAlexLichter we removed the merge queue label because we could not find a Graphite account associated with your GitHub profile.

You must have a Graphite account in order to use the merge queue. Create an account and try again using this link

  • May 18, 3:02 AM UTC: @TheAlexLichter we removed the merge queue label because we could not find a Graphite account associated with your GitHub profile.

You must have a Graphite account in order to use the merge queue. Create an account and try again using this link

@graphite-app graphite-app Bot removed the 0-merge Merge with Graphite Merge Queue label May 18, 2026
@Dunqing Dunqing merged commit e9ec7c6 into oxc-project:main May 18, 2026
35 checks passed
camc314 pushed a commit that referenced this pull request May 18, 2026
### 🐛 Bug Fixes

- 0f26de6 ecmascript: Resolve identifier value type via tracked
constants (#22234) (Alexander Lichter)
- c27a8cf minifier: Normalize `{ x: x }` shorthand so adjacent-if merge
is idempotent (#22401) (Dunqing)
- e431a0e parser: Break extends clause loop on fatal error (#22517)
(Boshen)
- e9ec7c6 minifier: Fold optional chains by base nullishness (#22236)
(Alexander Lichter)
- e6090e7 transformer: Keep enum IIFE when a non-inlinable value
reference remains (#22501) (Dunqing)
- 931b7d6 transformer: Inline const enum members through type-cast
wrappers (#22500) (Dunqing)
- b9615b2 codegen: Preserve string quotes in require() calls during
minification (#22475) (zennnnnnn11)
- c73c159 transformer/async-to-generator: Reparent parameter initializer
scopes (#22507) (camc314)
- ecfd3ca transformer/async-to-generator: Move only parameter bindings
(#22503) (camc314)
- 3ce3431 transformer/explicit-resource-managment: Preserve shadowed
for-head block (#22451) (camc314)

### ⚡ Performance

- ce92c6c semantic: `#[inline]` `Scoping::get_binding` (#22414)
(Dunqing)
- 98be95c regular_expression: Track regex flags via bitflags (#22427)
(Boshen)
- dbbc059 jsdoc: Skip should_attach_jsdoc when no remaining comments
(#22409) (Boshen)
- 217d7d8 minifier: Index `SymbolValues` by `SymbolId` (#22441)
(Dunqing)
- d782b78 minifier: Use BitSet for LiveUsageCollector live references
(#22425) (Boshen)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

minifier: remove unnecessary optional chaining expressions

5 participants