From 139d4e77e148c58cce05ac8a3b6e54f5cb1002cb Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Fri, 31 Jul 2026 01:23:47 +0000
Subject: [PATCH] Preserve root scrolling overflow geometry
---
crates/obscura-js/src/runtime.rs | 37 +++++++++
crates/obscura-render/src/dom.rs | 130 ++++++++++++++++++++++++++-----
2 files changed, 148 insertions(+), 19 deletions(-)
diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs
index 703b201..7aa1233 100644
--- a/crates/obscura-js/src/runtime.rs
+++ b/crates/obscura-js/src/runtime.rs
@@ -3194,6 +3194,43 @@ mod tests {
assert_eq!(after[2].as_f64(), Some(80.0));
}
+ #[cfg(feature = "render")]
+ #[test]
+ fn root_overflow_clip_preserves_cssom_scroll_range() {
+ let dom = parse_html(
+ r#"
+
+
+
+
+
+ "#,
+ );
+ let mut rt = ObscuraJsRuntime::new();
+ rt.set_dom(dom);
+ rt.set_viewport(900.0, 1000.0);
+ rt.run_page_init();
+
+ let result = rt
+ .evaluate(
+ r#"
+ const main = document.getElementById("main");
+ const before = main.getBoundingClientRect();
+ scrollTo(0, 99999);
+ const after = main.getBoundingClientRect();
+ return [
+ before.height,
+ document.documentElement.scrollHeight,
+ document.body.scrollHeight,
+ scrollY,
+ after.top,
+ ];
+ "#,
+ )
+ .unwrap();
+ assert_eq!(result, serde_json::json!([5048, 5048, 5048, 4048, -4048]));
+ }
+
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn rendered_window_scroll_events_require_actual_movement() {
diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs
index 070f15c..346bace 100644
--- a/crates/obscura-render/src/dom.rs
+++ b/crates/obscura-render/src/dom.rs
@@ -285,25 +285,22 @@ impl DomLayout {
let mut right = viewport.0.max(0.0);
let mut bottom = viewport.1.max(0.0);
- for (&id, rect) in &self.rects {
- if fixed.contains(&id) {
- continue;
- }
- let (tx, ty) = self.translates.get(&id).copied().unwrap_or((0.0, 0.0));
- let mut painted = Rect {
- x: rect.x + tx,
- y: rect.y + ty,
- width: rect.width,
- height: rect.height,
- };
- if let Some(clip) = self.clip_rects.get(&id).copied().flatten() {
- let Some(clipped) = painted.intersect(&clip) else {
- continue;
- };
- painted = clipped;
- }
- right = right.max(painted.x + painted.width);
- bottom = bottom.max(painted.y + painted.height);
+ if let Some(root) = tree
+ .descendants(tree.document())
+ .into_iter()
+ .find(|id| tree.get_node(*id).is_some_and(|node| node.is_element()))
+ {
+ accumulate_scrolling_overflow(
+ tree,
+ root,
+ None,
+ &fixed,
+ &self.rects,
+ &self.styles,
+ &self.translates,
+ &mut right,
+ &mut bottom,
+ );
}
(right.ceil(), bottom.ceil())
@@ -458,6 +455,69 @@ impl DomLayout {
}
}
+#[allow(clippy::too_many_arguments)]
+fn accumulate_scrolling_overflow(
+ tree: &DomTree,
+ id: NodeId,
+ inherited_clip: Option,
+ fixed: &HashSet,
+ rects: &HashMap,
+ styles: &HashMap,
+ translates: &HashMap,
+ right: &mut f32,
+ bottom: &mut f32,
+) {
+ if fixed.contains(&id) {
+ return;
+ }
+
+ let translated = rects.get(&id).map(|rect| {
+ let (tx, ty) = translates.get(&id).copied().unwrap_or((0.0, 0.0));
+ Rect {
+ x: rect.x + tx,
+ y: rect.y + ty,
+ width: rect.width,
+ height: rect.height,
+ }
+ });
+ if let Some(overflow) = translated {
+ let visible = if let Some(clip) = inherited_clip {
+ overflow.intersect(&clip)
+ } else {
+ Some(overflow)
+ };
+ if let Some(overflow) = visible {
+ *right = right.max(overflow.x + overflow.width);
+ *bottom = bottom.max(overflow.y + overflow.height);
+ }
+ }
+
+ // Overflow propagated from html/body establishes the viewport clip for
+ // painting, but it does not truncate the root scrolling area's CSSOM
+ // overflow dimensions. Ordinary descendant overflow clips still bound
+ // their subtree's contribution.
+ let is_viewport_overflow_source = tree.get_node(id).is_some_and(|node| {
+ node.as_element()
+ .is_some_and(|element| matches!(element.local.as_ref(), "html" | "body"))
+ });
+ let child_clip = match (styles.get(&id), rects.get(&id)) {
+ (Some(style), Some(rect)) if style.overflow_hidden && !is_viewport_overflow_source => {
+ let (tx, ty) = translates.get(&id).copied().unwrap_or((0.0, 0.0));
+ let own = overflow_clip_rect(rect, style, tx, ty);
+ Some(match inherited_clip {
+ Some(clip) => clip.intersect(&own).unwrap_or(Rect::default()),
+ None => own,
+ })
+ }
+ _ => inherited_clip,
+ };
+ for child in tree.children(id) {
+ accumulate_scrolling_overflow(
+ tree, child, child_clip, fixed, rects, styles, translates, right, bottom,
+ );
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GeneratedBoxKind {
Before,
@@ -8164,6 +8224,38 @@ mod tests {
}
}
+ #[test]
+ fn viewport_overflow_clip_does_not_truncate_root_scrolling_overflow() {
+ let tree = parse_html(
+ r#""#,
+ );
+ let laid = layout_dom(&tree, (900.0, 1000.0));
+ let main = laid.rects[&tree.get_element_by_id("main").unwrap()];
+ let content = laid.scrolling_content_size(&tree, (900.0, 1000.0));
+ assert!((main.height - 5048.0).abs() < 0.01, "{main:?}");
+ assert_eq!(content, (900.0, 5048.0));
+ }
+
+ #[test]
+ fn descendant_overflow_clip_still_bounds_root_scrolling_overflow() {
+ let tree = parse_html(
+ r#""#,
+ );
+ let laid = layout_dom(&tree, (900.0, 1000.0));
+ assert_eq!(
+ laid.scrolling_content_size(&tree, (900.0, 1000.0)),
+ (900.0, 1000.0)
+ );
+ }
+
#[test]
fn size_container_auto_block_size_ignores_descendants() {
let tree = parse_html(