From 4fcf6191817f0d08a4b49dad40b7dadfd337a799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 06:21:49 +0200 Subject: [PATCH 1/2] fix(runtime): preserve evaluated Error heritage --- crates/perry-runtime/src/object/instanceof.rs | 14 ++ ...sue_9940_compile_package_error_identity.rs | 135 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 crates/perry/tests/issue_9940_compile_package_error_identity.rs diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 8c537cacbc..a688ed8f8e 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -1755,6 +1755,20 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // For user-defined classes that extend Error: `myErr instanceof Error` should be true. if class_id == crate::error::CLASS_ID_ERROR { + // #9940: a function-local class declaration gets a fresh class + // object on every evaluation, but all evaluations share its + // compile-time class id. A constructor factory can therefore + // evaluate `class Definition extends Error {}`, then later + // evaluate the same declaration with an Object parent. The class + // registry is keyed by the shared id and is necessarily + // last-wins; the instance's recorded evaluation prototype is the + // authoritative chain. Zod's `$constructor` has exactly this + // shape, and its later schema classes made an earlier ZodError + // fail `instanceof Error` even though getPrototypeOf still showed + // `ZodError -> Error -> Object`. + if let Some(matches) = recorded_prototype_instanceof_builtin(value, "Error") { + return if matches { true_val } else { false_val }; + } let obj_class_id = (*obj_ptr).class_id; if extends_builtin_error(obj_class_id) { return true_val; diff --git a/crates/perry/tests/issue_9940_compile_package_error_identity.rs b/crates/perry/tests/issue_9940_compile_package_error_identity.rs new file mode 100644 index 0000000000..00fdfdcf4c --- /dev/null +++ b/crates/perry/tests/issue_9940_compile_package_error_identity.rs @@ -0,0 +1,135 @@ +//! Regression test for #9940: an Error subclass declared in a +//! `compilePackages` dependency must share the application's global Error +//! identity. Frameworks such as Hono use `value instanceof Error` to decide +//! whether a thrown value reaches their error handler. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn compiled_package_error_subclass_is_instanceof_global_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + std::fs::write( + root.join("package.json"), + r#"{ + "name": "compile-package-error-identity", + "private": true, + "type": "module", + "perry": { + "compilePackages": ["error-package"], + "allow": { "compilePackages": ["error-package"] } + } +}"#, + ) + .expect("write consumer package.json"); + + let package = root.join("node_modules").join("error-package"); + std::fs::create_dir_all(package.join("src")).expect("mkdir error-package/src"); + std::fs::write( + package.join("package.json"), + r#"{ + "name": "error-package", + "version": "1.0.0", + "type": "module", + "exports": { ".": "./index.js" } +}"#, + ) + .expect("write error-package package.json"); + std::fs::write( + package.join("index.js"), + "export { PackageError, makeError } from \"./src/errors.js\";\n", + ) + .expect("write error-package published entry"); + std::fs::write( + package.join("src/core.ts"), + r#"export function constructorFactory(name: string, params?: { Parent?: any }): any { + const Parent = params?.Parent ?? Object; + class Definition extends Parent {} + function DynamicPackageError(message: string) { + const inst: any = params?.Parent ? new Definition() : this; + inst.message = message; + inst.kind = name; + return inst; + } + Object.defineProperty(DynamicPackageError, Symbol.hasInstance, { + value: (inst: any) => inst?.kind === name, + }); + Object.defineProperty(DynamicPackageError, "name", { value: name }); + return DynamicPackageError; +} +"#, + ) + .expect("write error-package core.ts"); + std::fs::write( + package.join("src/errors.ts"), + r#"import { constructorFactory } from "./core.js"; + +// Zod creates many Object-backed classes from this factory before it creates +// its Error-backed class from the same nested class declaration. +export const PlainThing = constructorFactory("PlainThing"); +export const PackageError = constructorFactory("PackageError", { Parent: Error }); +export const AnotherPlainThing = constructorFactory("AnotherPlainThing"); +export function makeError(message: string): any { + return new PackageError(message); +} +"#, + ) + .expect("write error-package errors.ts"); + std::fs::write( + package.join("src/index.ts"), + "export { PackageError, makeError } from \"./errors.js\";\n", + ) + .expect("write error-package src/index.ts"); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#"import { makeError } from "error-package"; + +const packageError: any = makeError("bad input"); +class AppError extends Error {} +console.log( + packageError instanceof Error, + new AppError("app") instanceof Error, + new Error("plain") instanceof Error +); +"#, + ) + .expect("write entry"); + + let output = root.join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(root) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .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).output().expect("run compiled binary"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!( + run.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + stdout, + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + stdout, "true true true\n", + "the package subclass must inherit the application's global Error identity" + ); +} From bb077b0a43a0936ff7dc893bf252bf5bc32447b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 06:22:39 +0200 Subject: [PATCH 2/2] docs(changelog): record Error heritage fix --- changelog.d/9946-compile-package-error-identity.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/9946-compile-package-error-identity.md diff --git a/changelog.d/9946-compile-package-error-identity.md b/changelog.d/9946-compile-package-error-identity.md new file mode 100644 index 0000000000..19e5bd7a67 --- /dev/null +++ b/changelog.d/9946-compile-package-error-identity.md @@ -0,0 +1 @@ +Fixed Error subclasses created by repeated `compilePackages` class factories to remain instances of the global `Error` constructor.