From af5c3fd4f8b2ff8b4efab650242b1d26b9c8ee65 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Wed, 15 Jul 2026 22:22:03 -0400 Subject: [PATCH 1/5] 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/5] 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/5] 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] From 2137fbf7758a4ae77ec48c1e4231c910243d3503 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Sat, 25 Jul 2026 16:12:17 -0400 Subject: [PATCH 4/5] refactor(engine): use JsObject's recursion-limited Debug for WeakJsObject Delegate WeakJsObject's Debug to JsObject's own Debug impl instead of the placeholder finish_non_exhaustive(). JsObject's Debug uses a RecursionLimiter to avoid overflowing the stack on cyclic object graphs, which was the reason VTableObject skips Debug in the first place, so upgrading and printing the referent (or None when collected) is both safe and more useful. Addresses review feedback on #5437. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/engine/src/object/weak.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/engine/src/object/weak.rs b/core/engine/src/object/weak.rs index 569ed24222f..e0b866bc278 100644 --- a/core/engine/src/object/weak.rs +++ b/core/engine/src/object/weak.rs @@ -76,12 +76,14 @@ impl Clone for WeakJsObject { // 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, -// non-recursive implementation instead. +// We can't derive `Debug` because `VTableObject` deliberately doesn't implement it. Instead we +// upgrade and delegate to `JsObject`'s own `Debug`, which uses a `RecursionLimiter` to avoid +// overflowing the stack on cyclic object graphs. A collected referent prints as `None`. impl Debug for WeakJsObject { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("WeakJsObject").finish_non_exhaustive() + f.debug_tuple("WeakJsObject") + .field(&self.upgrade()) + .finish() } } From 3355d720441f9f1a981de44685641dc9f2ecb91b Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Sat, 25 Jul 2026 16:21:42 -0400 Subject: [PATCH 5/5] docs(examples): replace WeakJsObject unit tests with a usage example The unit tests largely duplicated the WeakGc tests in boa_gc, so drop them and add examples/src/bin/weak_js_object.rs instead. The example shows the intended embedder use case: a Rust-side registry that remembers JS objects by id through weak references without keeping them alive, upgrading while a strong handle exists and failing to upgrade once the referent is collected. Addresses review feedback on #5437. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/engine/src/object/weak.rs | 108 ----------------------------- examples/src/bin/weak_js_object.rs | 60 ++++++++++++++++ 2 files changed, 60 insertions(+), 108 deletions(-) create mode 100644 examples/src/bin/weak_js_object.rs diff --git a/core/engine/src/object/weak.rs b/core/engine/src/object/weak.rs index e0b866bc278..e7a3e0da747 100644 --- a/core/engine/src/object/weak.rs +++ b/core/engine/src/object/weak.rs @@ -86,111 +86,3 @@ impl Debug for WeakJsObject { .finish() } } - -#[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.upgrade().expect("live"), - cloned.upgrade().expect("live") - ); - } - - #[test] - 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 - ); - - // 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()); - } - - #[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] - fn built_from_reference_via_conversion() { - let object = JsObject::with_null_proto(); - let weak: WeakJsObject = (&object).into(); - - assert_eq!(weak.upgrade().expect("live"), object); - } -} diff --git a/examples/src/bin/weak_js_object.rs b/examples/src/bin/weak_js_object.rs new file mode 100644 index 00000000000..a42e5d540d4 --- /dev/null +++ b/examples/src/bin/weak_js_object.rs @@ -0,0 +1,60 @@ +//! Example demonstrating the `WeakJsObject` API: an embedder-held weak reference to a `JsObject` +//! that does not keep the referenced object alive across garbage collections. +use boa_engine::{Context, Source, js_string, object::WeakJsObject}; +use boa_gc::force_collect; +use std::collections::HashMap; + +fn main() { + let mut context = Context::default(); + + // Create two independent JS objects. For now we hold strong handles to both. + let first = context + .eval(Source::from_bytes("({ name: 'first' })")) + .unwrap() + .as_object() + .unwrap() + .clone(); + let second = context + .eval(Source::from_bytes("({ name: 'second' })")) + .unwrap() + .as_object() + .unwrap() + .clone(); + + // A Rust-side registry that remembers objects by an embedder-chosen id *without* keeping them + // alive. This is the shape of use case `WeakJsObject` exists for: for example, mapping host DOM + // nodes to their JS wrappers so that the same node keeps yielding the same object, while still + // letting the engine collect a wrapper once nothing else references it. + let mut registry: HashMap = HashMap::new(); + registry.insert(1, WeakJsObject::new(&first)); + registry.insert(2, WeakJsObject::new(&second)); + + // While the strong handles live, the registry can hand the objects back. + println!("id 1 upgradable? {}", registry[&1].is_upgradable()); + println!("id 2 upgradable? {}", registry[&2].is_upgradable()); + println!("debug of id 1: {:?}", registry[&1]); + + // Drop the strong handle to the first object and run a collection. Its registry entry can no + // longer be upgraded, and the weak entry never kept the object alive in the first place. + drop(first); + force_collect(); + + println!("after dropping `first` and collecting:"); + println!(" id 1 upgradable? {}", registry[&1].is_upgradable()); + println!(" id 2 upgradable? {}", registry[&2].is_upgradable()); + + // The surviving object still round-trips through its weak reference. Upgrading yields a strong + // handle, which keeps the object alive for as long as it is held. + let recovered = registry[&2].upgrade().expect("`second` is still alive"); + let name = recovered.get(js_string!("name"), &mut context).unwrap(); + println!(" id 2 name = {}", name.display()); + + // Drop the last strong handles (the original and the upgraded one); now nothing is upgradable. + drop(second); + drop(recovered); + force_collect(); + println!( + "after dropping everything: id 2 upgradable? {}", + registry[&2].is_upgradable() + ); +}