From 7a0d63a99469021bfbca85cf87e0e393695fca3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 21 Sep 2026 18:06:24 +0200 Subject: [PATCH] fix(hir): preserve computed field key scope (#10889) --- .../10889-computed-field-key-captures.md | 4 ++ crates/perry-hir/src/ir/decl.rs | 8 ++-- crates/perry-hir/src/lower/tests.rs | 39 +++++++++++++++++++ .../src/lower_decl/class_captures.rs | 26 +++++-------- 4 files changed, 56 insertions(+), 21 deletions(-) create mode 100644 changelog.d/10889-computed-field-key-captures.md diff --git a/changelog.d/10889-computed-field-key-captures.md b/changelog.d/10889-computed-field-key-captures.md new file mode 100644 index 0000000000..01fc4054a6 --- /dev/null +++ b/changelog.d/10889-computed-field-key-captures.md @@ -0,0 +1,4 @@ +Fixed computed instance-field keys in nested and CommonJS-wrapped classes being +rewritten to unbound constructor capture parameters. Symbol-keyed fields now +retain the PropertyKey resolved at class definition time, allowing undici's +pool state to initialize under the symbols used by its request path. diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index 30a4490eb7..1ca622a24b 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -420,10 +420,10 @@ pub struct ClassComputedMember { #[derive(Debug, Clone)] pub struct ClassField { pub name: String, - /// When `Some`, this field's key is the lowered expression evaluated at - /// construction time (e.g. `[Symbol.for("k")]` or `[Parent.Symbol.X]`). - /// `name` is then a synthetic placeholder used only for HIR identity — - /// runtime property writes go through `IndexSet` with this expression. + /// When `Some`, this field's key is the lowered expression evaluated once + /// during ClassDefinitionEvaluation (e.g. `[Symbol.for("k")]` or + /// `[Parent.Symbol.X]`). `name` identifies the hidden class slot holding + /// the resolved PropertyKey; construction reuses that stored key. pub key_expr: Option, pub ty: Type, pub init: Option, diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 7b6f42c592..653132c86a 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -15,6 +15,45 @@ fn make_ctx() -> LoweringContext { LoweringContext::new("test.ts") } +#[test] +fn a_computed_instance_field_key_is_not_a_constructor_capture() { + let source = r#" + function make() { + const items = Symbol("items"); + const payload = { ok: true }; + class Base { + [items] = []; + getPayload() { return payload; } + } + return Base; + } + "#; + let module = + perry_parser::parse_typescript(source, "computed-field-key.ts").expect("source parses"); + let hir = super::lower_module(&module, "computed-field-key", "computed-field-key.ts") + .expect("source lowers"); + let class = hir + .classes + .iter() + .find(|class| class.name == "Base") + .expect("nested class is lowered"); + + assert_eq!( + class + .fields + .iter() + .filter(|field| field.name.starts_with("__perry_cap_")) + .count(), + 1, + "the method value is captured, but the definition-time key is not" + ); + assert!(class.fields[0].key_expr.is_some()); + assert!( + class.constructor.is_some(), + "the unrelated method capture keeps a synthesized constructor" + ); +} + mod instanceof_rhs; mod literal_shape; diff --git a/crates/perry-hir/src/lower_decl/class_captures.rs b/crates/perry-hir/src/lower_decl/class_captures.rs index 343fac4676..52f23622b6 100644 --- a/crates/perry-hir/src/lower_decl/class_captures.rs +++ b/crates/perry-hir/src/lower_decl/class_captures.rs @@ -68,7 +68,13 @@ pub fn synthesize_class_captures( // Without this, `LocalGet(outer_id)` inside a field's init expression // would read a non-existent local in the ctor's scope when // `apply_field_initializers_recursive` lowers the initializer. - // Collect refs from both the init expr and the computed key_expr. + // Collect refs from initializer expressions. Computed field keys are + // deliberately excluded: ClassDefinitionEvaluation resolves them in the + // enclosing scope and stores the resulting PropertyKey in a hidden class + // slot. They do not execute in the constructor and therefore must not be + // rewritten to constructor-local capture parameters. Doing so leaves the + // definition-site StaticFieldSet reading an unbound synthetic parameter; + // undici's PoolBase then installs `[kClients] = []` under the wrong key. for field in fields.iter() { if let Some(init) = &field.init { let mut refs = Vec::new(); @@ -80,16 +86,6 @@ pub fn synthesize_class_captures( } } } - if let Some(key) = &field.key_expr { - let mut refs = Vec::new(); - let mut visited = std::collections::HashSet::new(); - crate::analysis::collect_local_refs_expr(key, &mut refs, &mut visited); - for id in refs { - if outer_scope_ids.contains(&id) && !module_level_ids.contains(&id) { - union_captures.insert(id); - } - } - } } // Inherited captures: if this class extends a parent that registered // captures, the parent's instance methods read from @@ -583,9 +579,8 @@ pub fn synthesize_class_captures( } *constructor = Some(ctor); - // Issue #740: rewrite field initializers and computed-key - // expressions using the same `ctor_id_map`. Field initializers - // are lowered inside the constructor body by + // Issue #740: rewrite field initializers using the same `ctor_id_map`. + // Field initializers are lowered inside the constructor body by // `apply_field_initializers_recursive`, so `LocalGet(outer_id)` // inside a field's init must be rewritten to read the fresh // ctor-local param that holds the captured value (synthesized @@ -595,9 +590,6 @@ pub fn synthesize_class_captures( if let Some(init) = field.init.as_mut() { crate::analysis::remap_local_ids_in_expr(init, &ctor_id_map); } - if let Some(key) = field.key_expr.as_mut() { - crate::analysis::remap_local_ids_in_expr(key, &ctor_id_map); - } } // 4. Register so `Expr::New { class_name }` appends