fix(semantic): mark delete of optional members as MemberWriteTarget (#27430)

Fixes #27419

`delete a.b` marks `a` as `Read | MemberWriteTarget`. `delete a?.b` and
`delete (a.b)` did not, because `visit_unary_expression` only set the
write context when `argument.is_member_expression()`. An optional chain
is a `ChainExpression`, and parentheses wrap the member, so the check
failed and `visit_member_expression` never saw a write.

Unwrap parentheses/type assertions, and treat a chain whose element is a
member (not a call) as a delete target. Bare `delete x` is unchanged.

Rolldown reads `MemberWriteTarget` to detect writes through member
chains (rolldown/rolldown#11146).
This commit is contained in:
Bartok authored and GitHub committed 2026-10-08 09:49:47 +08:00
1 parent 408d87f628
commit 558a8908f2
2 files changed
+53 -2

No files matched your search

+21 -1
View File
@@ -2239,7 +2239,10 @@ impl<'a> Visit<'a> for SemanticBuilder<'a> {
// Set Write so `visit_member_expression` can detect it and mark MemberWriteTarget.
// Only for member expressions — `delete x` (bare identifier in sloppy mode)
// is not a property modification.
if it.operator == UnaryOperator::Delete && it.argument.is_member_expression() {
// `is_member_expression` misses `delete a?.b` (a `ChainExpression`) and
// `delete (a.b)` (a `ParenthesizedExpression`). Unwrap those so the
// member still sees the write context.
if it.operator == UnaryOperator::Delete && is_delete_member_target(&it.argument) {
self.current_reference_flags = ReferenceFlags::Write;
}
self.visit_expression(&it.argument);
@@ -2933,6 +2936,23 @@ impl<'a> Visit<'a> for SemanticBuilder<'a> {
}
}
/// `delete` only modifies a property when its argument is a member expression.
///
/// Optional chains (`delete a?.b`) are `ChainExpression`, and parentheses or
/// type assertions wrap the member (`delete (a.b)`, `delete (a as T).b`).
/// Bare `delete x` is not a property modification.
fn is_delete_member_target(expr: &Expression<'_>) -> bool {
match expr.get_inner_expression() {
Expression::ChainExpression(chain) => match &chain.expression {
ChainElement::StaticMemberExpression(_)
| ChainElement::ComputedMemberExpression(_)
| ChainElement::PrivateFieldExpression(_) => true,
ChainElement::CallExpression(_) | ChainElement::TSNonNullExpression(_) => false,
},
inner => inner.is_member_expression(),
}
}
impl<'a> SemanticBuilder<'a> {
#[inline]
fn reference_identifier(&mut self, ident: &IdentifierReference<'a>) {
@@ -1,4 +1,4 @@
use oxc_semantic::{Reference, SymbolFlags};
use oxc_semantic::{Reference, ReferenceFlags, SymbolFlags};
use crate::util::SemanticTester;
@@ -25,6 +25,37 @@ fn assert_root_and_single_local_reference_counts(
}
}
/// `delete a?.b` and `delete (a.b)` are property writes, same as `delete a.b`.
/// https://github.com/oxc-project/oxc/issues/27419
#[test]
fn test_delete_member_write_target() {
for code in [
"delete a.b",
"delete a.b.c",
"delete a?.b",
"delete a?.b.c",
"delete a.b?.c",
"delete (a.b)",
] {
let source = format!("const a = {{}}; {code};");
let source: &'static str = Box::leak(source.into_boxed_str());
let tester = SemanticTester::js(source);
let semantic = tester.build();
let scoping = semantic.scoping();
let symbol = scoping.get_root_binding("a".into()).unwrap();
let flags: Vec<_> =
scoping.get_resolved_references(symbol).map(oxc_semantic::Reference::flags).collect();
assert_eq!(flags, vec![ReferenceFlags::Read | ReferenceFlags::MemberWriteTarget], "{code}");
}
// Bare `delete` is not a property modification.
SemanticTester::js("let x; delete x;")
.with_script(true)
.has_some_symbol("x")
.has_number_of_references_where(0, |reference| reference.flags().is_member_write_target())
.test();
}
#[test]
fn test_class_simple() {
SemanticTester::js("export class Foo {};")