diff --git a/changelog.d/9104-named-class-static-arrow.md b/changelog.d/9104-named-class-static-arrow.md new file mode 100644 index 0000000000..130b220b25 --- /dev/null +++ b/changelog.d/9104-named-class-static-arrow.md @@ -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. diff --git a/crates/perry-hir/src/lower/expr_member/private_guard.rs b/crates/perry-hir/src/lower/expr_member/private_guard.rs index fb3211b552..ea5d747281 100644 --- a/crates/perry-hir/src/lower/expr_member/private_guard.rs +++ b/crates/perry-hir/src/lower/expr_member/private_guard.rs @@ -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) diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 9f29aacd44..642779549c 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -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. diff --git a/test-files/test_gap_9104_named_class_expr_static_self_capture.ts b/test-files/test_gap_9104_named_class_expr_static_self_capture.ts new file mode 100644 index 0000000000..6a46518212 --- /dev/null +++ b/test-files/test_gap_9104_named_class_expr_static_self_capture.ts @@ -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()]);