From 9ccba92a9dca78303462bbcf54201ce3ab170824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 14 Jun 2026 15:56:45 +0200 Subject: [PATCH] fix: Next.js standalone walls 36-44 (rebased onto latest main, v0.5.1168) Re-applies PR #5125's wall 36-44 code cleanly onto current main (which now has #5124's js_node_system_error_value link fix, unblocking http auto-optimize). Consolidated commit; per-wall detail in CHANGELOG. --- CHANGELOG.md | 14 + CLAUDE.md | 2 +- Cargo.lock | 148 +++--- Cargo.toml | 2 +- crates/perry-codegen/src/expr/super_method.rs | 54 ++ .../perry-codegen/src/expr/this_super_call.rs | 29 +- crates/perry-codegen/src/lower_call/new.rs | 88 +++- .../src/runtime_decls/strings.rs | 11 + .../src/destructuring/pattern_binding.rs | 40 +- crates/perry-hir/src/lower/expr_function.rs | 51 +- crates/perry-hir/src/lower_decl/block.rs | 470 +++++++++++++++++- crates/perry-hir/src/lower_decl/class_decl.rs | 31 ++ crates/perry-hir/src/lower_decl/mod.rs | 1 + .../src/object/class_constructors.rs | 143 ++++++ .../src/object/class_registry.rs | 67 +++ .../perry-runtime/src/object/global_this.rs | 26 + .../src/object/native_call_method.rs | 146 +++--- .../compile/cjs_wrap/extract_exports.rs | 40 ++ .../compile/cjs_wrap/hoist_classes.rs | 16 +- .../src/commands/compile/cjs_wrap/wrap.rs | 2 +- 20 files changed, 1230 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ad6cb49e..3cf557fb85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.5.1168 — Next.js standalone bring-up: walls 36–44 (rebased on main) + +Dynamic-parent classes, forward-capture, super.method, cjs export-hint, self-new in capturing closure. (Same content as PR #5125, re-applied cleanly onto current main.) + +- **36** pre-register sibling class names in function-expression bodies (cjs IIFE forward refs). +- **37** don't hold the class-vtable read lock across a method body (lazy `require()` write-lock deadlock). +- **38** `class X extends _mod.default` runs the base constructor + inherits: cjs hoist guard scans the `extends` head; `.default` member-extends routes through `extends_expr`; super/`new` use the decl-time-stashed parent (`CLASS_DYNAMIC_PARENT_VALUE`) and invoke a ClassRef parent via `run_class_constructor_on_this_flat`. +- **39** ignore the dead `0 && (module.exports = {…})` Babel export hint (statement-boundary guard) so it doesn't override real `_export` getters. +- **40** dynamic `require()` of an unresolvable specifier throws `MODULE_NOT_FOUND` (Node parity). +- **41** closures forward-capture later-declared function-scope `let`/`const` (shared `pre_register_forward_captured_lets`). +- **42** `super.method()` on a dynamic-parent class dispatches at runtime (`js_super_method_call_dynamic`). +- **43** forward-capture of destructured `let`/`const` + cjs-IIFE bodies. +- **44** `new SelfClass(...)` inside a `this`-alias-capturing closure (redirect to the standalone ctor symbol). + ## v0.5.1167 — fix(runtime): relink node:http/net — restore `js_node_system_error_value` (#5124) Release-blocking regression fix folded in via #5124. PR #5112 (Next.js diff --git a/CLAUDE.md b/CLAUDE.md index b6b387e835..b1e864d698 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1167 +**Current Version:** 0.5.1168 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 931b23ead8..49d8d220fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "base64", @@ -5338,14 +5338,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "cc", "libc", @@ -5353,7 +5353,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "log", @@ -5368,7 +5368,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-hir", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-hir", @@ -5385,7 +5385,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-dispatch", @@ -5395,7 +5395,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-hir", @@ -5404,7 +5404,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "base64", @@ -5417,7 +5417,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-hir", @@ -5425,7 +5425,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "async-trait", @@ -5454,14 +5454,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "serde", "serde_json", @@ -5469,7 +5469,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1167" +version = "0.5.1168" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5480,7 +5480,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "clap", @@ -5495,14 +5495,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "argon2", "perry-ffi", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "reqwest", @@ -5519,7 +5519,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "bcrypt", "perry-ffi", @@ -5527,7 +5527,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "rusqlite", @@ -5535,7 +5535,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "scraper", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "perry-runtime", @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "chrono", "cron 0.16.0", @@ -5561,7 +5561,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "chrono", "perry-ffi", @@ -5569,7 +5569,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "rust_decimal", @@ -5577,7 +5577,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "serde_json", @@ -5585,7 +5585,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5593,7 +5593,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "perry-runtime", @@ -5601,14 +5601,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "bytes", "http-body-util", @@ -5625,7 +5625,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "lazy_static", "perry-ffi", @@ -5637,7 +5637,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5650,7 +5650,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "bytes", "h2", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "lazy_static", "perry-ffi", @@ -5683,7 +5683,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "jsonwebtoken", @@ -5694,7 +5694,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "lru", "perry-ffi", @@ -5702,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "chrono", "perry-ffi", @@ -5710,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "bson", "futures-util", @@ -5722,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "chrono", "perry-ffi", @@ -5732,7 +5732,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "nanoid", "perry-ffi", @@ -5741,7 +5741,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "perry-runtime", @@ -5753,7 +5753,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "lettre", "perry-ffi", @@ -5763,7 +5763,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "printpdf", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "sqlx", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "governor", "perry-ffi", @@ -5788,7 +5788,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "image", @@ -5797,14 +5797,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "lazy_static", "perry-ffi", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "uuid", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ffi", "regex", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "futures-util", "lazy_static", @@ -5843,7 +5843,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "brotli", "flate2", @@ -5852,7 +5852,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "dashmap", "once_cell", @@ -5861,7 +5861,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-api-manifest", @@ -5878,7 +5878,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-diagnostics", @@ -5890,7 +5890,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "base64", @@ -5922,7 +5922,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6014,7 +6014,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "perry-hir", @@ -6024,7 +6024,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6032,14 +6032,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "itoa", @@ -6056,7 +6056,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "rand 0.8.6", "serde", @@ -6066,7 +6066,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "cairo-rs", @@ -6089,7 +6089,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "block2", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "block2", @@ -6120,7 +6120,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1167" +version = "0.5.1168" [[package]] name = "perry-ui-test" @@ -6128,11 +6128,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1167" +version = "0.5.1168" [[package]] name = "perry-ui-tvos" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "block2", @@ -6148,7 +6148,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "block2", @@ -6164,7 +6164,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "block2", "libc", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "libc", @@ -6194,14 +6194,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "base64", "ed25519-dalek", @@ -6215,7 +6215,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1167" +version = "0.5.1168" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 5e90901ab8..d00630bcb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -201,7 +201,7 @@ codegen-units = 16 opt-level = "s" # Optimize for size in stdlib [workspace.package] -version = "0.5.1167" +version = "0.5.1168" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/expr/super_method.rs b/crates/perry-codegen/src/expr/super_method.rs index 363d8ffbc2..9928792592 100644 --- a/crates/perry-codegen/src/expr/super_method.rs +++ b/crates/perry-codegen/src/expr/super_method.rs @@ -72,6 +72,60 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { parent = ctx.classes.get(&p).and_then(|c| c.extends_name.clone()); } let Some(fn_name) = resolved_fn else { + // Static resolution failed. For a class with a DYNAMIC parent + // (`class X extends _mod.default` — the interop ESM + // default-export base, wall 38/42), `extends_name` is "default" + // and never resolves to a compile-time class, so the chain walk + // above finds nothing. Dispatch `super.method(...)` at runtime + // via the registered parent edge instead of returning the bogus + // numeric `0.0` (which made `super.getRequestHandler()` in + // Next.js's `NextNodeServer.makeRequestHandler` yield a number, + // and the handler it built threw "value is not a function"). + let has_dyn_parent = ctx + .classes + .get(¤t_class_name) + .map(|c| c.extends_expr.is_some()) + .unwrap_or(false); + let cid = ctx.class_ids.get(¤t_class_name).copied().unwrap_or(0); + if has_dyn_parent && cid != 0 { + let this_box = match ctx.this_stack.last().cloned() { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; + let mut lowered_args: Vec = Vec::with_capacity(args.len()); + for a in args { + lowered_args.push(lower_expr(ctx, a)?); + } + let (args_ptr, args_len) = if lowered_args.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = lowered_args.len(); + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + for (i, v) in lowered_args.iter().enumerate() { + let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + ctx.block().store(DOUBLE, v, &slot); + } + let ptr_reg = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", + ptr_reg, n, buf + )); + (ptr_reg, n.to_string()) + }; + let name_global = emit_string_literal_global(ctx, method); + return Ok(ctx.block().call( + DOUBLE, + "js_super_method_call_dynamic", + &[ + (I32, &cid.to_string()), + (PTR, &name_global), + (I64, &method.len().to_string()), + (DOUBLE, &this_box), + (PTR, &args_ptr), + (I64, &args_len), + ], + )); + } for a in args { let _ = lower_expr(ctx, a)?; } diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 87b4d43002..26ea5e2289 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -279,10 +279,31 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { lowered_args.push(lower_expr(ctx, a)?); } - // Evaluate the parent expression (the runtime function - // value). The HIR layer already lowered it as part of - // class.extends_expr. - let parent_val = lower_expr(ctx, extends_expr)?; + // Resolve the parent constructor VALUE. The decl-time + // `js_register_class_parent_dynamic` already evaluated + // `extends_expr` in the module-init scope (where its free + // variables — e.g. a require alias `_suffix` in + // `class X extends _suffix.default` — are bound) and + // stashed the result keyed by this class's id. Prefer the + // stashed value: re-evaluating `extends_expr` HERE runs in + // the constructor scope, where an IIFE-local require alias + // is NOT captured, so the member read would throw "Cannot + // read properties of undefined". Fall back to a fresh eval + // only when the class id is unknown at codegen time (the + // value was never stashed) or the stash is empty. + // The decl-time `RegisterClassParentDynamic` runs at + // module init, before any `new X()`, so a class that + // reaches this branch has reliably stashed its parent. + // Fall back to a fresh eval only when the class id is + // unknown at codegen time (no stash key). + let parent_val = match ctx.class_ids.get(¤t_class_name).copied() { + Some(cid) if cid != 0 => ctx.block().call( + DOUBLE, + "js_get_dynamic_parent_value", + &[(crate::types::I32, &cid.to_string())], + ), + _ => lower_expr(ctx, extends_expr)?, + }; // Spill args into a contiguous double[] for the // js_native_call_value(ptr, len) ABI. Mirrors the diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index d0669a4c6e..fa4950a637 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -358,6 +358,73 @@ fn effective_constructor_param_count(ctx: &FnCtx<'_>, class: &perry_hir::Class) 0 } +/// True when the standalone `_constructor` symbol exists (so the +/// recursion-guard / capture-collision redirect can call it instead of +/// inlining). Mirrors the lookup in `call_local_constructor_symbol`. +fn local_constructor_symbol_exists(ctx: &FnCtx<'_>, class: &perry_hir::Class) -> bool { + let ctor_method_name = format!("{}_constructor", class.name); + ctx.methods + .contains_key(&(class.name.clone(), ctor_method_name)) +} + +/// Collect every LocalId DECLARED (via `Stmt::Let`, incl. nested in compound +/// statements) within a constructor body. Used to detect the wall-44 inline +/// collision: a ctor local whose id is also a capture of the enclosing closure. +/// Mirrors `collect_let_ids` in `class_members.rs`. +fn collect_decl_local_ids(stmts: &[perry_hir::Stmt], out: &mut std::collections::HashSet) { + use perry_hir::Stmt; + for s in stmts { + match s { + Stmt::Let { id, .. } => { + out.insert(*id); + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + collect_decl_local_ids(then_branch, out); + if let Some(e) = else_branch { + collect_decl_local_ids(e, out); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + collect_decl_local_ids(body, out) + } + Stmt::For { init, body, .. } => { + if let Some(init_stmt) = init { + if let Stmt::Let { id, .. } = init_stmt.as_ref() { + out.insert(*id); + } + } + collect_decl_local_ids(body, out); + } + Stmt::Try { + body, + catch, + finally, + } => { + collect_decl_local_ids(body, out); + if let Some(c) = catch { + collect_decl_local_ids(&c.body, out); + } + if let Some(f) = finally { + collect_decl_local_ids(f, out); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + collect_decl_local_ids(&case.body, out); + } + } + Stmt::Labeled { body, .. } => { + collect_decl_local_ids(std::slice::from_ref(body.as_ref()), out) + } + _ => {} + } + } +} + fn call_local_constructor_symbol( ctx: &mut FnCtx<'_>, class: &perry_hir::Class, @@ -945,7 +1012,26 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // same constructor body forever at compile time. Use the standalone // constructor symbol for the nested construction instead; it preserves // the ordinary initializer path without recursively cloning HIR. - if ctx.class_stack.iter().any(|active| active == class_name) { + // + // Same redirect when inlining would alias the constructor's own locals + // with the ENCLOSING closure's captures. `class F { constructor(){ const + // t = this; t.mk = () => new F(t._cc); } }` lifts the arrow to a separate + // function that captures `t` (the `const t = this` alias). When `new F` + // inside that arrow is inlined, the inlined ctor's `const t = this` reuses + // the same LocalId — which is a capture in this closure — so reads/writes + // of `t` resolve through `js_closure_get_capture_f64` and land on the + // CAPTURED outer instance instead of the freshly-allocated one (the new + // instance gets no fields → wall 44 `BaseContext.setValue` → "Cannot read + // properties of undefined"). The standalone symbol takes `this` as an + // explicit parameter, so it is immune to the collision. + let ctor_alias_collision = !ctx.closure_captures.is_empty() + && local_constructor_symbol_exists(ctx, class) + && class.constructor.as_ref().is_some_and(|c| { + let mut ids: std::collections::HashSet = c.params.iter().map(|p| p.id).collect(); + collect_decl_local_ids(&c.body, &mut ids); + ids.iter().any(|id| ctx.closure_captures.contains_key(id)) + }); + if ctx.class_stack.iter().any(|active| active == class_name) || ctor_alias_collision { call_local_constructor_symbol(ctx, class, &obj_box, &lowered_args); return Ok(obj_box); } diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 05aa27e150..af14f4ebee 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1123,6 +1123,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // class_id from the value (ClassRef payload or ObjectHeader.class_id) // and wires the (child, parent) edge into CLASS_REGISTRY. module.declare_function("js_register_class_parent_dynamic", VOID, &[I32, DOUBLE]); + // Read back the parent VALUE stashed by `js_register_class_parent_dynamic` + // at decl time, so `super()` resolves the parent from the module-init scope + // instead of re-evaluating its extends expression in the constructor scope. + module.declare_function("js_get_dynamic_parent_value", DOUBLE, &[I32]); // Decl-site snapshot of a function-nested class's captured locals — // consumed by the dynamic-construction replay (`new mod.C()`). module.declare_function("js_class_register_capture_values", VOID, &[I32, PTR, I64]); @@ -1130,6 +1134,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_class_capture_value", DOUBLE, &[I32, I32]); // `super(...spread)` — dynamic-arity ancestor ctor invocation on `this`. module.declare_function("js_super_construct_apply", VOID, &[I32, DOUBLE, DOUBLE]); + // `super.method(...)` on a class with a runtime-registered (dynamic) parent + // — resolve the method from the registered parent chain and call on `this`. + module.declare_function( + "js_super_method_call_dynamic", + DOUBLE, + &[I32, PTR, I64, DOUBLE, PTR, I64], + ); module.declare_function("js_array_push_spread_any", I64, &[I64, DOUBLE]); // Issue #711 part 2: prototype-based class declaration via // `.prototype = `. Binds an object as the function's diff --git a/crates/perry-hir/src/destructuring/pattern_binding.rs b/crates/perry-hir/src/destructuring/pattern_binding.rs index 11cc6a025e..4e8698937e 100644 --- a/crates/perry-hir/src/destructuring/pattern_binding.rs +++ b/crates/perry-hir/src/destructuring/pattern_binding.rs @@ -339,7 +339,24 @@ pub(crate) fn lower_pattern_binding_into( .as_ref() .map(|ann| extract_ts_type(&ann.type_ann)) .unwrap_or(Type::Any); - let id = ctx.define_local(name.clone(), ty.clone()); + // Reuse a forward-pre-registered (boxed) local when an earlier + // closure captured this destructured binding before its declaration + // (the function-body Phase 1.6 pass, span-keyed). Without this the + // closure's box and this binding's slot diverge. + let id = match ctx.lexical_forward_decls.remove(&ident.id.span.lo.0) { + Some(pre_id) => { + if let Some((_, _, ety)) = ctx + .locals + .iter_mut() + .rev() + .find(|(_, lid, _)| *lid == pre_id) + { + *ety = ty.clone(); + } + pre_id + } + None => ctx.define_local(name.clone(), ty.clone()), + }; if !mutable { ctx.mark_local_immutable(id); } @@ -504,7 +521,26 @@ pub(crate) fn lower_pattern_binding_into( .as_ref() .map(|ann| extract_ts_type(&ann.type_ann)) .unwrap_or(Type::Any); - let id = ctx.define_local(name.clone(), ty.clone()); + // Reuse a forward-pre-registered (boxed) local when an + // earlier closure forward-captured this `{ key }` + // shorthand binding (Phase 1.6, span-keyed) — e.g. the + // Next.js tracer's `_export(exports, { SpanKind: () => + // SpanKind })` getter referencing the later `const { + // SpanKind } = api`. + let id = match ctx.lexical_forward_decls.remove(&assign.key.span.lo.0) { + Some(pre_id) => { + if let Some((_, _, ety)) = ctx + .locals + .iter_mut() + .rev() + .find(|(_, lid, _)| *lid == pre_id) + { + *ety = ty.clone(); + } + pre_id + } + None => ctx.define_local(name.clone(), ty.clone()), + }; let init_value = if let Some(default_expr) = &assign.value { // Materialize the property read into a temp so we diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index a14880dcee..4d57f008e9 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -600,6 +600,11 @@ pub(crate) fn lower_fn_expr(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> // with that index or higher was defined in the current scope. let outer_locals_len = scope_mark.0; let mut hoisted_id_set: std::collections::HashSet = std::collections::HashSet::new(); + // Forward-captured `let`/`const` boxes pre-registered for THIS fn-expr body + // (the cjs `const _cjs = (function(){…})()` wrapper) — see + // `pre_register_forward_captured_lets`. Kept out of `hoisted_id_set` and + // preallocated directly at the assembly below. + let mut forward_boxed_ids: Vec = Vec::new(); // #4950: undefined-initialised `Stmt::Let`s for `var`s found nested in // compound statements — prepended to the lowered body below. let mut nested_var_prologue: Vec = Vec::new(); @@ -788,6 +793,16 @@ pub(crate) fn lower_fn_expr(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> } } } + // Forward-captured `let`/`const` (incl. destructuring) referenced by an + // EARLIER closure than their declaration. The `#4973` pass above only + // covers `Pat::Ident` and only when the body has a `function` + // declaration; the cjs IIFE's `_export(exports, { SpanKind: () => + // SpanKind })` getter forward-captures the later `const { SpanKind } = + // api` (Next.js tracer), which it misses. Shared with arrow / fn-decl + // bodies (`lower_fn_body_block_stmt`). Bindings already pre-registered + // above are skipped (the `already_in_scope` guard inside). + forward_boxed_ids = + crate::lower_decl::pre_register_forward_captured_lets(ctx, block, outer_locals_len); } // Lower body with JS hoisting: only function declarations are fully @@ -809,6 +824,26 @@ pub(crate) fn lower_fn_expr(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> // threw `TypeError: value is not a function`. Function declarations // must run before any var-init in the body, then var-inits and other // executable statements run in source order. + // Pre-register sibling class DECLARATION names so forward references in + // earlier statements (and nested closures lowered before the class) resolve + // to `ClassRef` rather than the unknown-global sentinel — the same Phase 1.5 + // that `lower_fn_body_block_stmt` (arrow / fn-decl bodies) performs. Plain + // function expressions previously skipped it: the cjs_wrap IIFE is exactly + // such an expression, and a class it can't hoist out (one whose body + // references an IIFE-local, e.g. `class X extends imp.Base { constructor(){ + // super(imp2.CONST) } }`) stays inside the IIFE with its export getter + // `() => X` lowered ABOVE it — that forward read fell through to + // `js_global_get_or_throw_unresolved("X")` → `ReferenceError: X is not + // defined` (Next.js RSCPathnameNormalizer). Scoped: restored after the body. + let saved_forward_class_names = ctx.forward_class_names.clone(); + if let Some(ref block) = fn_expr.function.body { + for stmt in &block.stmts { + if let ast::Stmt::Decl(ast::Decl::Class(class_decl)) = stmt { + ctx.forward_class_names + .insert(class_decl.ident.sym.to_string()); + } + } + } let mut body = if let Some(ref block) = fn_expr.function.body { // #4795: a `using` / `await using` declaration in a function-expression // body must be desugared (scope-exit disposal + declaration-time @@ -842,12 +877,21 @@ pub(crate) fn lower_fn_expr(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> combined.extend(std::mem::take(&mut nested_var_prologue)); combined.extend(func_decls); combined.extend(exec_stmts); - // Issue #633: prealloc-box for sibling/forward captures. - if !hoisted_id_set.is_empty() { - let prealloc = crate::lower_decl::compute_prealloc_for_hoisted_closures( + // Issue #633: prealloc-box for sibling/forward captures. Merge the + // forward-captured `let`/`const` boxes (kept out of `hoisted_id_set` + // to avoid hoist-reordering their non-hoistable declarations) so + // their boxes exist before the earlier capturing closure literal. + if !hoisted_id_set.is_empty() || !forward_boxed_ids.is_empty() { + let mut prealloc = crate::lower_decl::compute_prealloc_for_hoisted_closures( &combined, &hoisted_id_set, ); + for id in &forward_boxed_ids { + if !prealloc.contains(id) { + prealloc.push(*id); + } + } + prealloc.sort(); if !prealloc.is_empty() { let mut with_prealloc: Vec = Vec::with_capacity(combined.len() + 1); with_prealloc.push(Stmt::PreallocateBoxes(prealloc)); @@ -861,6 +905,7 @@ pub(crate) fn lower_fn_expr(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Vec::new() }; ctx.current_strict = outer_strict; + ctx.forward_class_names = saved_forward_class_names; // Prepend destructuring statements to body if !destructuring_stmts.is_empty() { diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index bdc8d88416..5bf5152c1e 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -17,6 +17,424 @@ pub fn lower_block_stmt(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Re lower_stmts_using_aware(ctx, &block.stmts) } +/// Collect identifier names referenced INSIDE any closure (arrow / function +/// expression / nested function declaration / class member) within a statement +/// (`in_cl` = whether we are already inside a closure body). +/// +/// Used by the Phase 1.6 forward `let`/`const` pre-registration in +/// `lower_fn_body_block_stmt` to box ONLY bindings a closure can actually +/// capture, rather than every top-level binding in a closure-containing body +/// (the latter regressed Next.js at scale — start-server's `initialize()` +/// exited after "Ready"). Over-collection is harmless (a collected name that +/// isn't a top-level `let`/`const` is simply ignored); under-collection on an +/// exotic AST node degrades to the pre-fix behavior for that one binding. +/// Collect every binding identifier (name + span low offset) introduced by a +/// declarator pattern, recursing through array / object destructuring. The span +/// keys `lexical_forward_decls` so the destructuring binding site reuses the +/// forward-pre-registered (boxed) local. Mirrors the binding sites in +/// `destructuring/pattern_binding.rs` (`Pat::Ident` leaf and the `{ key }` +/// shorthand `ObjectPatProp::Assign`). +/// Pre-register the top-level `let`/`const` bindings of a function body that +/// are FORWARD-captured — referenced by a closure (arrow / function expression +/// / nested function declaration) appearing EARLIER in the body than the +/// binding's declaration. Each such binding is defined as a boxed function- +/// scope local now (so the earlier closure resolves it to the local and +/// captures the live box) and span-keyed in `lexical_forward_decls` so the +/// declaration — including a destructuring leaf — reuses the same id. Returns +/// the pre-registered ids so the caller can prealloc their boxes at entry. +/// +/// `body_entry_locals_len` is `ctx.locals.len()` captured before any of this +/// body's own locals were defined — anything at or above it is in THIS scope, +/// so a binding that shadows an outer name still gets a fresh local. Shared by +/// `lower_fn_body_block_stmt` (function declarations + arrows) and +/// `lower_fn_expr` (the cjs `const _cjs = (function(){…})()` wrapper, where the +/// `_export(exports, { X: () => X })` getter forward-captures a later `const { +/// X } = …`). +pub(crate) fn pre_register_forward_captured_lets( + ctx: &mut LoweringContext, + block: &ast::BlockStmt, + body_entry_locals_len: usize, +) -> Vec { + let mut forward_boxed_ids: Vec = Vec::new(); + let mut seen_closure_refs: std::collections::HashSet = std::collections::HashSet::new(); + for stmt in &block.stmts { + if let ast::Stmt::Decl(ast::Decl::Var(var_decl)) = stmt { + if matches!( + var_decl.kind, + ast::VarDeclKind::Let | ast::VarDeclKind::Const + ) { + for decl in &var_decl.decls { + let mut binding_idents: Vec<(String, u32)> = Vec::new(); + collect_pat_forward_idents(&decl.name, &mut binding_idents); + for (name, span_lo) in binding_idents { + if !seen_closure_refs.contains(&name) { + continue; + } + let already_in_scope = ctx + .locals + .iter() + .enumerate() + .rev() + .any(|(idx, (n, _, _))| n == &name && idx >= body_entry_locals_len); + if !already_in_scope { + let id = ctx.define_local(name, Type::Any); + ctx.var_hoisted_ids.insert(id); + forward_boxed_ids.push(id); + ctx.lexical_forward_decls.insert(span_lo, id); + } + } + } + } + } + // Record closures introduced by THIS statement for subsequent decls. + cic_stmt(stmt, false, &mut seen_closure_refs); + } + forward_boxed_ids +} + +fn collect_pat_forward_idents(pat: &ast::Pat, out: &mut Vec<(String, u32)>) { + match pat { + ast::Pat::Ident(i) => out.push((i.id.sym.to_string(), i.id.span.lo.0)), + ast::Pat::Array(arr) => arr + .elems + .iter() + .flatten() + .for_each(|el| collect_pat_forward_idents(el, out)), + ast::Pat::Object(o) => { + for p in &o.props { + match p { + ast::ObjectPatProp::KeyValue(kv) => collect_pat_forward_idents(&kv.value, out), + ast::ObjectPatProp::Assign(a) => { + out.push((a.key.sym.to_string(), a.key.span.lo.0)) + } + ast::ObjectPatProp::Rest(r) => collect_pat_forward_idents(&r.arg, out), + } + } + } + ast::Pat::Assign(a) => collect_pat_forward_idents(&a.left, out), + ast::Pat::Rest(r) => collect_pat_forward_idents(&r.arg, out), + _ => {} + } +} + +fn cic_stmt(s: &ast::Stmt, in_cl: bool, out: &mut std::collections::HashSet) { + use ast::Stmt::*; + match s { + Block(b) => b.stmts.iter().for_each(|st| cic_stmt(st, in_cl, out)), + Return(r) => { + if let Some(a) = &r.arg { + cic_expr(a, in_cl, out); + } + } + Expr(e) => cic_expr(&e.expr, in_cl, out), + If(i) => { + cic_expr(&i.test, in_cl, out); + cic_stmt(&i.cons, in_cl, out); + if let Some(a) = &i.alt { + cic_stmt(a, in_cl, out); + } + } + Throw(t) => cic_expr(&t.arg, in_cl, out), + While(w) => { + cic_expr(&w.test, in_cl, out); + cic_stmt(&w.body, in_cl, out); + } + DoWhile(w) => { + cic_expr(&w.test, in_cl, out); + cic_stmt(&w.body, in_cl, out); + } + For(f) => { + if let Some(init) = &f.init { + match init { + ast::VarDeclOrExpr::Expr(e) => cic_expr(e, in_cl, out), + ast::VarDeclOrExpr::VarDecl(vd) => vd.decls.iter().for_each(|d| { + if let Some(i) = &d.init { + cic_expr(i, in_cl, out); + } + }), + } + } + if let Some(t) = &f.test { + cic_expr(t, in_cl, out); + } + if let Some(u) = &f.update { + cic_expr(u, in_cl, out); + } + cic_stmt(&f.body, in_cl, out); + } + ForIn(f) => { + cic_expr(&f.right, in_cl, out); + cic_stmt(&f.body, in_cl, out); + } + ForOf(f) => { + cic_expr(&f.right, in_cl, out); + cic_stmt(&f.body, in_cl, out); + } + Try(t) => { + t.block.stmts.iter().for_each(|st| cic_stmt(st, in_cl, out)); + if let Some(h) = &t.handler { + h.body.stmts.iter().for_each(|st| cic_stmt(st, in_cl, out)); + } + if let Some(f) = &t.finalizer { + f.stmts.iter().for_each(|st| cic_stmt(st, in_cl, out)); + } + } + Switch(sw) => { + cic_expr(&sw.discriminant, in_cl, out); + for c in &sw.cases { + if let Some(t) = &c.test { + cic_expr(t, in_cl, out); + } + c.cons.iter().for_each(|st| cic_stmt(st, in_cl, out)); + } + } + Labeled(l) => cic_stmt(&l.body, in_cl, out), + With(w) => { + cic_expr(&w.obj, in_cl, out); + cic_stmt(&w.body, in_cl, out); + } + Decl(d) => cic_decl(d, in_cl, out), + _ => {} + } +} + +fn cic_decl(d: &ast::Decl, in_cl: bool, out: &mut std::collections::HashSet) { + match d { + ast::Decl::Var(vd) => vd.decls.iter().for_each(|de| { + if let Some(i) = &de.init { + cic_expr(i, in_cl, out); + } + }), + // A nested function declaration's body is a closure scope. + ast::Decl::Fn(f) => { + if let Some(b) = &f.function.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + ast::Decl::Class(c) => cic_class(&c.class, in_cl, out), + _ => {} + } +} + +fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet) { + if let Some(sc) = &c.super_class { + cic_expr(sc, in_cl, out); + } + for m in &c.body { + match m { + ast::ClassMember::Method(mm) => { + if let Some(b) = &mm.function.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + ast::ClassMember::PrivateMethod(mm) => { + if let Some(b) = &mm.function.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + ast::ClassMember::ClassProp(p) => { + if let Some(v) = &p.value { + cic_expr(v, true, out); + } + } + ast::ClassMember::PrivateProp(p) => { + if let Some(v) = &p.value { + cic_expr(v, true, out); + } + } + _ => {} + } + } +} + +fn cic_expr(e: &ast::Expr, in_cl: bool, out: &mut std::collections::HashSet) { + use ast::Expr::*; + match e { + Ident(i) => { + if in_cl { + out.insert(i.sym.to_string()); + } + } + Arrow(a) => { + for p in &a.params { + cic_pat(p, true, out); + } + match &*a.body { + ast::BlockStmtOrExpr::BlockStmt(b) => { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)) + } + ast::BlockStmtOrExpr::Expr(ex) => cic_expr(ex, true, out), + } + } + Fn(f) => { + for p in &f.function.params { + cic_pat(&p.pat, true, out); + } + if let Some(b) = &f.function.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + Class(c) => cic_class(&c.class, in_cl, out), + Array(a) => a + .elems + .iter() + .flatten() + .for_each(|el| cic_expr(&el.expr, in_cl, out)), + Object(o) => { + for p in &o.props { + match p { + ast::PropOrSpread::Spread(s) => cic_expr(&s.expr, in_cl, out), + ast::PropOrSpread::Prop(pr) => cic_prop(pr, in_cl, out), + } + } + } + Unary(u) => cic_expr(&u.arg, in_cl, out), + Update(u) => cic_expr(&u.arg, in_cl, out), + Bin(b) => { + cic_expr(&b.left, in_cl, out); + cic_expr(&b.right, in_cl, out); + } + Assign(a) => { + cic_assign_target(&a.left, in_cl, out); + cic_expr(&a.right, in_cl, out); + } + Member(m) => { + cic_expr(&m.obj, in_cl, out); + if let ast::MemberProp::Computed(c) = &m.prop { + cic_expr(&c.expr, in_cl, out); + } + } + Cond(c) => { + cic_expr(&c.test, in_cl, out); + cic_expr(&c.cons, in_cl, out); + cic_expr(&c.alt, in_cl, out); + } + Call(c) => { + if let ast::Callee::Expr(e) = &c.callee { + cic_expr(e, in_cl, out); + } + c.args.iter().for_each(|a| cic_expr(&a.expr, in_cl, out)); + } + New(n) => { + cic_expr(&n.callee, in_cl, out); + if let Some(args) = &n.args { + args.iter().for_each(|a| cic_expr(&a.expr, in_cl, out)); + } + } + Seq(s) => s.exprs.iter().for_each(|e| cic_expr(e, in_cl, out)), + Tpl(t) => t.exprs.iter().for_each(|e| cic_expr(e, in_cl, out)), + TaggedTpl(t) => { + cic_expr(&t.tag, in_cl, out); + t.tpl.exprs.iter().for_each(|e| cic_expr(e, in_cl, out)); + } + Paren(p) => cic_expr(&p.expr, in_cl, out), + Await(a) => cic_expr(&a.arg, in_cl, out), + Yield(y) => { + if let Some(a) = &y.arg { + cic_expr(a, in_cl, out); + } + } + OptChain(o) => match &*o.base { + ast::OptChainBase::Member(m) => { + cic_expr(&m.obj, in_cl, out); + if let ast::MemberProp::Computed(c) = &m.prop { + cic_expr(&c.expr, in_cl, out); + } + } + ast::OptChainBase::Call(c) => { + cic_expr(&c.callee, in_cl, out); + c.args.iter().for_each(|a| cic_expr(&a.expr, in_cl, out)); + } + }, + _ => {} + } +} + +fn cic_pat(p: &ast::Pat, in_cl: bool, out: &mut std::collections::HashSet) { + match p { + ast::Pat::Assign(a) => { + cic_pat(&a.left, in_cl, out); + cic_expr(&a.right, in_cl, out); + } + ast::Pat::Array(arr) => arr + .elems + .iter() + .flatten() + .for_each(|el| cic_pat(el, in_cl, out)), + ast::Pat::Object(o) => { + for pp in &o.props { + match pp { + ast::ObjectPatProp::KeyValue(kv) => cic_pat(&kv.value, in_cl, out), + ast::ObjectPatProp::Assign(a) => { + if let Some(v) = &a.value { + cic_expr(v, in_cl, out); + } + } + ast::ObjectPatProp::Rest(r) => cic_pat(&r.arg, in_cl, out), + } + } + } + ast::Pat::Rest(r) => cic_pat(&r.arg, in_cl, out), + _ => {} + } +} + +fn cic_prop(p: &ast::Prop, in_cl: bool, out: &mut std::collections::HashSet) { + match p { + ast::Prop::Shorthand(i) => { + if in_cl { + out.insert(i.sym.to_string()); + } + } + ast::Prop::KeyValue(kv) => { + if let ast::PropName::Computed(c) = &kv.key { + cic_expr(&c.expr, in_cl, out); + } + cic_expr(&kv.value, in_cl, out); + } + ast::Prop::Getter(g) => { + if let Some(b) = &g.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + ast::Prop::Setter(s) => { + if let Some(b) = &s.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + ast::Prop::Method(m) => { + if let Some(b) = &m.function.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } + } + ast::Prop::Assign(a) => cic_expr(&a.value, in_cl, out), + } +} + +fn cic_assign_target( + t: &ast::AssignTarget, + in_cl: bool, + out: &mut std::collections::HashSet, +) { + if let ast::AssignTarget::Simple(s) = t { + match s { + ast::SimpleAssignTarget::Ident(i) => { + if in_cl { + out.insert(i.id.sym.to_string()); + } + } + ast::SimpleAssignTarget::Member(m) => { + cic_expr(&m.obj, in_cl, out); + if let ast::MemberProp::Computed(c) = &m.prop { + cic_expr(&c.expr, in_cl, out); + } + } + ast::SimpleAssignTarget::Paren(p) => cic_expr(&p.expr, in_cl, out), + _ => {} + } + } +} + fn collect_var_binding_names_from_pat(pat: &ast::Pat, out: &mut Vec) { match pat { ast::Pat::Ident(ident) => out.push(ident.id.sym.to_string()), @@ -180,6 +598,11 @@ pub fn lower_fn_body_block_stmt( let parent_strict = ctx.current_strict; ctx.current_strict = parent_strict || crate::lower::stmt_list_starts_with_use_strict_directive(&block.stmts); + // Boundary between outer-scope locals (+ this function's params, defined by + // the caller before entry) and locals defined while lowering THIS body. + // Used by the Phase 1.6 forward `let`/`const` pre-registration so a const + // that shadows an outer binding still gets a fresh this-body local. + let body_entry_locals_len = ctx.locals.len(); let hoisted_var_slots = predefine_var_bindings_in_function_body(ctx, block); // Phase 1: pre-define hoisted FnDecl locals so forward references in @@ -225,6 +648,36 @@ pub fn lower_fn_body_block_stmt( } } + // Phase 1.6: pre-register top-level `let`/`const` Ident bindings of this + // function body so a closure created EARLIER in the body — a hoisted + // FnDecl, or an arrow / function expression assigned to a `const`/`let` + // (`const handler = async (req, res) => { … later … }`) — that references + // a binding declared LATER (`const later = …`) resolves it to the (boxed) + // function-scope local instead of falling through to a `globalThis` read. + // Next.js `router-server.js` `initialize()` does exactly this: the request + // handler closure reads `relativeProjectDir`, a `const` declared ~400 + // lines later in the same function — without this it lowered to a global + // read and threw `ReferenceError: relativeProjectDir is not defined` at + // request time. Each pre-registered binding is boxed (`var_hoisted_ids`), + // its declaration reuses the same id via `lexical_forward_decls`, and its + // box is preallocated at function entry (`forward_boxed_ids`, merged into + // the Phase-5 prealloc set below) so the earlier closure literal captures + // the live box. Scoped to bindings a closure ACTUALLY references + // (`collect_closure_referenced_idents`) — boxing every top-level binding + // regressed Next.js at scale. + // + // CRUCIAL: these ids are NOT added to `hoisted_id_set`. Phase 3 hoists + // every `Let { init: Closure }` in that set to the function top, which is + // correct ONLY for `function` declarations (they hoist per spec). A + // `const handler = async () => {}` does NOT hoist — reordering it ahead of + // the bindings it depends on corrupted `initialize()` (the server exited + // after "Ready"). We therefore prealloc the captured boxes directly + // instead of routing through the FnDecl-hoisting machinery. Shared with the + // function-expression body path (`lower_fn_expr`) via + // `pre_register_forward_captured_lets`; also handles destructuring leaves + // (`const { SpanKind } = api`). + let forward_boxed_ids = pre_register_forward_captured_lets(ctx, block, body_entry_locals_len); + // Phase 2: lower the body. The inner FnDecl arm in `lower_body_stmt` // calls `lookup_local(name)` and reuses our pre-defined id. let mut body = match lower_block_stmt(ctx, block) { @@ -306,7 +759,7 @@ pub fn lower_fn_body_block_stmt( }) .collect(); - if hoisted_id_set.is_empty() { + if hoisted_id_set.is_empty() && forward_boxed_ids.is_empty() { ctx.current_strict = parent_strict; let mut result = var_slot_lets; result.extend(body); @@ -338,9 +791,20 @@ pub fn lower_fn_body_block_stmt( } } - // Phase 4: compute the prealloc-box set via shared helper. + // Phase 4: compute the prealloc-box set via shared helper, then add the + // forward-captured `let`/`const` boxes pre-registered in Phase 1.6. Those + // are deliberately kept out of `hoisted_id_set` (so Phase 3 doesn't hoist + // their non-hoistable `const = closure` declarations), so their boxes must + // be preallocated here explicitly — the earlier closure literal captures + // the box before the declaration assigns through it. let combined: Vec = hoisted_lets.iter().chain(other.iter()).cloned().collect(); - let prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_id_set); + let mut prealloc = compute_prealloc_for_hoisted_closures(&combined, &hoisted_id_set); + for id in forward_boxed_ids { + if !prealloc.contains(&id) { + prealloc.push(id); + } + } + prealloc.sort(); // Phase 5: assemble the final body — PreallocateBoxes (if any), // then the hoisted FnDecl Lets, then everything else. diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 84a68be62f..d3c96ceb09 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -290,6 +290,27 @@ pub fn lower_class_decl( // this keeps the colliding-name case on par with the rest. if parent_name == name { (None, None, None, None) + } else if parent_name == "default" { + // `class X extends _mod.default` — the interop ESM + // default-export-class pattern (Next.js `NextNodeServer + // extends base-server`'s default `Server`). The trailing + // property `default` never resolves through `lookup_class`, + // and a `.default` export is always a real user/registered + // class — never a native-module member like `http.Agent` + // (which inherits via a *named* property and is handled by + // the colliding-name / parentless branches). Route through + // the dynamic `extends_expr` path so `super(opts)` + // re-evaluates the alias at construction time and runs the + // base constructor, and the decl-time + // `RegisterClassParentDynamic` wires the real parent edge + // (inherited methods / `instanceof`). The companion hoist + // guard in `extract_top_level_class_decls` keeps this class + // inside the IIFE so the require alias is assigned before the + // registration runs. + match lower_expr(ctx, super_class) { + Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (None, Some(parent_name), None, None), + } } else { // Refs #488 drizzle-sqlite: also try resolving the parent // class by name across modules. Pre-fix the Member arm set @@ -1271,6 +1292,16 @@ pub fn lower_class_from_ast( // the non-colliding native-member-base behavior. if parent_name == name { (None, None, None, None) + } else if parent_name == "default" { + // `class X extends _mod.default` — the interop ESM + // default-export-class pattern. Keep in lockstep with the + // matching `.default` arm in `lower_class_decl` above: route + // through `extends_expr` so `super()` re-evaluates the alias + // at construction time and the parent edge is registered. + match lower_expr(ctx, super_class) { + Ok(expr) => (None, Some(parent_name), None, Some(Box::new(expr))), + Err(_) => (None, Some(parent_name), None, None), + } } else { ( ctx.lookup_class(&parent_name), diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index 8b7359c42a..37fd01d8fe 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -31,6 +31,7 @@ pub(crate) use block::{ collect_refs_in_closure_bodies_stmt, collect_top_level_let_ids_stmt, collect_var_binding_names_from_stmt, compute_prealloc_for_hoisted_closures, lower_block_stmt, lower_block_stmt_scoped, lower_fn_body_block_stmt, lower_stmts_using_aware, + pre_register_forward_captured_lets, }; pub(crate) use body_stmt::{find_native_return_in_stmts, lower_body_stmt}; pub(crate) use class_captures::synthesize_class_captures; diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index dc313e31aa..8d3db230ff 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -229,6 +229,149 @@ pub unsafe extern "C" fn js_super_construct_apply( static KEEP_JS_SUPER_CONSTRUCT_APPLY: unsafe extern "C" fn(u32, f64, f64) -> f64 = js_super_construct_apply; +/// Dynamic `super.method(...)` dispatch for a class whose parent was registered +/// at runtime (`class X extends _mod.default` — wall 38/42). Static codegen +/// can't resolve the parent method (the textual parent name is "default", which +/// matches no compile-time class), so it falls back to this helper: resolve +/// `method_name` starting from the REGISTERED parent of `child_class_id` (NOT +/// the child itself — otherwise the child's own override is re-selected and +/// `super.m()` recurses forever) and invoke it on `this` with a flat f64 arg +/// buffer. Returns `undefined` when the method is not found on the parent chain. +/// +/// # Safety +/// `name_ptr` must be valid for `name_len` bytes; `args_ptr` for `args_len` +/// `f64`s (or null when `args_len == 0`). +#[no_mangle] +pub unsafe extern "C" fn js_super_method_call_dynamic( + child_class_id: u32, + name_ptr: *const u8, + name_len: usize, + this_value: f64, + args_ptr: *const f64, + args_len: usize, +) -> f64 { + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + if child_class_id == 0 || name_ptr.is_null() { + return undef; + } + let name = match std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) { + Ok(s) => s, + Err(_) => return undef, + }; + let parent_cid = match crate::object::get_parent_class_id(child_class_id) { + Some(p) if p != 0 => p, + _ => return undef, + }; + // `lookup_class_method_in_chain` resolves under the registry read lock and + // DROPS it before returning — the invoked method body may take the registry + // write lock (a lazy `require()` registering a module class), so we must not + // hold it across the call (the wall-37 deadlock). + let resolved = super::class_registry::lookup_class_method_in_chain(parent_cid, name); + if let Some((func_ptr, param_count, has_synth, has_rest)) = resolved { + let this_raw = (this_value.to_bits() & crate::value::POINTER_MASK) as i64; + return call_vtable_method( + func_ptr, + this_raw, + args_ptr, + args_len, + param_count, + has_synth, + has_rest, + ); + } + // The parent may be a function-style class whose method lives in the + // runtime prototype-method registry (`Base.prototype.m = ...` via + // `js_register_function_prototype_method`, or a synthetic prototype object + // wired by `js_set_function_prototype`) rather than the class vtable — + // these never land in `lookup_class_method_in_chain`. `lookup_prototype_method` + // walks the parent chain and drops its read lock before returning, so the + // invoked body may re-take the registry lock without deadlocking (wall-37). + if let Some(method_value) = super::class_registry::lookup_prototype_method(parent_cid, name) { + let prev_this = super::IMPLICIT_THIS.with(|c| c.replace(this_value.to_bits())); + let result = crate::closure::js_native_call_value(method_value, args_ptr, args_len); + super::IMPLICIT_THIS.with(|c| c.set(prev_this)); + return result; + } + undef +} + +/// Keepalive anchor (generated-code-only callee). +#[used] +static KEEP_JS_SUPER_METHOD_CALL_DYNAMIC: unsafe extern "C" fn( + u32, + *const u8, + usize, + f64, + *const f64, + usize, +) -> f64 = js_super_method_call_dynamic; + +/// Run the constructor of class `parent_cid` (or its nearest ctor-bearing +/// ancestor) on the EXISTING `this`, taking arguments from a flat f64 buffer — +/// the codegen `super()` ABI. Returns `true` when a constructor was found and +/// invoked. +/// +/// Used by `js_fetch_or_value_super` for the `class X extends _mod.default` +/// case where the dynamic parent value resolves to a ClassRef (a real +/// registered Perry class — Next.js `NextNodeServer extends base-server`'s +/// default `Server`). A ClassRef is NaN-tagged, so it is NOT callable via +/// `js_native_call_value` (that path early-returns `undefined`); the base +/// constructor would never run and parent `this. = …` writes would be +/// lost. This invokes the class constructor directly, mirroring +/// `js_super_construct_apply` but starting from `parent_cid` inclusive and +/// reading a flat arg buffer instead of an array handle. +/// +/// # Safety +/// `this_raw` must be a valid `ObjectHeader` pointer (as `i64`); `args_ptr` +/// must point to `args_len` valid `f64`s (or be null when `args_len == 0`). +pub(crate) unsafe fn run_class_constructor_on_this_flat( + parent_cid: u32, + this_raw: i64, + args_ptr: *const f64, + args_len: usize, +) -> bool { + if this_raw == 0 || parent_cid == 0 { + return false; + } + let undef = f64::from_bits(crate::value::TAG_UNDEFINED); + let mut cur = parent_cid; + let mut depth = 0usize; + while cur != 0 && depth < 64 { + if let Some((ctor_ptr, total_params)) = lookup_class_constructor(cur) { + let caps = class_capture_values(cur).unwrap_or_default(); + let user_params = (total_params as usize).saturating_sub(caps.len()); + let mut final_args: Vec = Vec::with_capacity(total_params as usize); + for i in 0..user_params { + if !args_ptr.is_null() && i < args_len { + final_args.push(*args_ptr.add(i)); + } else { + final_args.push(undef); + } + } + for bits in &caps { + final_args.push(f64::from_bits(*bits)); + } + let _ = call_vtable_method( + ctor_ptr, + this_raw, + final_args.as_ptr(), + final_args.len(), + total_params, + false, + false, + ); + return true; + } + let next = crate::object::get_parent_class_id(cur).unwrap_or(0); + if next == cur { + break; + } + cur = next; + depth += 1; + } + false +} + /// Append the spread of `value` to `target` (array handle), handling BOTH /// real arrays AND array-likes (Perry's `arguments` object is an /// ObjectHeader with "0".."n-1" + "length" props — `super(...arguments)` diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index a87e7140f9..4537ac41aa 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -269,6 +269,20 @@ pub static CLASS_DECL_PROTOTYPE_OBJECTS: RwLock>> = R /// Stored as `usize` (raw address) for Send + Sync; converted back at use. pub static CLASS_PARENT_CLOSURES: RwLock>> = RwLock::new(None); +/// Maps a child class_id to the raw NaN-boxed bits of the parent constructor +/// VALUE that `js_register_class_parent_dynamic` evaluated at class-definition +/// time. For `class X extends _mod.default {}` (the interop ESM +/// default-export-class pattern), the extends expression references a require +/// alias (`_mod`) that is an IIFE-local — bound only in the module-init scope. +/// The decl-time registration evaluates it there correctly, so we stash the +/// resulting value here keyed by the child's class id. `super()` then reads it +/// back via `js_get_dynamic_parent_value` instead of re-evaluating the extends +/// expression inside the constructor (where the IIFE-local alias is NOT +/// captured and the member read would throw "Cannot read properties of +/// undefined"). Stored as raw `u64` bits (Send + Sync), covering both ClassRef +/// (INT32-tagged) and object/closure (POINTER-tagged) parents. +pub static CLASS_DYNAMIC_PARENT_VALUE: RwLock>> = RwLock::new(None); + pub(crate) fn class_prototype_object_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { if class_id == 0 || proto_ptr.is_null() { return; @@ -3111,6 +3125,20 @@ pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisit } } + // The dynamic-parent value stash (`class X extends _mod.default`) holds + // raw NaN-boxed parent-constructor bits. For a ClassRef (INT32-tagged) + // parent this is inert, but a function/object parent (Effect's + // `extends `) is a live heap pointer that a moving GC must + // visit + forward — otherwise `js_get_dynamic_parent_value` later hands + // `super()` a stale pointer. + if let Ok(mut guard) = CLASS_DYNAMIC_PARENT_VALUE.write() { + if let Some(map) = guard.as_mut() { + for value_bits in map.values_mut() { + visitor.visit_nanbox_u64_slot(value_bits); + } + } + } + scan_class_symbol_member_keys_mut(visitor); scan_function_class_id_keys_mut(visitor); } @@ -4345,6 +4373,24 @@ pub extern "C" fn js_register_class_parent(class_id: u32, parent_class_id: u32) /// recursive helper that returns its receiver can't create a cycle. #[no_mangle] pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, parent_value: f64) { + // Stash the parent VALUE keyed by child class id so `super()` can read it + // back (`js_get_dynamic_parent_value`) instead of re-evaluating the extends + // expression inside the constructor scope. The decl-time call here runs in + // the module-init scope where the extends expression's free variables + // (require aliases such as `_suffix` in `class X extends _suffix.default`) + // are bound. Skip undefined (the bare placeholder) — a genuinely undefined + // superclass throws below anyway. + { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + let bits = parent_value.to_bits(); + if bits != TAG_UNDEFINED && class_id != 0 { + let mut guard = CLASS_DYNAMIC_PARENT_VALUE.write().unwrap(); + if guard.is_none() { + *guard = Some(HashMap::new()); + } + guard.as_mut().unwrap().insert(class_id, bits); + } + } // A globalThis builtin constructor closure is a valid superclass // (`class CloseEvent extends Event` — the `ws` package's WebSocket // events). Resolve it through the same name table the dynamic @@ -4465,6 +4511,27 @@ pub extern "C" fn js_register_class_parent_dynamic(class_id: u32, parent_value: } } +/// Read back the parent constructor value stashed at class-definition time by +/// `js_register_class_parent_dynamic` (see `CLASS_DYNAMIC_PARENT_VALUE`). +/// `super()` in a `class X extends ` body uses this so the +/// parent is resolved from the value captured in the module-init scope, not +/// re-evaluated in the constructor scope (where an IIFE-local require alias +/// like `_suffix` in `extends _suffix.default` is not in scope). Returns +/// `undefined` when nothing was stashed for this class id — the caller then +/// falls back to re-evaluating its extends expression. +#[no_mangle] +pub extern "C" fn js_get_dynamic_parent_value(class_id: u32) -> f64 { + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; + if class_id == 0 { + return f64::from_bits(TAG_UNDEFINED); + } + let guard = CLASS_DYNAMIC_PARENT_VALUE.read().unwrap(); + match guard.as_ref().and_then(|m| m.get(&class_id)) { + Some(&bits) => f64::from_bits(bits), + None => f64::from_bits(TAG_UNDEFINED), + } +} + /// #1789: stamp a freshly-allocated object as a heap "class object" (the /// value a class EXPRESSION evaluates to). Sets `object_type = /// OBJECT_TYPE_CLASS` so `typeof` reports "function" and `new`/`instanceof` diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index ff40fe3f3c..1b502d4a5c 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -477,6 +477,32 @@ pub unsafe extern "C" fn js_fetch_or_value_super( const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; + // A dynamic parent that resolved to a ClassRef (INT32-tagged) is a + // real registered Perry class — `class X extends _mod.default` + // where the default export is a user class (Next.js + // `NextNodeServer extends base-server`'s default `Server`). A + // ClassRef is NaN-tagged, so `js_native_call_value` below would + // early-return `undefined` (it treats NaN as not callable) and the + // base constructor would never run — parent `this. = …` + // writes (e.g. `this.nextConfig = opts`) would be lost. Invoke the + // class constructor directly on `this` instead. + if bits & TAG_MASK == INT32_TAG { + let parent_cid = bits as u32; + if let Some(obj) = subclass_this_object_ptr(this_box) { + super::class_constructors::run_class_constructor_on_this_flat( + parent_cid, obj as i64, args_ptr, args_len, + ); + } + // A ClassRef is NaN-tagged and is NEVER callable via + // `js_native_call_value` (it early-returns `undefined`). Return + // here unconditionally — whether or not a constructor was found + // and run — instead of falling through to the closure-dispatch + // path below, which would (a) silently produce `undefined` and + // (b) skip the `parent_closure_in_chain` recovery that only + // applies to closure/object parents, not a ClassRef. + return undef; + } let usable = if bits & TAG_MASK == POINTER_TAG { let p = (bits & PTR_MASK) as usize; // A real callability test: a closure, or a per-evaluation class diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 949d2be28d..b227fde632 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -3635,19 +3635,42 @@ pub unsafe extern "C" fn js_native_call_method( has_rest, ); } + // Refs #420: walk the parent chain via the class registry. Per + // JS spec, `subInstance.method()` for a method defined on a + // parent dispatches to the parent's implementation — drizzle's + // `serial("id").primaryKey()` where primaryKey is on + // ColumnBuilder (grandparent) but the receiver is a + // PgSerialBuilder (grandchild). The codegen-side dispatch tower + // in `lower_call.rs` only registers classes the importing module + // knows about; for not-by-name-imported subclasses (return + // values of imported functions) we depend on this runtime walk. + // + // DEADLOCK SAFETY: resolve the target under the registry READ + // lock, then DROP the lock before invoking the method body. + // A user method body can lazily init a module (function-local + // `require()` — Next.js `getServerImpl()` → `require('./next- + // server')`) whose top-level `class` declarations call + // `js_register_class_method` → a registry WRITE lock. std + // `RwLock` is not re-entrant, so holding the read guard across + // the call deadlocked the (single) main thread. + enum ResolvedMethod { + Vtable { + func_ptr: usize, + param_count: u32, + has_synthetic_arguments: bool, + has_rest: bool, + this_i64: i64, + }, + // #711 part 2 / #321: a method that is an own-property of a + // registered prototype object (`Function.prototype = X`, + // effect's `EffectPrototype.pipe`). + ProtoClosure { + field_bits: u64, + }, + } + let mut resolved_method: Option = None; if let Ok(registry) = CLASS_VTABLE_REGISTRY.read() { if let Some(ref reg) = *registry { - // Refs #420: walk the parent chain via the class - // registry. Per JS spec, `subInstance.method()` for - // a method defined on a parent dispatches to the - // parent's implementation — drizzle's - // `serial("id").primaryKey()` where primaryKey is on - // ColumnBuilder (grandparent) but the receiver is a - // PgSerialBuilder (grandchild). The codegen-side - // dispatch tower in `lower_call.rs` only registers - // classes the importing module knows about; for - // not-by-name-imported subclasses (return values of - // imported functions) we depend on this runtime walk. let mut cur_cid = class_id; let mut depth = 0u32; while depth < 32 { @@ -3661,26 +3684,16 @@ pub unsafe extern "C" fn js_native_call_method( entry.has_synthetic_arguments, entry.has_rest, ); - let this_i64 = jsval.as_pointer::() as i64; - return call_vtable_method( - entry.func_ptr, - this_i64, - args_ptr, - args_len, - entry.param_count, - entry.has_synthetic_arguments, - entry.has_rest, - ); + resolved_method = Some(ResolvedMethod::Vtable { + func_ptr: entry.func_ptr, + param_count: entry.param_count, + has_synthetic_arguments: entry.has_synthetic_arguments, + has_rest: entry.has_rest, + this_i64: jsval.as_pointer::() as i64, + }); + break; } } - // Issue #711 part 2: if this class id has a - // registered prototype object (from - // `Function.prototype = X`), look up the - // method as a regular property of that - // object. Effect's `EffectPrototype.pipe()` - // and friends are own-properties of the - // proto object; the value is a closure that - // expects `this = receiver`. let proto_obj = class_prototype_object(cur_cid); if !proto_obj.is_null() { let method_key = crate::string::js_string_from_bytes( @@ -3692,37 +3705,10 @@ pub unsafe extern "C" fn js_native_call_method( method_key as *const crate::StringHeader, ); if !field_val.is_undefined() && !field_val.is_null() { - // #321 (effect Context/Layer/Scope): the - // method we just read is an *inherited* - // own-property of the prototype object - // `proto_obj`, not of the receiver. When - // it is an object-literal method - // (`captures_this:true`), its reserved - // capture slot was baked to the PROTOTYPE - // at construction time, so invoking it - // with `IMPLICIT_THIS = receiver` still - // reads `this === proto`. Rebind the - // closure's `this` slot to the receiver - // first (same treatment as the symbol path - // #1969 and the `#809` arm below). - // `clone_closure_rebind_this` is a no-op - // for closures that don't capture `this` - // (e.g. effect's `EffectPrototype.pipe`, - // which reads `this` from `IMPLICIT_THIS`) - // and for non-closure values, so those - // paths are unaffected. - let bound = crate::closure::clone_closure_rebind_this( - field_val.bits(), - f64::from_bits(jsval.bits()), - ); - let prev_this = IMPLICIT_THIS.with(|c| c.replace(jsval.bits())); - let result = crate::closure::js_native_call_value( - f64::from_bits(bound), - args_ptr, - args_len, - ); - IMPLICIT_THIS.with(|c| c.set(prev_this)); - return result; + resolved_method = Some(ResolvedMethod::ProtoClosure { + field_bits: field_val.bits(), + }); + break; } } match get_parent_class_id(cur_cid) { @@ -3735,6 +3721,46 @@ pub unsafe extern "C" fn js_native_call_method( } } } + // Registry guard released — safe to run the method body (which + // may register classes via lazy module init). + match resolved_method { + Some(ResolvedMethod::Vtable { + func_ptr, + param_count, + has_synthetic_arguments, + has_rest, + this_i64, + }) => { + return call_vtable_method( + func_ptr, + this_i64, + args_ptr, + args_len, + param_count, + has_synthetic_arguments, + has_rest, + ); + } + Some(ResolvedMethod::ProtoClosure { field_bits }) => { + // #321 (effect Context/Layer/Scope): rebind the closure's + // `this` slot to the receiver — `clone_closure_rebind_this` + // is a no-op for closures that don't capture `this` and for + // non-closure values, so those paths are unaffected. + let bound = crate::closure::clone_closure_rebind_this( + field_bits, + f64::from_bits(jsval.bits()), + ); + let prev_this = IMPLICIT_THIS.with(|c| c.replace(jsval.bits())); + let result = crate::closure::js_native_call_value( + f64::from_bits(bound), + args_ptr, + args_len, + ); + IMPLICIT_THIS.with(|c| c.set(prev_this)); + return result; + } + None => {} + } // #809: independent prototype-object resolution. The walk // above only runs when `CLASS_VTABLE_REGISTRY` is `Some` — // a program with no user classes that only does diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs index 8b77e578a0..dcacbf8909 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs @@ -385,6 +385,46 @@ pub fn extract_exports_from_source(source: &str) -> Vec { let mut search_from = 0usize; while let Some(idx) = source[search_from..].find("module.exports") { let abs = search_from + idx; + // Skip a `module.exports = { … }` that is in EXPRESSION position rather + // than at a statement boundary. The dominant case is the + // `0 && (module.exports = { X: null, Y: null })` idiom that Babel / + // TypeScript emit as a DEAD type-only export hint (the values are `null` + // placeholders; the real exports are installed separately, e.g. via + // `_export(exports, { X: () => X })` getters). Treating the + // placeholder's keys as named exports overrode the real getter exports + // with `undefined` (Next.js `built/pages` → `PagesNormalizers` + // undefined → "undefined is not a constructor"). + // + // A real top-level assignment is fine even when it's not at column 0 + // (`exports.a = 1; module.exports = { b, c }`) — accept it at a true + // statement boundary. The dead `0 && (module.exports = …)` hint (and + // its newline-split form `0 && (\n module.exports = …\n)`) sits in + // EXPRESSION position, so the nearest preceding non-whitespace token — + // searched ACROSS newlines, not just the current line — is `(` (or an + // operator), never a statement terminator. + let prefix = &source[..abs]; + let prev_token = prefix.bytes().rev().find(|b| !b.is_ascii_whitespace()); + // Statement terminator / block brace / start-of-file → real statement. + let stmt_boundary = matches!(prev_token, None | Some(b';') | Some(b'}') | Some(b'{')); + // Operator / open-bracket / comma → the assignment continues an + // expression (`0 && (…`, `x =`, `a,`), so it is NOT a real export. + let expr_continuation = matches!( + prev_token, + Some(b'(' | b'&' | b'|' | b',' | b'=' | b'?' | b':' | b'+' | b'-' | b'*' | b'<' | b'>') + ); + // A clean column-0 / ASI line start is also acceptable (e.g. after a + // block comment) — but only when the previous token isn't a dangling + // expression continuation. + let line_start_ok = prefix + .bytes() + .rev() + .take_while(|&b| b != b'\n') + .all(|b| b == b' ' || b == b'\t'); + let accept = stmt_boundary || (line_start_ok && !expr_continuation); + if !accept { + search_from = abs + 1; + continue; + } // Skip past `module.exports` let mut p = abs + "module.exports".len(); // Skip whitespace diff --git a/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs b/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs index 93aac6563a..e2deb48aa4 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/hoist_classes.rs @@ -353,8 +353,22 @@ pub fn extract_top_level_class_decls(source: &str) -> (String, Vec, Stri // in which case hoisting would sever the closure (#2310). let block_text = std::str::from_utf8(&bytes[line_start..r]).unwrap_or(""); let body_text = std::str::from_utf8(&bytes[body_start..r]).unwrap_or(""); + // The `extends` clause head (between the class name and the + // body's opening `{`) can reference an IIFE-local require + // alias, e.g. `class Derived extends _suffix.default { ... }` + // (the Next.js `NextNodeServer extends base-server.default` + // interop pattern). Hoisting the class above its + // `const _suffix = _interop(require(...))` evaluates the + // parent before the alias is assigned, so the dynamic + // parent-registration sees `undefined` and throws "Class + // extends value is not a constructor". Treat an extends-head + // reference to an IIFE-local the same as a body reference: + // keep the class inside the IIFE at its source position. + let extends_head = + std::str::from_utf8(&bytes[name_end..body_start]).unwrap_or(""); let references_iife_local = - class_body_references_any(body_text, &iife_locals); + class_body_references_any(body_text, &iife_locals) + || class_body_references_any(extends_head, &iife_locals); if !hoisted_names.contains(&class_name) && !references_iife_local { hoisted_blocks.push(block_text); hoisted_names.push(class_name); diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 10b1c47fb2..df7c18cc5c 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -627,7 +627,7 @@ pub(in crate::commands::compile) fn wrap_commonjs_for_target( if (typeof specifier !== 'string') throw __perry_cjs_require_error('type', 'ERR_INVALID_ARG_TYPE', 'The "id" argument must be of type string.'); if (specifier === '') throw __perry_cjs_require_error('type', 'ERR_INVALID_ARG_VALUE', 'The argument "id" must be a non-empty string.'); {require_cases} - throw new Error('require() is not supported: ' + specifier); + throw __perry_cjs_require_error('error', 'MODULE_NOT_FOUND', "Cannot find module '" + specifier + "'"); }} Object.defineProperty(require, 'name', {{ value: 'require',