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(