From e19daa47081786c64b514580460598921db947ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 13:40:59 +0200 Subject: [PATCH 1/3] perf(codegen): specialize branded ReadonlySet.has --- crates/perry-codegen/src/expr/mod.rs | 2 + .../src/expr/readonly_collection_tests.rs | 282 ++++++++++++++++++ .../src/lower_call/property_get/map_set.rs | 19 +- .../src/runtime_decls/strings.rs | 1 + crates/perry-codegen/src/type_analysis.rs | 2 +- .../src/type_analysis/strings.rs | 88 ++++++ crates/perry-runtime/src/set.rs | 48 ++- .../src/commands/compile/collect_modules.rs | 44 ++- .../src/commands/compile/run_pipeline.rs | 85 ++++-- .../tests/readonly_set_branded_dispatch.rs | 103 +++++++ .../tests/source_graph_export_regressions.rs | 41 +++ 11 files changed, 669 insertions(+), 46 deletions(-) create mode 100644 crates/perry-codegen/src/expr/readonly_collection_tests.rs create mode 100644 crates/perry/tests/readonly_set_branded_dispatch.rs diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 5cb84eb1b5..e5235a0ce5 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -169,6 +169,8 @@ mod call_spread_short; mod call_spread_short_tests; #[cfg(test)] mod issue7628_rooting_tests; +#[cfg(test)] +mod readonly_collection_tests; pub(crate) mod shadow_slot; #[cfg(test)] mod slice7_rooting_tests; diff --git a/crates/perry-codegen/src/expr/readonly_collection_tests.rs b/crates/perry-codegen/src/expr/readonly_collection_tests.rs new file mode 100644 index 0000000000..fcd8fff304 --- /dev/null +++ b/crates/perry-codegen/src/expr/readonly_collection_tests.rs @@ -0,0 +1,282 @@ +use crate::{compile_module, CompileOptions, ImportedClass}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt}; + +fn number_param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn has_method() -> Function { + Function { + id: 2, + name: "hasComponent".to_string(), + type_params: Vec::new(), + params: vec![number_param(1, "componentType")], + return_type: Type::Boolean, + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "componentTypeSet".to_string(), + byte_offset: 0, + }), + property: "has".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(1)], + type_args: Vec::new(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn archetype_class() -> Class { + Class { + id: 1, + name: "Archetype".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ClassField { + name: "componentTypeSet".to_string(), + key_expr: None, + ty: Type::Generic { + base: "ReadonlySet".to_string(), + type_args: vec![Type::Number], + }, + init: None, + is_private: false, + is_readonly: true, + decorators: Vec::new(), + }], + constructor: None, + methods: vec![has_method()], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn executor_class() -> Class { + Class { + id: 3, + name: "CommandExecutor".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: vec![Function { + id: 4, + name: "contains".to_string(), + type_params: Vec::new(), + params: vec![ + Param { + id: 1, + name: "archetype".to_string(), + ty: Type::Union(vec![Type::Named("Archetype".to_string()), Type::Void]), + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }, + number_param(2, "componentType"), + ], + return_type: Type::Boolean, + body: vec![Stmt::Return(Some(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "componentTypeSet".to_string(), + byte_offset: 0, + }), + property: "has".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(2)], + type_args: Vec::new(), + byte_offset: 0, + }))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +fn compile_has_ir() -> String { + let mut module = Module::new("readonly_set_field.ts"); + module.classes.push(archetype_class()); + module.classes.push(executor_class()); + String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..Default::default() + }, + ) + .expect("ReadonlySet field call compiles"), + ) + .expect("LLVM IR is UTF-8") +} + +fn imported_archetype() -> ImportedClass { + ImportedClass { + name: "Archetype".to_string(), + local_alias: None, + source_prefix: "archetype_ts".to_string(), + constructor_param_count: 0, + has_own_constructor: true, + constructor_has_rest: false, + has_instance_fields: true, + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: vec!["componentTypeSet".to_string()], + field_types: vec![Type::Generic { + base: "ReadonlySet".to_string(), + type_args: vec![Type::Number], + }], + source_class_id: Some(1), + return_shape_imports: Vec::new(), + object_literal: None, + } +} + +fn compile_imported_has_ir() -> String { + let mut module = Module::new("command_executor.ts"); + module.classes.push(executor_class()); + let mut options = CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + options.imported_classes.push(imported_archetype()); + String::from_utf8( + compile_module(&module, options).expect("imported ReadonlySet field compiles"), + ) + .expect("LLVM IR is UTF-8") +} + +fn method_ir<'a>(ir: &'a str, owner: &str, method: &str) -> &'a str { + let suffix = format!("__{owner}__{method}("); + let suffix_start = ir.find(&suffix).expect("requested method is present"); + let start = ir[..suffix_start] + .rfind("define double @perry_method_") + .expect("requested method has a definition"); + let method_and_rest = &ir[start..]; + let end = method_and_rest + .find("\n}\n") + .expect("requested method has a closing brace"); + &method_and_rest[..end + 3] +} + +#[test] +fn readonly_set_field_has_uses_branded_collection_fast_path() { + let ir = compile_has_ir(); + let method_ir = method_ir(&ir, "Archetype", "hasComponent"); + + assert!( + method_ir.contains("call double @js_readonly_set_has("), + "ReadonlySet.has must use the branded native-Set fast path with a structural-object fallback:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "the common native-Set case must not enter the full generic dispatch tower:\n{method_ir}" + ); +} + +#[test] +fn nullable_class_receiver_readonly_set_field_uses_branded_fast_path() { + let ir = compile_has_ir(); + let method_ir = method_ir(&ir, "CommandExecutor", "contains"); + + assert!( + method_ir.contains("call double @js_readonly_set_has("), + "a ReadonlySet field reached through `Archetype | undefined` must retain the branded candidate:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "the nullable owner type must not force every native Set through generic method dispatch:\n{method_ir}" + ); +} + +#[test] +fn imported_class_readonly_set_field_uses_branded_fast_path() { + let ir = compile_imported_has_ir(); + let method_ir = method_ir(&ir, "CommandExecutor", "contains"); + + assert!( + method_ir.contains("call double @js_readonly_set_has("), + "an imported class's published ReadonlySet field type must remain a branded candidate:\n{method_ir}" + ); + assert!( + !method_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "cross-module field metadata must not force native Sets through generic dispatch:\n{method_ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/property_get/map_set.rs b/crates/perry-codegen/src/lower_call/property_get/map_set.rs index 55d422ef60..6d90dee044 100644 --- a/crates/perry-codegen/src/lower_call/property_get/map_set.rs +++ b/crates/perry-codegen/src/lower_call/property_get/map_set.rs @@ -29,7 +29,9 @@ use perry_hir::Expr; use crate::expr::{lower_expr, unbox_to_i64, FnCtx}; use crate::nanbox::double_literal; use crate::rooting; -use crate::type_analysis::{is_map_expr, is_set_expr, is_url_search_params_expr}; +use crate::type_analysis::{ + is_map_expr, is_readonly_set_expr, is_set_expr, is_url_search_params_expr, +}; use crate::types::{DOUBLE, I64}; /// Map/Set methods on PropertyGet receivers. The HIR only folds @@ -42,6 +44,21 @@ pub(crate) fn try_lower_map_set_methods( property: &str, args: &[Expr], ) -> Result> { + // `ReadonlySet` is a structural interface, not a native-layout proof. + // The runtime helper brand-checks the overwhelmingly common genuine Set + // and otherwise preserves JavaScript dispatch (custom interface objects, + // proxies, and Set subclasses with overrides). + if is_readonly_set_expr(ctx, object) && property == "has" && args.len() == 1 { + return rooting::with_operands_rooted(ctx, &[object, &args[0]], |ctx, vals| { + let receiver = vals[0].clone(); + let value = vals[1].clone(); + Ok(Some(ctx.block().call( + DOUBLE, + "js_readonly_set_has", + &[(DOUBLE, &receiver), (DOUBLE, &value)], + ))) + }); + } if is_map_expr(ctx, object) { match property { "set" if args.len() == 2 => { diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 95b887d5ec..2a90e2db99 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -634,6 +634,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_set_add_f32", I64, &[I64, F32]); module.declare_function("js_set_add_bool", I64, &[I64, I32]); module.declare_function("js_set_has", I32, &[I64, DOUBLE]); + module.declare_function("js_readonly_set_has", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_set_has_string", I32, &[I64, I64]); module.declare_function("js_set_has_number", I32, &[I64, DOUBLE]); module.declare_function("js_set_has_i32", I32, &[I64, I32]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 01d70ace64..2212a195c2 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -60,7 +60,7 @@ pub(crate) use refine::{ }; pub(crate) use strings::{ class_name_extends_url_search_params, is_declared_string_expr, is_definitely_string_expr, - is_map_expr, is_set_expr, is_string_expr, is_url_search_params_expr, + is_map_expr, is_readonly_set_expr, is_set_expr, is_string_expr, is_url_search_params_expr, is_url_search_params_subclass_expr, map_static_type_args, set_static_type_args, string_proof_is_declared_only, string_value_is_runtime_guaranteed, }; diff --git a/crates/perry-codegen/src/type_analysis/strings.rs b/crates/perry-codegen/src/type_analysis/strings.rs index 70c21642cc..13231d7db8 100644 --- a/crates/perry-codegen/src/type_analysis/strings.rs +++ b/crates/perry-codegen/src/type_analysis/strings.rs @@ -38,6 +38,94 @@ pub(crate) fn is_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// True when the declared receiver type is TypeScript's structural +/// `ReadonlySet` interface. +/// +/// This is deliberately separate from [`is_set_expr`]. A `ReadonlySet` +/// annotation does not prove that the value has Perry's native `SetHeader` +/// layout: an ordinary object can implement the interface. Callers must use a +/// runtime-branded operation with a normal method-dispatch fallback. +pub(crate) fn is_readonly_set_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { + match e { + // Unlike native-layout lowering, this is only a guarded candidate: + // the runtime helper preserves generic dispatch on a brand miss. A + // declared hint therefore remains useful even for reassigned locals. + Expr::LocalGet(id) => ctx.local_type_hint(id).is_some_and(type_is_readonly_set), + Expr::PropertyGet { + object, property, .. + } => static_type_of(ctx, object).is_some_and(|owner_ty| { + type_may_declare_readonly_set_field(ctx, &owner_ty, property, 0) + }), + _ => false, + } +} + +#[inline] +fn type_is_readonly_set(ty: &HirType) -> bool { + matches!(ty, HirType::Generic { base, .. } if base == "ReadonlySet") +} + +/// Look through a nullable/union owner claim for a field declared as +/// `ReadonlySet`. This is a dispatch candidate, not a layout proof: a false +/// positive only reaches `js_readonly_set_has`, whose brand miss performs the +/// original method call. That lets `Archetype | undefined` retain the useful +/// candidate without weakening nullish, proxy, subclass, or structural-object +/// semantics. +fn type_may_declare_readonly_set_field( + ctx: &FnCtx<'_>, + owner_ty: &HirType, + property: &str, + depth: usize, +) -> bool { + if depth > 32 { + return false; + } + match owner_ty { + HirType::Union(variants) => variants.iter().any(|variant| { + !matches!(variant, HirType::Null | HirType::Void | HirType::Never) + && type_may_declare_readonly_set_field(ctx, variant, property, depth + 1) + }), + HirType::Named(name) | HirType::Generic { base: name, .. } => { + if let Some(class) = ctx.classes.get(name) { + if let Some(field) = class.fields.iter().find(|field| field.name == property) { + return type_is_readonly_set(&field.ty); + } + if let Some(parent) = class.extends_name.as_deref() { + return type_may_declare_readonly_set_field( + ctx, + &HirType::Named(parent.to_string()), + property, + depth + 1, + ); + } + } + if let Some(iface) = ctx.interfaces.get(name) { + if let Some(field) = iface.properties.iter().find(|field| field.name == property) { + return type_is_readonly_set(&field.ty); + } + if iface.extends.iter().any(|parent| { + type_may_declare_readonly_set_field(ctx, parent, property, depth + 1) + }) { + return true; + } + } + matches!( + ctx.type_aliases.get(name), + Some(HirType::Object(object)) + if object + .properties + .get(property) + .is_some_and(|field| type_is_readonly_set(&field.ty)) + ) + } + HirType::Object(object) => object + .properties + .get(property) + .is_some_and(|field| type_is_readonly_set(&field.ty)), + _ => false, + } +} + pub(crate) fn set_static_type_args<'a>(ctx: &'a FnCtx<'_>, e: &Expr) -> Option<&'a [HirType]> { match e { Expr::LocalGet(id) diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index f83afc1169..4037651a75 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -1115,14 +1115,52 @@ pub extern "C" fn js_set_has(set: *const SetHeader, value: f64) -> i32 { if set.is_null() { return 0; } + set_has_resolved(set, value) +} + +#[inline(always)] +fn set_has_resolved(set: *const SetHeader, value: f64) -> i32 { let value = normalize_zero(value); - unsafe { - if find_value_index(set, value) >= 0 { - 1 - } else { - 0 + unsafe { i32::from(find_value_index(set, value) >= 0) } +} + +/// Fast `ReadonlySet.has` that preserves TypeScript's structural semantics. +/// +/// A `ReadonlySet` annotation is not a native-layout guarantee: ordinary +/// objects, proxies, and Set subclasses can all inhabit it. A genuine +/// `GC_TYPE_SET` receiver takes the direct lookup without the generic method +/// tower. Every other receiver retains normal JavaScript `receiver.has(value)` +/// dispatch, including user overrides and the usual TypeError behavior. +#[no_mangle] +pub unsafe extern "C-unwind" fn js_readonly_set_has(receiver: f64, value: f64) -> f64 { + let receiver_value = crate::value::JSValue::from_bits(receiver.to_bits()); + if receiver_value.is_pointer() { + let raw = receiver_value.as_pointer::(); + if matches!( + crate::value::addr_class::try_read_gc_header(raw as usize), + Some(header) if header.obj_type == crate::gc::GC_TYPE_SET + ) { + return f64::from_bits( + crate::value::JSValue::bool(set_has_resolved(raw, value) != 0).bits(), + ); } } + + // The structural fallback can allocate and re-enter generated code. Root + // both operands before crossing that boundary, then pass refreshed values + // into the existing dispatcher (which establishes its own roots before + // the first collecting probe). + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_handle = scope.root_nanbox_f64(receiver); + let value_handle = scope.root_nanbox_f64(value); + let refreshed_value = value_handle.get_nanbox_f64(); + crate::object::js_native_call_method( + receiver_handle.get_nanbox_f64(), + b"has".as_ptr() as *const i8, + 3, + &refreshed_value, + 1, + ) } #[no_mangle] diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index ddaeaab8f9..aabfdf1b1e 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1011,18 +1011,40 @@ fn collect_module_one( // Process imports and update their resolved paths and module kinds for import in &mut hir_module.imports { - // Skip type-only imports — they were recorded for class-metadata flow - // (see lower.rs's #446 comment: a per-specifier `import { type Foo }` - // is preserved so Foo's class info reaches `imported_classes` for - // method dispatch) but they MUST NOT be loaded as runtime modules. - // Without this skip, `import type { StandardSchemaV1 } from - // "@standard-schema/spec"` (Effect's only `@standard-schema` use, - // a type-only reference) queued the package's V8 fallback. The - // spec ships an empty `src_exports = {}` at runtime, so any - // `something._tag` from the import binding then threw - // `TypeError: Cannot read properties of undefined (reading '_tag')` - // during Effect's module init. Refs #321, #684. + // Resolve TypeScript type-only imports for metadata, but never queue + // their target as a runtime module. The final graph may already + // contain the target through a value import elsewhere; retaining its + // canonical path here then lets run_pipeline attach that existing + // class's field/method metadata to this consumer. This is compile-time + // bookkeeping only: no init edge, binding, package capability, or V8 + // fallback is created. In particular, the `@standard-schema/spec` + // case from #684 remains erased at runtime. if import.type_only { + if let Some(alias) = ctx.package_aliases.get(import.source.as_str()).cloned() { + import.source = alias; + import.is_native = perry_hir::is_native_module(&import.source); + } + if !import.is_native { + if let Some(resolved) = cached_resolve_import_with_lexical_base( + &import.source, + entry_path, + &canonical, + ctx, + ) { + let resolved_path = resolved.canonical_path; + let kind = if resolved.kind == ModuleKind::Interpreted + && !is_in_perry_native_package(&resolved_path) + && !is_declaration_file(&resolved_path) + && aot_promotion_is_authorized(&resolved_path, ctx) + { + ModuleKind::NativeCompiled + } else { + resolved.kind + }; + import.resolved_path = Some(resolved_path.to_string_lossy().to_string()); + import.module_kind = kind; + } + } continue; } let uses_file_loader = file_loader_sources.contains(&import.source); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 2d26c29371..fbaf832649 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -3095,34 +3095,6 @@ pub fn run_with_parse_cache( if import.module_kind != perry_hir::ModuleKind::NativeCompiled { continue; } - // Issue #684: skip WHOLE-DECL type-only imports - // (`import type * as X`, `import type { Foo }`). They - // contribute zero runtime state — neither the namespace - // binding nor the named members ever appear in a - // value-position expression after type erasure. Pre-fix - // the loop below treated them like value imports and - // registered every export of the source module into - // `import_function_prefixes` / `namespace_member_prefixes`, - // which collided with later named-import registrations: - // effect's `ParseResult.ts` has both - // `import { TaggedError } from "./Data.js"` - // `import type * as Schema from "./Schema.js"` - // Schema.ts also exports `TaggedError`, so the type-only - // loop iteration registered `TaggedError → Schema_ts` - // into `import_function_prefixes`. If Schema.ts was - // processed AFTER Data.ts (HashMap iteration order is - // unstable), the Schema entry won — and top-level - // `class ParseError extends TaggedError("ParseError")` - // dispatched into Schema.ts's `TaggedError` instead of - // Data.ts's. Worse, Schema.ts is type-only so it isn't - // in `module_init_deps` either, meaning its backing - // global was still 0.0 — `js_closure_call1(0.0, ...)` - // threw `TypeError: value is not a function` during - // `ParseResult.ts__init`. Closes #684 (companion to - // #680's `module_init_deps` filter at L3234). - if import.type_only { - continue; - } let resolved_path = match &import.resolved_path { Some(p) => p, None => continue, @@ -3137,6 +3109,63 @@ pub fn run_with_parse_cache( Some(m) => sanitize_name(&m.name), None => continue, }; + // A whole-declaration `import type` contributes no runtime + // binding or init edge (#684), but a named class annotation + // may still carry useful producer-authored field metadata. + // Attach only that exact class when its defining module is + // already present in the value-reachable graph. Do not touch + // function/namespace maps, imported vars, native libraries, + // or module-init dependencies: those were the collision and + // phantom-load hazards #684 removed. + if import.type_only { + for spec in &import.specifiers { + let perry_hir::ImportSpecifier::Named { imported, local } = spec else { + continue; + }; + let key = (resolved_path_str.clone(), imported.clone()); + let Some(class) = exported_classes.get(&key) else { + continue; + }; + let origin_path = all_module_exports + .get(&resolved_path_str) + .and_then(|exports| exports.get(imported)) + .cloned() + .unwrap_or_else(|| resolved_path_str.clone()); + let effective_prefix = if origin_path != resolved_path_str { + compute_module_prefix(&origin_path, &ctx.project_root) + } else { + source_prefix.clone() + }; + let class_prefix = canonical_class_source_prefix( + class, + &class_canonical_path, + &ctx.project_root, + &effective_prefix, + ); + let local_alias = (local != &class.name).then(|| local.clone()); + let duplicate = imported_classes.iter().any(|existing| { + existing.name == class.name + && existing.local_alias.as_ref() == local_alias.as_ref() + && existing.source_prefix == class_prefix + }); + if !duplicate { + imported_classes.push(imported_class_from_hir( + class, + class_prefix, + local_alias, + proven_this_methods_for_import( + class, + &class_proven_this_methods, + ), + proven_this_methods_for_import( + class, + &class_proven_this_tower_methods, + ), + )); + } + } + continue; + } // PerryTS/storekit#1: when the import source is a package that // declares `perry.nativeLibrary` (e.g. `@perryts/storekit`), // its `.ts` source is a wrapper holding ambient `export diff --git a/crates/perry/tests/readonly_set_branded_dispatch.rs b/crates/perry/tests/readonly_set_branded_dispatch.rs new file mode 100644 index 0000000000..5fa1d9cf9d --- /dev/null +++ b/crates/perry/tests/readonly_set_branded_dispatch.rs @@ -0,0 +1,103 @@ +//! Executable semantics for the `ReadonlySet.has` branded fast path. +//! Native Sets bypass generic dispatch, while TypeScript's structural values +//! and Set subclasses retain ordinary JavaScript method lookup. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +#[test] +fn native_structural_and_subclass_receivers_keep_has_semantics() { + let stdout = compile_and_run( + r#" +class Holder { + constructor(public readonly values: ReadonlySet) {} + contains(value: number): boolean { + return this.values.has(value); + } +} + +function nullableContains(holder: Holder | undefined, value: number): boolean { + return holder.values.has(value); +} + +const native = new Holder(new Set([2, 4, 6])); +console.log("native", native.contains(4), native.contains(5)); + +let customCalls = 0; +const structural = { + has(value: number) { + customCalls++; + return value === 7; + }, +} as unknown as ReadonlySet; +const custom = new Holder(structural); +console.log("structural", custom.contains(7), custom.contains(8), customCalls); +console.log("nullable", nullableContains(custom, 7), customCalls); + +let nullishRejected = false; +try { + nullableContains(undefined, 7); +} catch (_error) { + nullishRejected = true; +} +console.log("nullish", nullishRejected); + +class OddSet extends Set { + override has(value: number): boolean { + return value === 99; + } +} +const subclass = new Holder(new OddSet([1, 3])); +console.log("subclass", subclass.contains(99), subclass.contains(1)); +"#, + ); + + assert_eq!( + stdout, + "native true false\n\ + structural true false 2\n\ + nullable true 3\n\ + nullish true\n\ + subclass true false\n" + ); +} diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index 9a5042147e..7338da391c 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -473,6 +473,47 @@ fn whole_type_only_import_does_not_wrap_same_named_runtime_builtin() { assert_eq!(compile_and_run(dir.path(), "main.ts"), "true\n"); } +#[test] +fn type_only_class_import_retains_guarded_readonly_set_field_metadata() { + let dir = tempfile::tempdir().expect("tempdir"); + write( + dir.path(), + "archetype.ts", + "export class Archetype {\n\ + readonly componentTypeSet: ReadonlySet;\n\ + constructor(values: number[]) { this.componentTypeSet = new Set(values); }\n\ + }\n", + ); + write( + dir.path(), + "factory.ts", + "import { Archetype } from './archetype';\n\ + export function makeArchetype() { return new Archetype([3, 5]); }\n", + ); + write( + dir.path(), + "main.ts", + "import type { Archetype } from './archetype';\n\ + import { makeArchetype } from './factory';\n\ + function contains(archetype: Archetype, value: number) {\n\ + return archetype.componentTypeSet.has(value);\n\ + }\n\ + const archetype = makeArchetype();\n\ + console.log(contains(archetype, 3), contains(archetype, 4));\n", + ); + + let (stdout, entry_ir) = compile_and_run_with_llvm_trace(dir.path(), "main.ts"); + assert_eq!(stdout, "true false\n"); + assert!( + entry_ir.contains("call double @js_readonly_set_has("), + "type-only class imports must retain field metadata for guarded collection dispatch:\n{entry_ir}" + ); + assert!( + !entry_ir.contains("call double @js_typed_feedback_native_call_method_by_id("), + "the imported ReadonlySet field should not fall through generic native dispatch:\n{entry_ir}" + ); +} + #[test] fn type_only_interface_dispatch_uses_runtime_class_registry() { let dir = tempfile::tempdir().expect("tempdir"); From 9c52782bd342c27ddda90820e410264905109b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 15:05:00 +0200 Subject: [PATCH 2/3] chore: add changelog for #8826 --- changelog.d/8826-readonly-set-has.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/8826-readonly-set-has.md diff --git a/changelog.d/8826-readonly-set-has.md b/changelog.d/8826-readonly-set-has.md new file mode 100644 index 0000000000..0514a1bc1f --- /dev/null +++ b/changelog.d/8826-readonly-set-has.md @@ -0,0 +1,4 @@ +Speed up calls to `ReadonlySet.has` with an exact runtime Set-brand check and +ordinary method dispatch fallback. Type-only class imports now retain the +field metadata needed for this guarded optimization without creating runtime +module bindings or initialization edges. From c1a2287cb487c4dfafdd92134a0f3c44c2425335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 25 Aug 2026 15:37:00 +0200 Subject: [PATCH 3/3] chore(codegen): classify readonly Set type hint --- scripts/local_binding_type_allowlist.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index a78f912533..c3ff53bcad 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -513,6 +513,14 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, + { + "path": "crates/perry-codegen/src/type_analysis/strings.rs", + "function": "is_readonly_set_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "The declared ReadonlySet hint only nominates js_readonly_set_has; that helper admits direct layout access after an exact live Set brand check and otherwise performs the original method dispatch." + }, { "path": "crates/perry-codegen/src/type_analysis/strings.rs", "function": "is_set_expr",