Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/9104-named-class-static-arrow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fixed named class expressions whose static methods update a static private
field through the class's inner name from inside a nested arrow. The write side
of `c.#field++` now preserves the same lexical class-evaluation brand as the
read side instead of falling back to the arrow's absent `this` binding.
8 changes: 8 additions & 0 deletions crates/perry-hir/src/lower/expr_member/private_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ pub(crate) fn private_storage_property(ctx: &LoweringContext, field_name: &str)
}

pub(crate) fn is_class_expr_self_binding(ctx: &LoweringContext, object: &Expr) -> bool {
let object = match object {
// A private update (`c.#v++`) applies a read guard and then a write
// guard to the same receiver. Preserve the lexical-brand-owner bit
// through the inner guard so the outer guard does not fall back to the
// surrounding function's `this` (which is absent in a nested arrow).
Expr::PrivateGuard { object, .. } => object.as_ref(),
other => other,
};
matches!(
object,
Expr::LocalGet(id)
Expand Down
35 changes: 35 additions & 0 deletions crates/perry-hir/src/lower/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,41 @@ fn named_class_expr_self_new_records_appended_capture_provenance() {
);
}

/// A private update wraps its receiver twice: once for the read and once for
/// the write. Both guards must preserve the named class expression's lexical
/// self binding, including when that binding is captured by a nested arrow.
#[test]
fn named_class_expr_static_private_update_in_arrow_keeps_lexical_brand_owner() {
let source = r#"
const make = () => class c {
static #v = 0;
static f() { return (() => { c.#v++; return c.#v; })(); }
};
"#;
let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses");
let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers");
let method = hir
.classes
.iter()
.find(|class| class.name.starts_with("c__class_expr_"))
.expect("named class expression is lowered")
.static_methods
.iter()
.find(|method| method.name == "f")
.expect("static f method is lowered");
let body = format!("{:#?}", method.body);

assert_eq!(
body.matches("receiver_is_brand_owner: true").count(),
3,
"the update's read/write guards and the following read must identify the lexical class owner: {body}"
);
assert!(
!body.contains("receiver_is_brand_owner: false"),
"both guards around the private update must retain the lexical class owner: {body}"
);
}

/// A sibling class declaration is already a known lexical binding while an
/// earlier class method is lowered, even though its registry entry is emitted
/// later. The unresolved-constructor guard must preserve that forward binding.
Expand Down
31 changes: 31 additions & 0 deletions test-files/test_gap_9104_named_class_expr_static_self_capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// A named class expression's inner binding remains visible from a static
// method even when the class value is stored under a different outer name.
const C = class Named {
static f() {
return Named === C;
}
};

console.log(C.f());

// Nested arrows close over the same per-evaluation self binding that the
// static method itself uses. Each evaluation keeps its own private state.
const make = () => class c {
static #v = 0;

static self() {
return (() => c)();
}

static f() {
return (() => {
c.#v++;
return c.#v;
})();
}
};

const A = make();
const B = make();
console.log(A.self() === A, B.self() === B, A.self() !== B.self());
console.log([A.f(), A.f(), B.f()]);
Loading