From af5c3fd4f8b2ff8b4efab650242b1d26b9c8ee65 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Wed, 15 Jul 2026 22:22:03 -0400 Subject: [PATCH 1/3] feat(engine): add WeakJsObject weak reference type Introduce a WeakJsObject type in boa_engine that lets embedders hold a weak reference to a JsObject without keeping it alive across garbage collections. This is the object-level counterpart of boa_gc::WeakGc. The mechanism already existed internally: the JS-level WeakRef builtin constructs a WeakGc> via WeakGc::new(target.inner()), but JsObject::inner() is pub(crate), so embedders had no public API to build a weak object reference. WeakJsObject wraps that construction and exposes new / upgrade / is_upgradable, mirroring WeakGc's surface, plus Clone, PartialEq, Eq and Hash. Debug is implemented by hand because VTableObject intentionally does not implement Debug. Closes #5420 Co-Authored-By: Claude Opus 4.8 --- core/engine/src/object/mod.rs | 2 + core/engine/src/object/weak.rs | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 core/engine/src/object/weak.rs diff --git a/core/engine/src/object/mod.rs b/core/engine/src/object/mod.rs index 258da691647..19e470928f9 100644 --- a/core/engine/src/object/mod.rs +++ b/core/engine/src/object/mod.rs @@ -36,6 +36,7 @@ mod datatypes; mod jsobject; mod operations; mod property_map; +mod weak; pub mod shape; @@ -43,6 +44,7 @@ pub(crate) use builtins::*; pub use datatypes::JsData; pub use jsobject::*; +pub use weak::WeakJsObject; /// Const `constructor`, usually set on prototypes as a key to point to their respective constructor object. pub const CONSTRUCTOR: JsString = js_string!("constructor"); diff --git a/core/engine/src/object/weak.rs b/core/engine/src/object/weak.rs new file mode 100644 index 00000000000..99e9c948607 --- /dev/null +++ b/core/engine/src/object/weak.rs @@ -0,0 +1,96 @@ +//! This module implements the [`WeakJsObject`] structure. +//! +//! A [`WeakJsObject`] is a weak reference to a [`JsObject`], allowing an embedder to hold a +//! reference to an object without keeping it alive across garbage collections. + +use super::{ErasedObjectData, JsObject, NativeObject, jsobject::VTableObject}; +use boa_gc::{Finalize, Trace, WeakGc}; +use std::{ + fmt::{self, Debug}, + hash::{Hash, Hasher}, +}; + +/// A weak reference to a [`JsObject`]. +/// +/// This is the object-level counterpart of [`boa_gc::WeakGc`]. It lets embedders keep a handle to a +/// [`JsObject`] without preventing it from being collected. Because the referenced object may be +/// collected at any point, [`WeakJsObject::upgrade`] returns an `Option>` that is `None` +/// once the object is gone. +/// +/// # Examples +/// +/// ``` +/// # use boa_engine::object::{JsObject, WeakJsObject}; +/// let object = JsObject::with_null_proto(); +/// let weak = WeakJsObject::new(&object); +/// +/// // While `object` is alive, the weak reference can be upgraded. +/// assert!(weak.upgrade().is_some()); +/// ``` +#[derive(Trace, Finalize)] +pub struct WeakJsObject { + inner: WeakGc>, +} + +impl WeakJsObject { + /// Creates a new weak reference to the given [`JsObject`]. + #[inline] + #[must_use] + pub fn new(object: &JsObject) -> Self { + Self { + inner: WeakGc::new(object.inner()), + } + } + + /// Upgrades the weak reference to a strong [`JsObject`] if the referenced object is still live, + /// or returns `None` if it was already garbage collected. + #[inline] + #[must_use] + pub fn upgrade(&self) -> Option> { + self.inner.upgrade().map(JsObject::from_inner) + } + + /// Checks whether this weak reference can still be upgraded to a live [`JsObject`]. + #[inline] + #[must_use] + pub fn is_upgradable(&self) -> bool { + self.inner.is_upgradable() + } +} + +impl From<&JsObject> for WeakJsObject { + fn from(object: &JsObject) -> Self { + Self::new(object) + } +} + +impl Clone for WeakJsObject { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl PartialEq for WeakJsObject { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + +impl Eq for WeakJsObject {} + +impl Hash for WeakJsObject { + fn hash(&self, state: &mut H) { + self.inner.hash(state); + } +} + +// `VTableObject` deliberately does not implement `Debug` to avoid recursing into the object graph +// (which could overflow the stack), so we cannot derive `Debug` here. We provide a minimal, +// non-recursive implementation instead. +impl Debug for WeakJsObject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WeakJsObject").finish_non_exhaustive() + } +} From ea52537bf34f6316d52bc3a1bc5e052f43760310 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Sat, 18 Jul 2026 22:43:14 -0400 Subject: [PATCH 2/3] test(engine): add tests for WeakJsObject Cover the weak reference behaviour of WeakJsObject: upgrading while the referent is live, upgrade returning None after the referent is collected, clone sharing the same referent, equality by referent identity, and construction via the From<&JsObject> conversion. The collection test uses boa_gc::force_collect to drive the garbage collector, mirroring the existing WeakGc tests in core/gc. Refs #5420 Co-Authored-By: Claude Opus 4.8 --- core/engine/src/object/weak.rs | 68 ++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/core/engine/src/object/weak.rs b/core/engine/src/object/weak.rs index 99e9c948607..dd7d5d56051 100644 --- a/core/engine/src/object/weak.rs +++ b/core/engine/src/object/weak.rs @@ -94,3 +94,71 @@ impl Debug for WeakJsObject { f.debug_struct("WeakJsObject").finish_non_exhaustive() } } + +#[cfg(test)] +mod tests { + use super::{JsObject, WeakJsObject}; + use boa_gc::force_collect; + + #[test] + fn upgrade_while_referent_is_live() { + let object = JsObject::with_null_proto(); + let weak = WeakJsObject::new(&object); + + assert!(weak.is_upgradable()); + let upgraded = weak.upgrade().expect("referent is still alive"); + assert_eq!(upgraded, object); + } + + #[test] + fn upgrade_returns_none_after_referent_is_collected() { + let object = JsObject::with_null_proto(); + let weak = WeakJsObject::new(&object); + + // While the strong reference is alive the weak one can be upgraded, even across a collection. + force_collect(); + assert!(weak.is_upgradable()); + assert!(weak.upgrade().is_some()); + + // Once the last strong reference is gone the referent can be collected. + drop(object); + force_collect(); + + assert!(!weak.is_upgradable()); + assert!(weak.upgrade().is_none()); + } + + #[test] + fn clone_points_to_the_same_referent() { + let object = JsObject::with_null_proto(); + let weak = WeakJsObject::new(&object); + let cloned = weak.clone(); + + assert_eq!(weak, cloned); + assert_eq!( + weak.upgrade().expect("live"), + cloned.upgrade().expect("live") + ); + } + + #[test] + fn equality_is_by_referent_identity() { + let a = JsObject::with_null_proto(); + let b = JsObject::with_null_proto(); + + let weak_a1 = WeakJsObject::new(&a); + let weak_a2 = WeakJsObject::new(&a); + let weak_b = WeakJsObject::new(&b); + + assert_eq!(weak_a1, weak_a2); + assert_ne!(weak_a1, weak_b); + } + + #[test] + fn built_from_reference_via_conversion() { + let object = JsObject::with_null_proto(); + let weak: WeakJsObject = (&object).into(); + + assert_eq!(weak.upgrade().expect("live"), object); + } +} From cd7e25648879ada9c47c8a5c24133f18c38e4e29 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Sat, 18 Jul 2026 23:40:59 -0400 Subject: [PATCH 3/3] refactor(engine): drop PartialEq/Eq/Hash from WeakJsObject The issue only asked for a straightforward WeakJsObject wrapper, and the Eq/Hash impls (mirrored from WeakGc) are unsound to expose on a public API: forwarding to WeakGc compares and hashes by the live referent, so once the object is collected two references to it stop comparing equal and change their hash. That breaks Eq reflexivity (a == a becomes false) and the Hash/Eq invariant for a collected key, making WeakJsObject unusable as a long-lived HashMap/HashSet key, which is the archetypal use of a weak reference. Drop these impls from the initial surface; they can be added back later without a breaking change once a collection-stable identity is designed. To compare weak references, upgrade them and compare the JsObjects. Also expand the tests: an upgraded handle keeps the referent alive across a later collection, all weak references to one object die together, and the generic works with a typed (non-erased) object. Refs #5420 Co-Authored-By: Claude Opus 4.8 --- core/engine/src/object/weak.rs | 82 +++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/core/engine/src/object/weak.rs b/core/engine/src/object/weak.rs index dd7d5d56051..569ed24222f 100644 --- a/core/engine/src/object/weak.rs +++ b/core/engine/src/object/weak.rs @@ -5,10 +5,7 @@ use super::{ErasedObjectData, JsObject, NativeObject, jsobject::VTableObject}; use boa_gc::{Finalize, Trace, WeakGc}; -use std::{ - fmt::{self, Debug}, - hash::{Hash, Hasher}, -}; +use std::fmt::{self, Debug}; /// A weak reference to a [`JsObject`]. /// @@ -72,19 +69,12 @@ impl Clone for WeakJsObject { } } -impl PartialEq for WeakJsObject { - fn eq(&self, other: &Self) -> bool { - self.inner == other.inner - } -} - -impl Eq for WeakJsObject {} - -impl Hash for WeakJsObject { - fn hash(&self, state: &mut H) { - self.inner.hash(state); - } -} +// `PartialEq`/`Eq`/`Hash` are intentionally not implemented. The natural forwarding to `WeakGc` +// would compare and hash by the *live* referent, so two references to the same object would stop +// comparing equal (and change their hash) once that object is collected, breaking `Eq`'s +// reflexivity and the `Hash`/`Eq` invariant for a collected key. To compare two weak references, +// upgrade them first and compare the resulting `JsObject`s. These impls can be added later, without +// a breaking change, if a collection-stable identity is designed. // `VTableObject` deliberately does not implement `Debug` to avoid recursing into the object graph // (which could overflow the stack), so we cannot derive `Debug` here. We provide a minimal, @@ -134,7 +124,6 @@ mod tests { let weak = WeakJsObject::new(&object); let cloned = weak.clone(); - assert_eq!(weak, cloned); assert_eq!( weak.upgrade().expect("live"), cloned.upgrade().expect("live") @@ -142,16 +131,57 @@ mod tests { } #[test] - fn equality_is_by_referent_identity() { - let a = JsObject::with_null_proto(); - let b = JsObject::with_null_proto(); + fn upgraded_handle_keeps_referent_alive_across_collection() { + let object = JsObject::with_null_proto(); + let weak = WeakJsObject::new(&object); + let strong = weak.upgrade().expect("referent is still alive"); + + // Drop the original binding; only the upgraded handle roots the referent now. + drop(object); + force_collect(); + assert!(weak.is_upgradable()); + assert_eq!( + weak.upgrade().expect("kept alive by the upgraded handle"), + strong + ); - let weak_a1 = WeakJsObject::new(&a); - let weak_a2 = WeakJsObject::new(&a); - let weak_b = WeakJsObject::new(&b); + // Once the upgraded handle is gone too, the referent can be collected. + drop(strong); + force_collect(); + assert!(!weak.is_upgradable()); + assert!(weak.upgrade().is_none()); + } - assert_eq!(weak_a1, weak_a2); - assert_ne!(weak_a1, weak_b); + #[test] + fn all_weaks_to_same_referent_die_together() { + let object = JsObject::with_null_proto(); + let first = WeakJsObject::new(&object); + let cloned = first.clone(); + let second = WeakJsObject::new(&object); + + drop(object); + force_collect(); + + assert!(!first.is_upgradable()); + assert!(!cloned.is_upgradable()); + assert!(!second.is_upgradable()); + } + + #[test] + fn works_with_a_typed_object() { + use crate::builtins::OrdinaryObject; + + let object: JsObject = JsObject::new_unique(None, OrdinaryObject); + let weak: WeakJsObject = WeakJsObject::new(&object); + + let upgraded: JsObject = weak.upgrade().expect("referent is still alive"); + assert_eq!(upgraded, object); + + drop(object); + drop(upgraded); + force_collect(); + assert!(!weak.is_upgradable()); + assert!(weak.upgrade().is_none()); } #[test]