fix(ecmascript): resolve identifier value type via tracked constants (#22234)

`value_type` for `Expression::Identifier` returned `Undetermined` for
any non-global identifier, **even when the binding had zero writes and a
known constant initializer**. This causes code like `slot?.()` on a `let
slot;` with all writers tree-shaken away not being folded to `void 0`,
leaving dead code in the output.

This PR changes that by checking
`GlobalContext::get_constant_value_for_reference_id` for non-global
identifier references and mapping the resulting `ConstantValue` to a
`ValueType` via a new private helper.

Once the identifier resolves to a tracked nullish value, the existing
nullish branch in `fold_chain_expr` collapses the chain to `void 0`. The
same lookup also works for the opposite case (#21923).

The lookup is gated by making sure there are no write references, so
anything that is being written anywhere keeps its `Undetermined` type.

Closes rolldown/rolldown#9281
Related (but does not resolve) #21923

---------

Co-authored-by: Dunqing <dengqing0821@gmail.com>
This commit is contained in:
Alexander LichterandDunqing authored and GitHub committed 2026-05-18 16:59:34 +08:00
1 parent 602dfd6294
commit 0f26de6dd2
7 files changed
+99 -13

No files matched your search

@@ -3,6 +3,7 @@ use std::borrow::Cow;
use num_bigint::BigInt;
use num_traits::Zero;
use super::value_type::ValueType;
use crate::{GlobalContext, ToBoolean, ToJsString, ToNumber};
#[derive(Debug, PartialEq, Clone)]
@@ -40,6 +41,17 @@ impl<'a> ConstantValue<'a> {
matches!(self, Self::Null)
}
pub fn value_type(&self) -> ValueType {
match self {
Self::Number(_) => ValueType::Number,
Self::BigInt(_) => ValueType::BigInt,
Self::String(_) => ValueType::String,
Self::Boolean(_) => ValueType::Boolean,
Self::Undefined => ValueType::Undefined,
Self::Null => ValueType::Null,
}
}
pub fn into_string(self) -> Option<Cow<'a, str>> {
match self {
Self::String(s) => Some(s),
@@ -92,7 +92,11 @@ impl<'a> DetermineValueType<'a> for Expression<'a> {
_ => ValueType::Undetermined,
}
} else {
ValueType::Undetermined
ident
.reference_id
.get()
.and_then(|reference_id| ctx.value_type_for_reference_id(reference_id))
.unwrap_or(ValueType::Undetermined)
}
}
Expression::UnaryExpression(e) => e.value_type(ctx),
+10 -1
View File
@@ -1,7 +1,7 @@
use oxc_ast::ast::{Expression, IdentifierReference};
use oxc_syntax::reference::ReferenceId;
use crate::constant_evaluation::ConstantValue;
use crate::constant_evaluation::{ConstantValue, ValueType};
pub trait GlobalContext<'a>: Sized {
/// Whether the reference is a global reference.
@@ -19,6 +19,15 @@ pub trait GlobalContext<'a>: Sized {
) -> Option<ConstantValue<'a>> {
None
}
/// The [`ValueType`] of the constant a reference resolves to, if known.
///
/// Cheaper than [`Self::get_constant_value_for_reference_id`] when only the
/// type discriminant is needed, since it avoids cloning the value
/// (relevant for `BigInt` / owned `String`).
fn value_type_for_reference_id(&self, _reference_id: ReferenceId) -> Option<ValueType> {
None
}
}
pub struct WithoutGlobalReferenceInformation;
@@ -3,7 +3,8 @@ use oxc_compat::ESFeature;
use oxc_ecmascript::{
GlobalContext,
constant_evaluation::{
ConstantEvaluation, ConstantEvaluationCtx, ConstantValue, binary_operation_evaluate_value,
ConstantEvaluation, ConstantEvaluationCtx, ConstantValue, ValueType,
binary_operation_evaluate_value,
},
side_effects::{
MayHaveSideEffects, MayHaveSideEffectsContext, PropertyReadSideEffects, is_pure_function,
@@ -33,13 +34,11 @@ impl<'a> GlobalContext<'a> for TraverseCtx<'a, MinifierState<'a>> {
&self,
reference_id: ReferenceId,
) -> Option<ConstantValue<'a>> {
self.scoping()
.get_reference(reference_id)
.symbol_id()
.and_then(|symbol_id| self.state.symbol_values.get_symbol_value(symbol_id))
.filter(|sv| sv.write_references_count == 0)
.and_then(|sv| sv.initialized_constant.as_ref())
.cloned()
self.tracked_constant_for_reference_id(reference_id).cloned()
}
fn value_type_for_reference_id(&self, reference_id: ReferenceId) -> Option<ValueType> {
self.tracked_constant_for_reference_id(reference_id).map(ConstantValue::value_type)
}
}
@@ -54,6 +53,10 @@ impl<'a> GlobalContext<'a> for &TraverseCtx<'a, MinifierState<'a>> {
) -> Option<ConstantValue<'a>> {
(*self).get_constant_value_for_reference_id(reference_id)
}
fn value_type_for_reference_id(&self, reference_id: ReferenceId) -> Option<ValueType> {
(*self).value_type_for_reference_id(reference_id)
}
}
impl<'a> GlobalContext<'a> for &mut TraverseCtx<'a, MinifierState<'a>> {
@@ -67,6 +70,10 @@ impl<'a> GlobalContext<'a> for &mut TraverseCtx<'a, MinifierState<'a>> {
) -> Option<ConstantValue<'a>> {
(**self).get_constant_value_for_reference_id(reference_id)
}
fn value_type_for_reference_id(&self, reference_id: ReferenceId) -> Option<ValueType> {
(**self).value_type_for_reference_id(reference_id)
}
}
impl<'a> MayHaveSideEffectsContext<'a> for TraverseCtx<'a, MinifierState<'a>> {
@@ -177,6 +184,18 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
ident.is_global_reference(self.scoping())
}
fn tracked_constant_for_reference_id(
&self,
reference_id: ReferenceId,
) -> Option<&ConstantValue<'a>> {
self.scoping()
.get_reference(reference_id)
.symbol_id()
.and_then(|symbol_id| self.state.symbol_values.get_symbol_value(symbol_id))
.filter(|sv| sv.write_references_count == 0)
.and_then(|sv| sv.initialized_constant.as_ref())
}
pub fn eval_binary(&self, e: &BinaryExpression<'a>) -> Option<Expression<'a>> {
if e.may_have_side_effects(self) {
None
@@ -589,3 +589,45 @@ fn drop_optional_chain_on_non_nullish_base() {
test("export const v = null?.foo.bar", "export const v = void 0");
test("export const v = []?.foo.bar", "export const v = [].foo.bar");
}
#[test]
fn fold_optional_chain_on_undefined_let_binding() {
// https://github.com/rolldown/rolldown/issues/9281
// A `let` binding with no writes is statically known to be `undefined`,
// so optional calls / member accesses on it should fold to `void 0`.
test("let slot; export function call() { slot?.() }", "export function call() {}");
test("let slot; export function call() { slot?.foo }", "export function call() {}");
test("let slot; export function call() { slot?.[foo()] }", "export function call() {}");
// A binding that is written somewhere is not nullish-known: leave it alone.
test_same(
"let slot; export function setSlot(v) { slot = v } export function call() { slot?.() }",
);
}
#[test]
fn fold_optional_chain_on_null_const_binding() {
// A `const` initialized to `null` resolves to `ValueType::Null`, so the
// optional chain folds the same way the `undefined` case does.
test("const slot = null; export function call() { slot?.() }", "export function call() {}");
test("const slot = null; export function call() { slot?.foo }", "export function call() {}");
}
#[test]
fn fold_coalesce_on_tracked_non_nullish_binding() {
// The new value_type lookup also resolves non-nullish constants, so the
// right-hand side of `??` can be dropped.
//
// Two reads + a string the inliner skips (length > 3) prevents
// `inline_identifier_reference` from short-circuiting the test by
// substituting the literal value before the coalesce fold runs.
test(
"let s = 'hello'; export function a() { return s ?? other() } export function b() { return s ?? other() }",
"let s = 'hello'; export function a() { return s } export function b() { return s }",
);
// BigInt is never inlined, so a single read is enough to exercise the
// value-type path here.
test(
"let n = 5n; export function a() { return n ?? other() } export function b() { return n ?? other() }",
"let n = 5n; export function a() { return n } export function b() { return n }",
);
}
+2 -2
View File
@@ -11,9 +11,9 @@ Original | minified | minified | gzip | gzip | Iterations | Fi
544.10 kB | 71.04 kB | 72.48 kB | 25.77 kB | 26.20 kB | 2 | lodash.js
555.77 kB | 267.41 kB | 270.13 kB | 88.02 kB | 90.80 kB | 2 | d3.js
555.77 kB | 267.40 kB | 270.13 kB | 88.00 kB | 90.80 kB | 2 | d3.js
1.01 MB | 439.37 kB | 458.89 kB | 122.05 kB | 126.71 kB | 2 | bundle.min.js
1.01 MB | 439.35 kB | 458.89 kB | 122.05 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 642.65 kB | 646.76 kB | 159.41 kB | 163.73 kB | 2 | three.js
@@ -6,7 +6,7 @@ cal.com.tsx | 1.06 MB || 16287 | 46 |
RadixUIAdoptionSection.jsx | 2.52 kB || 24 | 6 || 31 | 6
pdf.mjs | 567.30 kB || 4016 | 417 || 47474 | 7746
pdf.mjs | 567.30 kB || 4016 | 417 || 47465 | 7740
antd.js | 6.69 MB || 7624 | 1657 || 333609 | 69349