From da2ae4cd44118f57808f39b2a0c76562ba0c8fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 07:10:38 +0200 Subject: [PATCH] perf(hir): admit method shorthand into the closed-shape record route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_closed_shape gated the anonymous-shape record lowering (fixed-slot reads, preinstalled shape stamp, class allocation) to key:value and shorthand props. A method-shorthand prop m() {} — the same value as m: function () {} for everything except super — fell to the Expr::Object route, where the literal's own field reads degrade to by-name dispatch and each this-method needs a post-construction patch. Mini, 3-prop literal: m: function(){ this.a } 44 ns; m(){ 1 } 114 ns; m(){ this.a } 234 ns; two methods 461 ns (node 5-17). Admit static-key, non-async, non-generator, super-free method shorthand: the shared lower_method_prop lowers the body, and the closure is taken as a dynamic-this value (captures_this: false, no enclosing_class), exactly the function-expression spelling, so t.m() and f.call(other) bind the call receiver per spec and no patch exists to get wrong. super is detected by an swc Visit over the whole body (nested functions and classes included): with no object home slot on the stack, super.x would otherwise fall to class-super lowering. Directly exported method literals keep the seeded IIFE route that imported-object capabilities key on. The #8793 routing test now pins the record form for the static case (source-ordered record fields, dynamic-this closure argument) and keeps its super/computed fail-closed assertions. Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p --- crates/perry-hir/src/lower/expr_object.rs | 112 +++++++++++++++++++++- crates/perry-hir/src/lower/tests.rs | 35 +++++-- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index dee4cf761f..dcd183560b 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -668,11 +668,58 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R } } ast::Prop::Shorthand(_) => {} + // Method shorthand `m() { … }` with a static key lowers exactly + // like `m: function () { … }` (dynamic `this`, a plain + // pointer-bearing field slot), so it joins the closed-shape + // record route instead of falling to the by-name/shape-cache + // `Expr::Object` path whose field READS degrade to by-name + // dispatch (114–461 ns per literal on a 3-prop object vs 44 ns + // for the function-expression spelling). What is NOT admitted: + // `super` (needs the object home slot the IIFE route + // provides), async/generator methods (their own lowering + // shapes), computed keys, and `__proto__`. + ast::Prop::Method(method) => { + if is_noncomputed_proto_key(&method.key) { + return false; + } + match &method.key { + ast::PropName::Ident(_) | ast::PropName::Str(_) | ast::PropName::Num(_) => {} + _ => return false, + } + if method.function.is_async + || method.function.is_generator + || function_body_uses_super(&method.function) + { + return false; + } + } _ => return false, } } true } + + /// Conservative: any `super.x` / `super[x]` / `super(...)` anywhere in the + /// body — nested functions and classes included — keeps the method on the + /// home-object-carrying route. + fn function_body_uses_super(function: &ast::Function) -> bool { + use swc_ecma_visit::{Visit, VisitWith}; + struct Finder(bool); + impl Visit for Finder { + fn visit_super_prop_expr(&mut self, _: &ast::SuperPropExpr) { + self.0 = true; + } + fn visit_callee(&mut self, callee: &ast::Callee) { + if matches!(callee, ast::Callee::Super(_)) { + self.0 = true; + } + callee.visit_children_with(self); + } + } + let mut finder = Finder(false); + function.visit_with(&mut finder); + finder.0 + } // #6812 (w16): `{}` — the builder-pattern seed — lowers to `new // __AnonShape_()`, a unique 0-field shape-only class per // source site, instead of the legacy class-0 empty object. See @@ -695,7 +742,15 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R cap_args_appended: 0, }); } - if is_closed_shape(obj) { + // Directly exported method literals stay on the seeded IIFE route below: + // a consumer module's guarded direct call on an imported object keys on + // the producer's shape-only seed class and slot order, and this routing + // is what that capability was built against. + let exported_method_literal = prefer_exported_method_shape_seed + && obj.props.iter().any(|prop| { + matches!(prop, ast::PropOrSpread::Prop(p) if matches!(p.as_ref(), ast::Prop::Method(_))) + }); + if is_closed_shape(obj) && !exported_method_literal { let mut fields: Vec<(String, Type, Expr)> = Vec::new(); let mut bail = false; let mut seen = std::collections::HashSet::new(); @@ -783,6 +838,61 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R }; fields.push((name, ty, value)); } + ast::Prop::Method(method) => { + // `is_closed_shape` admitted only static-key, non-async, + // non-generator, super-free methods. Lower the body with + // the shared method lowering, then take the closure as a + // dynamic-`this` value — identical to the function- + // expression spelling — so no post-construction `this` + // patch is needed and `t.m()` / `f.call(other)` both bind + // the call receiver, as the spec says. + let Some((mkey, value_expr, _uses_this)) = lower_method_prop(ctx, method)? + else { + bail = true; + break; + }; + let MethodKeyKind::Static(key) = mkey else { + bail = true; + break; + }; + if !seen.insert(key.clone()) { + bail = true; + break; + } + let value = match value_expr { + Expr::Closure { + func_id, + params, + return_type, + body, + captures, + mutable_captures, + captures_this: _, + captures_new_target, + enclosing_class: _, + is_arrow, + is_async, + is_generator, + is_strict, + } => Expr::Closure { + func_id, + params, + return_type, + body, + captures, + mutable_captures, + captures_this: false, + captures_new_target, + enclosing_class: None, + is_arrow, + is_async, + is_generator, + is_strict, + }, + other => other, + }; + fields.push((key, Type::Any, value)); + } _ => unreachable!(), } } diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 9f29aacd44..a317c71543 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -163,23 +163,44 @@ const computed = { [key]() { return 1; } }; .unwrap_or_else(|| panic!("missing init for {name}")) }; - let Expr::Object(props) = local_init("fast") else { + // A static-key, super-free method literal is a closed-shape RECORD: it + // lowers to the anonymous-shape class allocation (fixed-slot reads, no + // builder IIFE), with each method carried as a dynamic-`this` closure + // argument — the function-expression spelling's semantics — so no + // post-construction `this` patch exists to get wrong. + let Expr::New { + class_name, args, .. + } = local_init("fast") + else { panic!( - "static method literal should be a direct object: {:#?}", + "static method literal should be a closed-shape record allocation: {:#?}", hir.init ); }; + assert!( + class_name.starts_with("__AnonShape_"), + "record class expected, got {class_name}" + ); + let record = hir + .classes + .iter() + .find(|class| &class.name == class_name) + .unwrap_or_else(|| panic!("record class {class_name} must be synthesized")); assert_eq!( - props + record + .fields .iter() - .map(|(key, _)| key.as_str()) + .map(|field| field.name.as_str()) .collect::>(), - ["plain", "captured", "dynamicThis"] + ["plain", "captured", "dynamicThis"], + "field order must follow source order" ); + assert_eq!(args.len(), 3); assert!(matches!( - &props[2].1, + &args[2], Expr::Closure { - captures_this: true, + captures_this: false, + enclosing_class: None, .. } ));