diff --git a/Cargo.lock b/Cargo.lock
index b943640..7c89b3d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,6 +14,7 @@ dependencies = [
"we-image",
"we-js",
"we-layout",
+ "we-memory",
"we-net",
"we-platform",
"we-render",
@@ -34,6 +35,9 @@ version = "0.1.0"
[[package]]
name = "we-dom"
version = "0.1.0"
+dependencies = [
+ "we-memory",
+]
[[package]]
name = "we-encoding"
@@ -74,6 +78,10 @@ dependencies = [
"we-text",
]
+[[package]]
+name = "we-memory"
+version = "0.1.0"
+
[[package]]
name = "we-net"
version = "0.1.0"
@@ -107,6 +115,7 @@ dependencies = [
"we-css",
"we-dom",
"we-html",
+ "we-memory",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 3766fac..b2ddead 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ members = [
"crates/render",
"crates/js",
"crates/svg",
+ "crates/memory",
"crates/browser",
]
diff --git a/crates/browser/Cargo.toml b/crates/browser/Cargo.toml
index a7d895a..065e7a3 100644
--- a/crates/browser/Cargo.toml
+++ b/crates/browser/Cargo.toml
@@ -27,3 +27,4 @@ we-url = { path = "../url" }
we-encoding = { path = "../encoding" }
we-image = { path = "../image" }
we-svg = { path = "../svg" }
+we-memory = { path = "../memory" }
diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs
index 1eb0027..b8ed525 100644
--- a/crates/browser/src/lib.rs
+++ b/crates/browser/src/lib.rs
@@ -8,5 +8,6 @@ pub mod iframe_loader;
pub mod img_loader;
pub mod indexeddb;
pub mod loader;
+pub mod memory_stats;
pub mod script_loader;
pub mod storage;
diff --git a/crates/browser/src/memory_stats.rs b/crates/browser/src/memory_stats.rs
new file mode 100644
index 0000000..0db9b9a
--- /dev/null
+++ b/crates/browser/src/memory_stats.rs
@@ -0,0 +1,110 @@
+//! Debug memory statistics: aggregates memory usage from all browser subsystems.
+//!
+//! Call [`collect_memory_report`] to gather a snapshot of current memory usage
+//! across DOM, layout, style, JS heap, glyph cache, network, and display list.
+
+use we_dom::Document;
+use we_layout::LayoutTree;
+use we_memory::intern;
+use we_memory::stats::{MemoryReport, SubsystemStats};
+use we_render::atlas::GlyphAtlas;
+use we_render::layer::LayerTree;
+
+/// Collect a full memory report from all available subsystems.
+///
+/// Pass `None` for any subsystem that is not currently active.
+pub fn collect_memory_report(
+ doc: Option<&Document>,
+ layout_tree: Option<&LayoutTree>,
+ layer_tree: Option<&LayerTree>,
+ glyph_atlas: Option<&GlyphAtlas>,
+) -> MemoryReport {
+ let mut report = MemoryReport::new();
+
+ // DOM
+ if let Some(doc) = doc {
+ report.add(
+ SubsystemStats::new("DOM tree")
+ .with_count(doc.len())
+ .with_bytes(doc.memory_usage())
+ .with_detail("elements", doc.element_count())
+ .with_detail("text_nodes", doc.text_node_count()),
+ );
+ }
+
+ // Layout
+ if let Some(tree) = layout_tree {
+ report.add(
+ SubsystemStats::new("Layout tree")
+ .with_count(tree.box_count())
+ .with_bytes(tree.memory_usage())
+ .with_detail("prev_box_count", tree.hints.prev_box_count),
+ );
+ }
+
+ // String interner
+ report.add(
+ SubsystemStats::new("String interner")
+ .with_count(intern::interned_count())
+ .with_bytes(intern::interner_memory_usage()),
+ );
+
+ // Display list / layer tree
+ if let Some(lt) = layer_tree {
+ report.add(
+ SubsystemStats::new("Display list")
+ .with_count(lt.display_list_command_count())
+ .with_bytes(lt.memory_usage()),
+ );
+ }
+
+ // Glyph atlas
+ if let Some(atlas) = glyph_atlas {
+ report.add(
+ SubsystemStats::new("Glyph atlas")
+ .with_count(atlas.glyph_count())
+ .with_bytes(atlas.memory_usage())
+ .with_detail("pages", atlas.page_count())
+ .with_detail("utilization", format!("{:.1}%", atlas.utilization())),
+ );
+ }
+
+ report
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn empty_report() {
+ let report = collect_memory_report(None, None, None, None);
+ // Should at least have the string interner entry
+ assert!(!report.subsystems.is_empty());
+ }
+
+ #[test]
+ fn report_with_dom() {
+ let mut doc = Document::new();
+ let root = doc.root();
+ let div = doc.create_element("div");
+ doc.append_child(root, div);
+
+ let report = collect_memory_report(Some(&doc), None, None, None);
+ let dom_stats = report
+ .subsystems
+ .iter()
+ .find(|s| s.name == "DOM tree")
+ .unwrap();
+ assert!(dom_stats.estimated_bytes > 0);
+ assert_eq!(dom_stats.item_count, 2); // root + div
+ }
+
+ #[test]
+ fn report_display_format() {
+ let report = collect_memory_report(None, None, None, None);
+ let s = format!("{report}");
+ assert!(s.contains("Memory Report"));
+ assert!(s.contains("Total:"));
+ }
+}
diff --git a/crates/dom/Cargo.toml b/crates/dom/Cargo.toml
index 2ec1be7..f0fc12f 100644
--- a/crates/dom/Cargo.toml
+++ b/crates/dom/Cargo.toml
@@ -6,3 +6,6 @@ edition.workspace = true
[lib]
name = "we_dom"
path = "src/lib.rs"
+
+[dependencies]
+we-memory = { path = "../memory" }
diff --git a/crates/dom/src/lib.rs b/crates/dom/src/lib.rs
index aff7dbb..fdab9b2 100644
--- a/crates/dom/src/lib.rs
+++ b/crates/dom/src/lib.rs
@@ -2,9 +2,14 @@
//!
//! Arena-based DOM tree with Document, Element, Text, and Comment node types.
//! Each node is stored in a flat `Vec` and referenced by `NodeId`.
+//!
+//! Tag names and attribute names are interned via `Atom` for memory efficiency:
+//! thousands of `
` elements share one string allocation instead of one each.
use std::fmt;
+use we_memory::intern::Atom;
+
/// A handle to a node in the DOM tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodeId(usize);
@@ -22,22 +27,27 @@ impl NodeId {
}
/// An HTML/XML attribute (name-value pair).
+///
+/// The `name` is an interned `Atom` (shared across identical attribute names).
+/// The `value` is a regular `String` (typically unique per element).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribute {
- pub name: String,
+ pub name: Atom,
pub value: String,
}
/// The data specific to each node type.
+///
+/// Element tag names and namespaces are interned `Atom` values.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NodeData {
/// The root document node.
Document,
/// An element node with tag name, attributes, and optional namespace.
Element {
- tag_name: String,
+ tag_name: Atom,
attributes: Vec
,
- namespace: Option,
+ namespace: Option,
},
/// A text node containing character data.
Text { data: String },
@@ -101,9 +111,9 @@ impl Document {
/// Create an element node with an optional namespace.
pub fn create_element_ns(&mut self, tag_name: &str, namespace: Option<&str>) -> NodeId {
self.push_node(NodeData::Element {
- tag_name: tag_name.to_string(),
+ tag_name: Atom::new(tag_name),
attributes: Vec::new(),
- namespace: namespace.map(|s| s.to_string()),
+ namespace: namespace.map(Atom::new),
})
}
@@ -235,7 +245,7 @@ impl Document {
match &self.nodes[node.0].data {
NodeData::Element { attributes, .. } => attributes
.iter()
- .find(|a| a.name == name)
+ .find(|a| *a.name == *name)
.map(|a| a.value.as_str()),
_ => None,
}
@@ -245,11 +255,11 @@ impl Document {
/// its value is replaced. Does nothing if `node` is not an element.
pub fn set_attribute(&mut self, node: NodeId, name: &str, value: &str) {
if let NodeData::Element { attributes, .. } = &mut self.nodes[node.0].data {
- if let Some(attr) = attributes.iter_mut().find(|a| a.name == name) {
+ if let Some(attr) = attributes.iter_mut().find(|a| *a.name == *name) {
attr.value = value.to_string();
} else {
attributes.push(Attribute {
- name: name.to_string(),
+ name: Atom::new(name),
value: value.to_string(),
});
}
@@ -261,7 +271,7 @@ impl Document {
pub fn remove_attribute(&mut self, node: NodeId, name: &str) -> bool {
if let NodeData::Element { attributes, .. } = &mut self.nodes[node.0].data {
let len_before = attributes.len();
- attributes.retain(|a| a.name != name);
+ attributes.retain(|a| *a.name != *name);
attributes.len() < len_before
} else {
false
@@ -416,6 +426,46 @@ impl Document {
self.nodes[node.0].prev_sibling = None;
self.nodes[node.0].next_sibling = None;
}
+
+ /// Estimated heap bytes used by the DOM tree.
+ pub fn memory_usage(&self) -> usize {
+ let node_struct_size = std::mem::size_of::();
+ let vec_overhead = self.nodes.capacity() * node_struct_size;
+ let string_bytes: usize = self.nodes.iter().map(|n| node_heap_bytes(&n.data)).sum();
+ vec_overhead + string_bytes
+ }
+
+ /// Number of element nodes in the tree.
+ pub fn element_count(&self) -> usize {
+ self.nodes
+ .iter()
+ .filter(|n| matches!(n.data, NodeData::Element { .. }))
+ .count()
+ }
+
+ /// Number of text nodes in the tree.
+ pub fn text_node_count(&self) -> usize {
+ self.nodes
+ .iter()
+ .filter(|n| matches!(n.data, NodeData::Text { .. }))
+ .count()
+ }
+}
+
+/// Heap bytes used by a NodeData's dynamic fields.
+fn node_heap_bytes(data: &NodeData) -> usize {
+ match data {
+ NodeData::Document => 0,
+ NodeData::Element { attributes, .. } => {
+ // Atom tag_name and namespace share allocations via Rc, so we don't
+ // count them here — they're tracked by the interner.
+ let attrs: usize = attributes.iter().map(|a| a.value.capacity()).sum();
+ let attr_vec = attributes.capacity() * std::mem::size_of::();
+ attrs + attr_vec
+ }
+ NodeData::Text { data } => data.capacity(),
+ NodeData::Comment { data } => data.capacity(),
+ }
}
impl Default for Document {
@@ -448,7 +498,7 @@ mod tests {
fn new_document_has_root() {
let doc = Document::new();
assert_eq!(doc.root(), NodeId(0));
- assert_eq!(*doc.node_data(doc.root()), NodeData::Document);
+ assert!(matches!(doc.node_data(doc.root()), NodeData::Document));
assert!(doc.children(doc.root()).next().is_none());
}
@@ -902,4 +952,20 @@ mod tests {
assert_eq!(children, vec![comment, div]);
assert_eq!(doc.text_content(comment), Some("TODO: add content"));
}
+
+ #[test]
+ fn atom_interning_shares_memory() {
+ let mut doc = Document::new();
+ let div1 = doc.create_element("div");
+ let div2 = doc.create_element("div");
+ // Both should have the same tag name
+ assert_eq!(doc.tag_name(div1), doc.tag_name(div2));
+ // And the atoms should share the same Rc allocation
+ match (doc.node_data(div1), doc.node_data(div2)) {
+ (NodeData::Element { tag_name: t1, .. }, NodeData::Element { tag_name: t2, .. }) => {
+ assert!(t1.ptr_eq(t2));
+ }
+ _ => panic!("expected elements"),
+ }
+ }
}
diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs
index 3065fc6..7b5c696 100644
--- a/crates/js/src/dom_bridge.rs
+++ b/crates/js/src/dom_bridge.rs
@@ -1199,7 +1199,7 @@ pub fn resolve_dom_get(
let mut attr_obj = ObjectData::new();
attr_obj.insert_property(
"name".to_string(),
- Property::data(Value::String(attr.name.clone())),
+ Property::data(Value::String(attr.name.to_string())),
shapes,
);
attr_obj.insert_property(
diff --git a/crates/js/src/gc.rs b/crates/js/src/gc.rs
index 70f8421..b062c78 100644
--- a/crates/js/src/gc.rs
+++ b/crates/js/src/gc.rs
@@ -70,6 +70,15 @@ pub struct Gc {
/// Initial collection threshold.
const INITIAL_THRESHOLD: usize = 256;
+/// Minimum growth factor after collection (1.5x = less memory pressure than 2x).
+const MIN_GROWTH_FACTOR: f64 = 1.5;
+
+/// Maximum threshold to avoid unbounded growth.
+const MAX_THRESHOLD: usize = 1 << 20; // ~1M objects
+
+/// If free slots exceed this ratio of total slots, compact the heap.
+const COMPACT_RATIO: f64 = 0.5;
+
impl Gc {
/// Create a new, empty GC heap.
pub fn new() -> Self {
@@ -136,9 +145,33 @@ impl Gc {
self.sweep();
self.collections += 1;
- // Grow threshold so we don't collect too often.
- if self.live_count >= self.threshold {
- self.threshold = self.live_count * 2;
+ // Adaptive threshold: use 1.5x growth factor instead of 2x to reduce
+ // peak memory. Cap at MAX_THRESHOLD to prevent unbounded growth.
+ let next = ((self.live_count as f64) * MIN_GROWTH_FACTOR) as usize;
+ self.threshold = next.clamp(INITIAL_THRESHOLD, MAX_THRESHOLD);
+
+ // Compact the heap if the free list is disproportionately large.
+ // This reclaims memory when many objects were freed.
+ if self.heap.len() > INITIAL_THRESHOLD {
+ let free_ratio = self.free_list.len() as f64 / self.heap.len() as f64;
+ if free_ratio > COMPACT_RATIO {
+ self.compact();
+ }
+ }
+ }
+
+ /// Compact the heap by removing trailing free slots and rebuilding the free list.
+ fn compact(&mut self) {
+ // Trim trailing None slots.
+ while self.heap.last().is_some_and(|s| s.is_none()) {
+ self.heap.pop();
+ }
+ // Rebuild the free list from remaining None slots.
+ self.free_list.clear();
+ for (i, slot) in self.heap.iter().enumerate() {
+ if slot.is_none() {
+ self.free_list.push(i as u32);
+ }
}
}
@@ -151,6 +184,19 @@ impl Gc {
}
}
+ /// Estimated heap bytes used by the GC.
+ pub fn memory_usage(&self) -> usize {
+ let slot_size = std::mem::size_of::