fix(minifier): fold optional chains by base nullishness#22236
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
|
what about handling nested cases like: and |
|
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:
Plus the side-effect-preserving variant: |
sapphi-red
left a comment
There was a problem hiding this comment.
LGTM but I would prefer @Dunqing to also have a look
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`.
3517b79 to
10a25f9
Compare
Merge activity
You must have a Graphite account in order to use the merge queue. Create an account and try again using this link
You must have a Graphite account in order to use the merge queue. Create an account and try again using this link |
### 🐛 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)
fold_chain_expronly operated on the outermost element of aChainExpressionand only handled the nullish case, leaving two gaps:({})?.foowere not folded. This isn't fixed by minifier: remove unnecessary optional chaining expressions #21923null?.foo.barand[]?.foo.barkept their?.because the optional is on an inner element, not the outermost. This it the focus for this PR.The implemented fix rewrites the fold to walk inward to the deepest (= leftmost in source) optional and act there. A small
ChainFoldResultenum encodes the three outcomes:None: no fold applies.Flipped: non-nullish base;optionalflag was set tofalse. Caller unwraps theChainExpressionif no other optionals remain.Collapse { base, has_side_effects }: nullish base; the chain short-circuits tovoid 0. The base is taken out so its side effects can be preserved via a sequence expression.Cases now covered
null?.foovoid 0(worked before)null?.foo.barvoid 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?.fooNumber?.POSITIVE_INFINITY.foob?.foo,foo()?.bar,new Foo()?.barCloses #21923