feat(mangler): deduplicate private accessor names (#25601)

## Summary

Fix private-name mangling for `get #x()` / `set #x(value)` accessor pairs.

JavaScript treats an accessor pair as one private name, but the mangler previously assigned a candidate per element before collecting into a name-keyed map. The setter overwrote the getter’s mapping, while still consuming an extra candidate.

### Before

```js
class Outer {
  get #x() {}
  set #x(value) {}
  #longName;
  nested() {
    return class {
      #nestedName;
    };
  }
}
```

```js
class Outer {
  get #t() {}
  set #t(e) {}
  #n;
  nested() {
    return class {
      #r;
    };
  }
}
```

### After

```js
class Outer {
  get #e() {}
  set #e(e) {}
  #t;
  nested() {
    return class {
      #n;
    };
  }
}
```

## Implementation

- Deduplicate private names during map construction with `HashMap::entry`.
- Derive nested-class offsets from already-built lexical parent mappings, preserving collision avoidance.
- Pre-size mappings from the number of private elements.
This commit is contained in:
camc314 committed 2026-08-14 00:44:46 +00:00
1 parent e82495ba54
commit 4922141d0a
4 files changed
+86 -59

No files matched your search

+41 -51
View File
@@ -1,10 +1,13 @@
use std::iter::{self, repeat_with};
use std::{
collections::hash_map::Entry,
iter::{self, repeat_with},
};
use itertools::Itertools;
use keep_names::collect_name_symbols;
use oxc_index::IndexVec;
use oxc_syntax::class::ClassId;
use rustc_hash::{FxHashMap, FxHashSet};
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use base54::base54;
use oxc_allocator::{Allocator, ArenaHashSet, ArenaVec, BitSet};
@@ -414,57 +417,44 @@ impl<'t> Mangler<'t> {
) -> IndexVec<ClassId, FxHashMap<String, CompactStr>> {
let classes = semantic.classes();
let private_member_count: IndexVec<ClassId, usize> = classes
.elements
.iter()
.map(|class_elements| {
class_elements.iter().filter(|element| element.is_private).count()
})
.collect();
let parent_private_member_count: IndexVec<ClassId, usize> = classes
.declarations
.iter_enumerated()
.map(|(class_id, _)| {
classes
.ancestors(class_id)
.skip(1)
.map(|id| private_member_count[id])
.sum::<usize>()
})
.collect();
let mut class_private_mappings: IndexVec<ClassId, FxHashMap<String, CompactStr>> =
IndexVec::with_capacity(classes.len());
for (class_id, class_elements) in classes.elements.iter_enumerated() {
// Nested classes are declared after their lexical parents, so their mappings have
// already been collected.
let parent_private_member_count = classes
.ancestors(class_id)
.skip(1)
.map(|id| class_private_mappings[id].len())
.sum::<usize>();
assert!(
u32::try_from(class_elements.len() + parent_private_member_count).is_ok(),
"too many class elements"
);
classes
.elements
.iter_enumerated()
.map(|(class_id, class_elements)| {
let parent_private_member_count = parent_private_member_count[class_id];
assert!(
u32::try_from(class_elements.len() + parent_private_member_count).is_ok(),
"too many class elements"
let private_member_count =
class_elements.iter().filter(|element| element.is_private).count();
let mut mappings =
FxHashMap::with_capacity_and_hasher(private_member_count, FxBuildHasher);
for element in class_elements.iter().filter(|element| element.is_private) {
let next_index = mappings.len();
let Entry::Vacant(entry) = mappings.entry(element.name.to_string()) else {
continue;
};
#[expect(clippy::cast_possible_truncation, reason = "checked above with assert")]
let mangled = CompactStr::new(
// Avoid reusing the same mangled name in parent classes.
// We can improve this by reusing names that are not used in child classes,
// but nesting a class inside another class is not common
// and that would require liveness analysis.
base54((parent_private_member_count + next_index) as u32).as_str(),
);
class_elements
.iter()
.filter_map(|element| {
if element.is_private { Some(element.name.to_string()) } else { None }
})
.enumerate()
.map(|(i, name)| {
#[expect(
clippy::cast_possible_truncation,
reason = "checked above with assert"
)]
let mangled = CompactStr::new(
// Avoid reusing the same mangled name in parent classes.
// We can improve this by reusing names that are not used in child classes,
// but nesting a class inside another class is not common
// and that would require liveness analysis.
base54((parent_private_member_count + i) as u32).as_str(),
);
(name, mangled)
})
.collect::<FxHashMap<_, _>>()
})
.collect()
entry.insert(mangled);
}
class_private_mappings.push(mappings);
}
class_private_mappings
}
}
+3
View File
@@ -195,6 +195,9 @@ fn private_member_mangling() {
"class Foo { #method() { return 1; } publicMethod() { return this.#method(); } }",
"class Foo { #field; #method() { return this.#field; } get() { return this.#method(); } }",
"class Foo { #x; check() { return #x in this; } }",
"class Outer { get #x() {} set #x(value) {} #longName; nested() { return class { #nestedName; }; } }",
"class Outer { get #x() {} #longName; nested() { return class { #nestedName; }; } }",
"class Outer { set #x(value) {} #longName; nested() { return class { #nestedName; }; } }",
// Nested classes
"class Outer { #outerField = 1; inner() { return class Inner { #innerField = 2; get() { return this.#innerField; } }; } }",
"class Outer { #shared = 1; getInner() { let self = this; return class { method() { return self.#shared; } }; } }",
@@ -47,6 +47,40 @@ class Foo {
}
}
class Outer { get #x() {} set #x(value) {} #longName; nested() { return class { #nestedName; }; } }
class Outer {
get #e() {}
set #e(e) {}
#t;
nested() {
return class {
#n;
};
}
}
class Outer { get #x() {} #longName; nested() { return class { #nestedName; }; } }
class Outer {
get #e() {}
#t;
nested() {
return class {
#n;
};
}
}
class Outer { set #x(value) {} #longName; nested() { return class { #nestedName; }; } }
class Outer {
set #e(e) {}
#t;
nested() {
return class {
#n;
};
}
}
class Outer { #outerField = 1; inner() { return class Inner { #innerField = 2; get() { return this.#innerField; } }; } }
class Outer {
#e = 1;
@@ -1,8 +1,8 @@
checker.ts:
file size: 2922154 # 2.92 MB
sys allocs: 152
sys allocs: 150
sys reallocs: 5
sys deallocs: 152
sys deallocs: 150
sys alloc bytes: 14996600 # 15.00 MB
sys peak growth: 10169560 # 10.17 MB
arena allocs: 148428
@@ -11,9 +11,9 @@ checker.ts:
App.tsx:
file size: 415342 # 415.34 kB
sys allocs: 63
sys allocs: 61
sys reallocs: 8
sys deallocs: 63
sys deallocs: 61
sys alloc bytes: 2128555 # 2.13 MB
sys peak growth: 1612427 # 1.61 MB
arena allocs: 16046
@@ -33,9 +33,9 @@ RadixUIAdoptionSection.jsx:
pdf.mjs:
file size: 567296 # 567.30 kB
sys allocs: 2467
sys allocs: 2427
sys reallocs: 205
sys deallocs: 2467
sys deallocs: 2427
sys alloc bytes: 5334247 # 5.33 MB
sys peak growth: 3693355 # 3.69 MB
arena allocs: 45881
@@ -66,9 +66,9 @@ binder.ts:
kitchen-sink.tsx:
file size: 732898 # 732.90 kB
sys allocs: 1858
sys allocs: 1854
sys reallocs: 174
sys deallocs: 1858
sys deallocs: 1854
sys alloc bytes: 5307512 # 5.31 MB
sys peak growth: 3707425 # 3.71 MB
arena allocs: 66771