From 083e8a1d2b3be8164acea2a3c50935c338d71b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 00:29:59 +0200 Subject: [PATCH 1/2] fix(runtime): inherit static DATA fields from a parent class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subclass inherits its parent's static data properties via the class-object prototype chain (`Sub.__proto__ === Base`) — both in-body `static x = …` fields and runtime `Base.x = …` assignments. Perry inherited static METHODS (they walk the class_id chain) but the static-DATA-field read only consulted the receiver class's own `CLASS_DYNAMIC_PROPS`, so `Sub.x` returned `undefined` even though `Base` defined `x`. `get_field_by_name` now walks the `get_parent_class_id` chain after an own-field miss, reading each ancestor's `CLASS_DYNAMIC_PROPS` (and honoring a deleted key on an ancestor). This mirrors the existing static-method chain walk. Surfaced by Auth.js v5: `SignInError.kind = "signIn"` is read off a `CredentialsSignin` subclass (`this.constructor.kind`) to choose the sign-in vs error redirect page; the missing inheritance sent every credentials error to `/auth/error` instead of `/auth/login`. --- .../object/field_get_set/get_field_by_name.rs | 35 +++++++++++++++++++ .../test_gap_static_field_inheritance.ts | 21 +++++++++++ 2 files changed, 56 insertions(+) create mode 100644 test-files/test_gap_static_field_inheritance.ts diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 630cc8c066..5d9eab0c1d 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -852,6 +852,41 @@ pub extern "C" fn js_object_get_field_by_name( if let Some(v) = result { return JSValue::from_bits(v.to_bits()); } + // Static DATA fields are INHERITED by subclasses, exactly like + // static methods: `class D {}; D.kind = "x"; class G extends D {}` + // makes `G.kind === "x"` (the class-object proto chain + // `G.__proto__ === D` carries statics). The own-field read above + // only consulted `class_id`; walk the parent class_id chain here + // so an inherited static field (or runtime `Parent.x = …` + // assignment — both live in CLASS_DYNAMIC_PROPS) resolves. Static + // METHODS are handled by `lookup_static_method_in_chain` below; + // this covers the data-field case that was returning `undefined` + // (Auth.js sets `SignInError.kind = "signIn"` and reads it off a + // `CredentialsSignin` subclass to pick the sign-in vs error page). + { + let mut cid = class_id; + let mut depth = 0usize; + while depth < 32 { + match get_parent_class_id(cid) { + Some(p) if p != 0 && p != cid => { + cid = p; + depth += 1; + } + _ => break, + } + if super::super::class_registry::class_is_key_deleted(cid, name) { + break; + } + let inherited = CLASS_DYNAMIC_PROPS.with(|m| { + m.borrow() + .get(&cid) + .and_then(|props| props.get(name).copied()) + }); + if let Some(v) = inherited { + return JSValue::from_bits(v.to_bits()); + } + } + } if super::super::class_registry::lookup_static_method_in_chain(class_id, name) .is_some() { diff --git a/test-files/test_gap_static_field_inheritance.ts b/test-files/test_gap_static_field_inheritance.ts new file mode 100644 index 0000000000..6485e0c4d0 --- /dev/null +++ b/test-files/test_gap_static_field_inheritance.ts @@ -0,0 +1,21 @@ +// A subclass inherits its parent's static DATA properties — both in-body static +// fields and runtime `Parent.x = …` assignments — via the class-object prototype +// chain (`Sub.__proto__ === Base`). Regression: perry inherited static METHODS +// but returned `undefined` for inherited static data fields, so +// `Sub.tag` / `Sub.kind` read undefined even though `Base` defined them. (Auth.js +// sets `SignInError.kind = "signIn"` and reads it off a `CredentialsSignin` +// subclass to choose the sign-in vs error redirect page.) +class Base { + static inBody = "field"; +} +(Base as any).external = "assigned"; +class Sub extends Base {} +class Leaf extends Sub {} +console.log("Sub.inBody=" + (Sub as any).inBody); +console.log("Sub.external=" + (Sub as any).external); +console.log("Leaf.inBody=" + (Leaf as any).inBody); +console.log("Leaf.external=" + (Leaf as any).external); +// own field still wins over inherited +(Sub as any).external = "own"; +console.log("Sub.own-wins=" + (Sub as any).external); +console.log("Base.unshadowed=" + (Base as any).external); From 675e8eb00f25225377795bd5d513d80c9a80f5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 16 Jul 2026 02:26:58 +0200 Subject: [PATCH 2/2] fix(runtime): continue past a deleted intermediate static in the parent-chain walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static-data-field inheritance walk broke out of the loop when a key was marked deleted on an intermediate ancestor. But `delete Mid.foo` only removes Mid's own static — a higher ancestor may still define it, so `Sub.foo` must inherit `Base.foo`, not resolve to undefined. `break` aborted the whole traversal; `continue` skips the deleted level and keeps walking up. Safe: `cid` and `depth` both advance at the top of every iteration, so it can't loop. Verified against node: `delete Mid.tag; Sub.tag` -> "base" with the fix, "undefined" without it. Extended test_gap_static_field_inheritance. --- .../src/object/field_get_set/get_field_by_name.rs | 8 +++++++- test-files/test_gap_static_field_inheritance.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 5d9eab0c1d..49065fe33c 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -875,7 +875,13 @@ pub extern "C" fn js_object_get_field_by_name( _ => break, } if super::super::class_registry::class_is_key_deleted(cid, name) { - break; + // A key deleted on THIS ancestor is not provided by + // it, but a higher ancestor may still define it — + // `delete Mid.foo` must let `Sub.foo` inherit + // `Base.foo`, not resolve to undefined. Skip this + // level and keep walking up (safe: `cid`/`depth` + // advance at the top of every iteration). + continue; } let inherited = CLASS_DYNAMIC_PROPS.with(|m| { m.borrow() diff --git a/test-files/test_gap_static_field_inheritance.ts b/test-files/test_gap_static_field_inheritance.ts index 6485e0c4d0..989b2b090c 100644 --- a/test-files/test_gap_static_field_inheritance.ts +++ b/test-files/test_gap_static_field_inheritance.ts @@ -19,3 +19,14 @@ console.log("Leaf.external=" + (Leaf as any).external); (Sub as any).external = "own"; console.log("Sub.own-wins=" + (Sub as any).external); console.log("Base.unshadowed=" + (Base as any).external); + +// A key deleted on an INTERMEDIATE ancestor must not shadow a higher one: +// `delete Mid.foo` should let `Sub.foo` inherit `Base.foo`, not resolve to +// undefined. (The chain walk must `continue` past the deleted level, not +// `break` out of the whole traversal.) +class DBase { static tag = "base"; } +class DMid extends DBase { static tag = "mid"; } +class DSub extends DMid {} +console.log("del-intermediate before:", (DSub as any).tag); +delete (DMid as any).tag; +console.log("del-intermediate after :", (DSub as any).tag);