Improve modern layout and viewport rendering

This commit is contained in:
SGavrl committed 2026-07-30 20:24:57 +00:00
1 parent aff0172987
commit 10b8492116
22 files changed
+5978 -450

No files matched your search

+1 -1
View File
@@ -14,4 +14,4 @@ pub use v8_flags::set_v8_flags;
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
// render feature (which enables obscura-render/paint) is compiled in.
#[cfg(feature = "render")]
pub use obscura_render::screenshot_png;
pub use obscura_render::{screenshot_png, screenshot_png_scrolled};
+155 -27
View File
@@ -64,6 +64,14 @@ pub struct JsNetworkEvent {
pub timestamp: f64,
}
#[cfg(feature = "render")]
pub struct LayoutCache {
rects: std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>,
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
sticky: obscura_render::StickyLayout,
content_size: (f32, f32),
}
pub struct ObscuraState {
pub dom: Option<DomTree>,
pub url: String,
@@ -104,14 +112,17 @@ pub struct ObscuraState {
// Network.requestWillBeSent / responseReceived for them (issue #406).
pub js_network_events: Vec<JsNetworkEvent>,
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The
/// cache is computed on first geometry read and cleared on navigation. It is
/// not invalidated on every DOM mutation yet (a follow-up), so geometry read
/// mid-script may lag a frame; reading after settle is reliable.
/// cache is computed on first geometry read and cleared on navigation,
/// viewport changes, and DOM/style mutations that can affect layout.
#[cfg(feature = "render")]
pub layout_cache:
Option<std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>>,
pub layout_cache: Option<LayoutCache>,
#[cfg(feature = "render")]
pub viewport: (f32, f32),
/// Root scrolling offset in CSS pixels. With render enabled this is
/// clamped against the cached document overflow and is the single source
/// read by CSSOM geometry and screenshot paint.
#[cfg(feature = "render")]
pub scroll_offset: (f32, f32),
}
impl ObscuraState {
@@ -141,6 +152,8 @@ impl ObscuraState {
layout_cache: None,
#[cfg(feature = "render")]
viewport: (1280.0, 720.0),
#[cfg(feature = "render")]
scroll_offset: (0.0, 0.0),
}
}
}
@@ -180,8 +193,26 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
}
fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String {
let gs = state.borrow::<SharedState>().clone();
let gs = gs.borrow();
let shared = state.borrow::<SharedState>().clone();
#[cfg(feature = "render")]
if matches!(
cmd.as_str(),
"set_attribute"
| "remove_attribute"
| "append_child"
| "remove_child"
| "insert_before"
| "set_inner_html"
| "set_inner_html_context"
| "set_text_content"
) {
// Any attribute can participate in an author selector, so even a
// seemingly non-geometric attribute may change computed layout.
// Detached-node creation and every read-only op deliberately skip this
// invalidation; the next geometry/scroll read rebuilds at most once.
shared.borrow_mut().layout_cache = None;
}
let gs = shared.borrow();
let dom = match &gs.dom {
Some(d) => d,
None => return "null".to_string(),
@@ -2010,7 +2041,12 @@ pub fn build_extension() -> Extension {
// Only registered when the render feature is compiled in. bootstrap.js
// probes with typeof before calling, so the op's absence is a clean fallback.
#[cfg(feature = "render")]
ops.push(op_layout_geometry());
{
ops.push(op_layout_geometry());
ops.push(op_layout_metrics());
ops.push(op_scroll_offset());
ops.push(op_scroll_to());
}
Extension {
name: "obscura_dom",
ops: std::borrow::Cow::Owned(ops),
@@ -2018,35 +2054,127 @@ pub fn build_extension() -> Extension {
}
}
#[cfg(feature = "render")]
fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> {
if state.layout_cache.is_none() {
let dom = state.dom.as_ref()?;
let laid = obscura_render::layout_dom(dom, state.viewport);
let viewport_fixed = laid.viewport_fixed_nodes(dom);
let sticky = laid.root_sticky_layout(dom, state.viewport);
let content_size = laid.scrolling_content_size(dom, state.viewport);
let mut rects = laid.rects;
for (&id, rect) in &mut rects {
if let Some((tx, ty)) = laid.translates.get(&id) {
rect.x += tx;
rect.y += ty;
}
}
state.layout_cache = Some(LayoutCache {
rects,
viewport_fixed,
sticky,
content_size,
});
}
state.layout_cache.as_ref()
}
#[cfg(feature = "render")]
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
let viewport = state.viewport;
let content_size = ensure_layout_cache(state)
.map(|cache| cache.content_size)
.unwrap_or(viewport);
let max_x = (content_size.0 - viewport.0).max(0.0);
let max_y = (content_size.1 - viewport.1).max(0.0);
let x = if requested.0.is_finite() {
requested.0.clamp(0.0, max_x)
} else {
0.0
};
let y = if requested.1.is_finite() {
requested.1.clamp(0.0, max_y)
} else {
0.0
};
state.scroll_offset = (x, y);
state.scroll_offset
}
/// Real border-box geometry for an element from the obscura-render layout
/// cache. The cache is computed lazily on first read and cleared on navigation
/// (see `set_dom`). Returns JSON `{"x","y","width","height"}` in CSS pixels, or
/// an empty string when the node has no box. Feature-gated.
/// (see `set_dom`). Coordinates are viewport-relative after the shared root
/// scroll offset, except for viewport-fixed subtrees. Returns JSON
/// `{"x","y","width","height"}` in CSS pixels, or an empty string when the
/// node has no box. Feature-gated.
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
let shared = state.borrow::<SharedState>().clone();
{
let mut gs = shared.borrow_mut();
if gs.layout_cache.is_none() {
if let Some(dom) = &gs.dom {
let viewport = gs.viewport;
gs.layout_cache = Some(obscura_render::layout_dom(dom, viewport).rects);
}
}
}
let gs = shared.borrow();
let nid: u32 = nid_str.parse().unwrap_or(0);
if let Some(rect) = gs
.layout_cache
.as_ref()
.and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid)))
{
let nid = obscura_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut();
let scroll = gs.scroll_offset;
let viewport = gs.viewport;
if let Some(cache) = ensure_layout_cache(&mut gs) {
let Some(rect) = cache.rects.get(&nid) else {
return String::new();
};
let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll);
let (x, y) = if cache.viewport_fixed.contains(&nid) {
(rect.x, rect.y)
} else {
(
rect.x + sticky_offset.0 - scroll.0,
rect.y + sticky_offset.1 - scroll.1,
)
};
let viewport_fixed = cache.viewport_fixed.contains(&nid);
return format!(
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}",
rect.x, rect.y, rect.width, rect.height
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}",
x, y, rect.width, rect.height, viewport_fixed
);
}
String::new()
}
/// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in
/// render builds; default scraping builds retain their deliberately unbounded
/// synthetic scrolling behavior.
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_layout_metrics(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
let viewport = gs.viewport;
let content = ensure_layout_cache(&mut gs)
.map(|cache| cache.content_size)
.unwrap_or(viewport);
format!(
"{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}",
content.0, content.1, viewport.0, viewport.1
)
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_scroll_offset(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
let requested = gs.scroll_offset;
let (x, y) = clamp_scroll_offset(&mut gs, requested);
format!("{{\"x\":{},\"y\":{}}}", x, y)
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32));
format!("{{\"x\":{},\"y\":{}}}", x, y)
}
+776
View File
@@ -10,6 +10,8 @@ use obscura_dom::DomTree;
pub use deno_core::v8::IsolateHandle;
use crate::module_loader::ObscuraModuleLoader;
#[cfg(feature = "render")]
use crate::ops::clamp_scroll_offset;
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
@@ -195,6 +197,7 @@ impl ObscuraJsRuntime {
#[cfg(feature = "render")]
{
gs.layout_cache = None;
gs.scroll_offset = (0.0, 0.0);
}
}
@@ -285,6 +288,12 @@ impl ObscuraJsRuntime {
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return;
}
#[cfg(feature = "render")]
{
let mut state = self.state.borrow_mut();
state.viewport = (width as f32, height as f32);
state.layout_cache = None;
}
let _ = self.runtime.execute_script(
"<set-viewport>",
format!(
@@ -294,11 +303,22 @@ impl ObscuraJsRuntime {
if(globalThis.visualViewport){{\
globalThis.visualViewport.width={width};\
globalThis.visualViewport.height={height};\
}}\
if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
globalThis.__obscura_recompute_intersections();\
}}",
),
);
}
/// Current clamped root scroll offset shared by CSSOM geometry and paint.
#[cfg(feature = "render")]
pub fn scroll_offset(&self) -> (f32, f32) {
let mut state = self.state.borrow_mut();
let requested = state.scroll_offset;
clamp_scroll_offset(&mut state, requested)
}
/// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
/// have been set. Must be called once per page setup, after all set_* methods.
pub fn run_page_init(&mut self) {
@@ -1871,6 +1891,146 @@ mod tests {
);
}
#[test]
fn animation_frame_requires_a_callable_callback() {
let mut rt = setup_runtime("<html><body></body></html>");
let result = rt
.evaluate(
r#"(() => {
try {
requestAnimationFrame(null);
return [false, ""];
} catch (error) {
return [error instanceof TypeError, error.name];
}
})()"#,
)
.unwrap();
assert_eq!(result, serde_json::json!([true, "TypeError"]));
}
#[tokio::test(flavor = "current_thread")]
async fn animation_frames_are_ordered_batches_with_rendering_timestamps() {
let mut rt = setup_runtime("<html><body></body></html>");
rt.execute_script(
"animation-frame-order",
r#"
globalThis.__rafEvents = [];
globalThis.__rafStamps = [];
Promise.resolve().then(() => __rafEvents.push("microtask-before"));
setTimeout(() => __rafEvents.push("timer"), 1);
requestAnimationFrame((timestamp) => {
__rafEvents.push("raf-a");
__rafStamps.push(timestamp);
Promise.resolve().then(() => __rafEvents.push("microtask-in-raf"));
requestAnimationFrame((nextTimestamp) => {
__rafEvents.push("raf-next");
__rafStamps.push(nextTimestamp);
});
});
requestAnimationFrame((timestamp) => {
__rafEvents.push("raf-b");
__rafStamps.push(timestamp);
});
"#,
)
.unwrap();
rt.run_event_loop_bounded(150).await.unwrap();
let result = rt
.evaluate(
r#"[
__rafEvents,
__rafStamps.length,
__rafStamps[0] === __rafStamps[1],
__rafStamps[2] > __rafStamps[1]
]"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
[
"microtask-before",
"timer",
"raf-a",
"raf-b",
"microtask-in-raf",
"raf-next"
],
3,
true,
true
])
);
}
#[tokio::test(flavor = "current_thread")]
async fn cancel_animation_frame_removes_pending_and_current_batch_callbacks() {
let mut rt = setup_runtime("<html><body></body></html>");
rt.execute_script(
"animation-frame-cancel",
r#"
globalThis.__rafEvents = [];
const pending = requestAnimationFrame(() => __rafEvents.push("pending"));
cancelAnimationFrame(pending);
let sameBatch;
requestAnimationFrame(() => {
__rafEvents.push("first");
cancelAnimationFrame(sameBatch);
});
sameBatch = requestAnimationFrame(() => __rafEvents.push("same-batch"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate("__rafEvents").unwrap(),
serde_json::json!(["first"])
);
}
#[tokio::test(flavor = "current_thread")]
async fn self_requeueing_animation_frame_yields_to_timer_tasks() {
let mut rt = setup_runtime("<html><body></body></html>");
rt.execute_script(
"animation-frame-yield",
r#"
globalThis.__rafCount = 0;
globalThis.__rafStopped = false;
globalThis.__timerAfterAnimation = false;
let frameId = 0;
function frame() {
__rafCount++;
frameId = requestAnimationFrame(frame);
}
frameId = requestAnimationFrame(frame);
setTimeout(() => {
cancelAnimationFrame(frameId);
__rafStopped = true;
}, 55);
setTimeout(() => {
__timerAfterAnimation = true;
}, 65);
"#,
)
.unwrap();
rt.run_event_loop_bounded(200).await.unwrap();
let result = rt
.evaluate("[__rafCount, __rafStopped, __timerAfterAnimation]")
.unwrap();
let values = result.as_array().unwrap();
let frame_count = values[0].as_u64().unwrap();
assert!(
(2..=5).contains(&frame_count),
"expected a few paced animation frames before cancellation, got {frame_count}"
);
assert_eq!(values[1], serde_json::json!(true));
assert_eq!(values[2], serde_json::json!(true));
}
#[test]
fn test_document_url() {
let mut rt = setup_runtime("<html><body></body></html>");
@@ -1892,6 +2052,38 @@ mod tests {
assert_eq!(count.as_f64().unwrap() as i64, 3);
}
#[test]
fn css_supports_matches_capabilities_and_boolean_conditions() {
let mut rt = setup_runtime("<html><body></body></html>");
let result = rt
.evaluate(
r#"JSON.stringify([
CSS.supports("-webkit-hyphens", "none"),
CSS.supports("margin-trim", "inline"),
CSS.supports("-moz-orient", "inline"),
CSS.supports("color", "rgb(from red r g b)"),
CSS.supports("(((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b))))"),
CSS.supports("display", "grid"),
CSS.supports("(display:grid) and (selector(.card > *))"),
CSS.supports("not (unknown-engine-prop:value)"),
CSS.supports("selector(.card >)"),
CSS.supports("selector(:obscura-unknown)"),
CSS.supports("selector(.card,)"),
CSS.supports("scrollbar-gutter", "stable"),
CSS.supports("scrollbar-gutter", "floating"),
CSS.supports("color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"),
CSS.supports("(color:light-dark(red, light-dark(white, black)))"),
CSS.supports("color", "light-dark(red)"),
CSS.supports("color", "light-dark(red, rgb(1, 2, 3)")
])"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!("[false,false,false,true,false,true,true,true,false,false,false,true,false,true,true,false,false]")
);
}
#[test]
fn test_get_element_by_id() {
let mut rt = setup_runtime(r#"<div id="test">Content</div>"#);
@@ -2500,6 +2692,7 @@ mod tests {
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
/// dominant infinite-scroll idiom never advanced the page offset.
#[cfg(not(feature = "render"))]
#[test]
fn window_scroll_methods_move_the_page_offset() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
@@ -2528,6 +2721,7 @@ mod tests {
/// Issue #468: the page offset is one value, readable and writable through
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
#[cfg(not(feature = "render"))]
#[test]
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
@@ -2549,6 +2743,7 @@ mod tests {
/// Issue #468: a scroll event must reach listeners on both the window and
/// the document — that is the signal lazy loaders wait for.
#[cfg(not(feature = "render"))]
#[tokio::test(flavor = "current_thread")]
async fn window_scroll_fires_a_scroll_event() {
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
@@ -2571,6 +2766,587 @@ mod tests {
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
}
#[cfg(feature = "render")]
#[test]
fn rendered_window_scroll_clamps_and_geometry_is_viewport_relative() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="wide" style="width:600px;height:700px"></div>
<div id="target" style="width:20px;height:300px"></div>
<div id="fixed" style="position:fixed;left:12px;top:14px;width:30px;height:25px">
<span id="fixed-child">fixed</span>
</div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(320.0, 200.0);
rt.run_page_init();
let result = rt
.evaluate(
r#"
const target = document.getElementById("target");
const fixed = document.getElementById("fixed");
const fixedChild = document.getElementById("fixed-child");
const before = {
target: target.getBoundingClientRect(),
fixed: fixed.getBoundingClientRect(),
fixedChild: fixedChild.getBoundingClientRect(),
};
window.scrollTo(99999, 99999);
const maxX = document.documentElement.scrollWidth - innerWidth;
const maxY = document.documentElement.scrollHeight - innerHeight;
const after = {
target: target.getBoundingClientRect(),
fixed: fixed.getBoundingClientRect(),
fixedChild: fixedChild.getBoundingClientRect(),
};
return [
innerWidth, innerHeight,
document.documentElement.clientWidth,
document.documentElement.clientHeight,
document.documentElement.scrollWidth,
document.documentElement.scrollHeight,
window.scrollX, window.scrollY,
document.scrollingElement.scrollLeft,
document.scrollingElement.scrollTop,
maxX, maxY,
Math.abs(after.target.left - (before.target.left - maxX)) < 0.01,
Math.abs(after.target.top - (before.target.top - maxY)) < 0.01,
Math.abs(after.fixed.left - before.fixed.left) < 0.01,
Math.abs(after.fixed.top - before.fixed.top) < 0.01,
Math.abs(after.fixedChild.left - before.fixedChild.left) < 0.01,
Math.abs(after.fixedChild.top - before.fixedChild.top) < 0.01,
];
"#,
)
.unwrap();
let values = result.as_array().expect("array");
assert_eq!(&values[0..4], &serde_json::json!([320, 200, 320, 200]).as_array().unwrap()[..]);
let scroll_width = values[4].as_f64().expect("scrollWidth");
let scroll_height = values[5].as_f64().expect("scrollHeight");
assert!(scroll_width >= 600.0, "scrollWidth was {scroll_width}");
assert!(scroll_height >= 1000.0, "scrollHeight was {scroll_height}");
assert_eq!(values[6], values[10]);
assert_eq!(values[7], values[11]);
assert_eq!(values[8], values[10]);
assert_eq!(values[9], values[11]);
assert!(values[12..].iter().all(|value| value == &serde_json::json!(true)));
}
/// Chromium 150 reference (800x513 CSS-pixel viewport):
/// top=[60,20,20,-267], bottom=[448,448,231,31,-496] at the sampled
/// root scroll offsets. This keeps sticky distinct from fixed positioning,
/// verifies subtree movement, bottom-only sticking, and the containing
/// block's lower boundary without depending on a live site.
#[cfg(feature = "render")]
#[test]
fn root_scroll_sticky_geometry_matches_chromium_constraints() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="height:40px"></div>
<div id="cb" style="box-sizing:border-box;height:900px;padding:10px 12px;border:4px solid #333">
<div id="top" style="box-sizing:border-box;position:sticky;top:20px;height:60px;margin:6px">
<div id="top-child" style="height:12px"></div>
</div>
<div style="height:500px"></div>
<div id="bottom" style="box-sizing:border-box;position:sticky;bottom:15px;height:50px;margin:5px"></div>
</div>
<div style="height:700px"></div>
<div id="fixed" style="position:fixed;left:600px;top:20px;width:60px;height:60px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(800.0, 513.0);
rt.run_page_init();
let result = rt
.evaluate(
r#"
const top = document.getElementById("top");
const child = document.getElementById("top-child");
const bottom = document.getElementById("bottom");
const fixed = document.getElementById("fixed");
const sample = y => {
window.scrollTo(0, y);
return [
window.scrollY,
top.getBoundingClientRect().top,
child.getBoundingClientRect().top,
bottom.getBoundingClientRect().top,
fixed.getBoundingClientRect().top,
];
};
return [sample(0), sample(100), sample(400), sample(600), sample(9999)];
"#,
)
.unwrap();
let rows = result.as_array().expect("rows");
let number = |row: usize, column: usize| {
rows[row].as_array().unwrap()[column].as_f64().unwrap()
};
let close = |actual: f64, expected: f64| {
assert!(
(actual - expected).abs() < 0.05,
"expected {expected}, got {actual}"
);
};
close(number(0, 1), 60.0);
close(number(0, 3), 448.0);
close(number(1, 1), 20.0);
close(number(1, 2), 20.0);
close(number(1, 3), 448.0);
close(number(2, 1), 20.0);
close(number(2, 3), 231.0);
close(number(3, 1), 20.0);
close(number(3, 3), 31.0);
close(number(4, 0), 1127.0);
close(number(4, 1), -267.0);
close(number(4, 2), -267.0);
close(number(4, 3), -496.0);
for row in 0..rows.len() {
close(number(row, 4), 20.0);
}
}
/// Chromium 150 horizontal reference for the same constraint algorithm:
/// the sticky subtree pins at x=20, remains distinct from fixed, then
/// leaves with its 500px containing block at the right boundary.
#[cfg(feature = "render")]
#[test]
fn root_scroll_sticky_supports_the_inline_axis() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="box-sizing:border-box;margin-left:40px;width:500px;height:100px;padding:10px;border:4px solid">
<div id="sticky" style="box-sizing:border-box;position:sticky;left:20px;width:60px;height:30px;margin:6px">
<div id="child" style="width:10px;height:10px"></div>
</div>
</div>
<div style="width:1600px;height:600px"></div>
<div id="fixed" style="position:fixed;left:20px;top:100px;width:60px;height:30px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(800.0, 513.0);
rt.run_page_init();
let result = rt
.evaluate(
r#"
const sticky = document.getElementById("sticky");
const child = document.getElementById("child");
const fixed = document.getElementById("fixed");
const sample = x => {
window.scrollTo(x, 0);
return [
window.scrollX,
sticky.getBoundingClientRect().left,
child.getBoundingClientRect().left,
fixed.getBoundingClientRect().left,
];
};
return [sample(0), sample(100), sample(400), sample(800)];
"#,
)
.unwrap();
let rows = result.as_array().unwrap();
let expected = [
[0.0, 60.0, 60.0, 20.0],
[100.0, 20.0, 20.0, 20.0],
[400.0, 20.0, 20.0, 20.0],
[800.0, -340.0, -340.0, 20.0],
];
for (row, expected) in rows.iter().zip(expected) {
for (actual, expected) in row.as_array().unwrap().iter().zip(expected) {
let actual = actual.as_f64().unwrap();
assert!(
(actual - expected).abs() < 0.05,
"expected {expected}, got {actual}"
);
}
}
}
#[cfg(feature = "render")]
#[test]
fn rendered_layout_cache_is_invalidated_by_style_mutations() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="box" style="height:300px;width:40px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
let result = rt
.evaluate(
r#"
const box = document.getElementById("box");
const before = [
document.documentElement.scrollHeight,
box.getBoundingClientRect().height,
];
box.setAttribute("style", "height:900px;width:80px");
const after = [
document.documentElement.scrollHeight,
box.getBoundingClientRect().height,
box.getBoundingClientRect().width,
];
return [before, after];
"#,
)
.unwrap();
let values = result.as_array().expect("result");
let before = values[0].as_array().expect("before");
let after = values[1].as_array().expect("after");
assert!(after[0].as_f64().unwrap() > before[0].as_f64().unwrap());
assert_eq!(before[1].as_f64(), Some(300.0));
assert_eq!(after[1].as_f64(), Some(900.0));
assert_eq!(after[2].as_f64(), Some(80.0));
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn rendered_window_scroll_events_require_actual_movement() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="height:1000px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(320.0, 200.0);
rt.run_page_init();
let moved = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
let win = 0, doc = 0;
window.addEventListener("scroll", () => win++);
document.addEventListener("scroll", () => doc++);
window.scrollTo(0, 100);
setTimeout(() => resolve([win, doc, window.scrollY]), 5);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 100]));
rt.evaluate("window.scrollTo(0, 99999)").unwrap();
rt.run_event_loop_bounded(20).await.unwrap();
let no_op = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
let win = 0, doc = 0;
window.addEventListener("scroll", () => win++);
document.addEventListener("scroll", () => doc++);
const before = window.scrollY;
window.scrollTo(0, 99999);
setTimeout(() => resolve([win, doc, before, window.scrollY]), 5);
})
"#,
true,
true,
)
.await
.unwrap();
let values = no_op.value.unwrap();
let values = values.as_array().expect("array");
assert_eq!(&values[0..2], &serde_json::json!([0, 0]).as_array().unwrap()[..]);
assert_eq!(values[2], values[3]);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_tracks_viewport_threshold_crossings() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="height:150px"></div>
<div id="target" style="height:100px"></div>
<div style="height:300px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const records = [];
const target = document.getElementById("target");
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
records.push([
entry.isIntersecting,
Math.round(entry.intersectionRatio * 100) / 100,
Math.round(entry.boundingClientRect.top),
Math.round(entry.intersectionRect.height),
]);
}
}, { threshold: [0, 0.5, 1] });
observer.observe(target);
setTimeout(() => window.scrollTo(0, 100), 5);
setTimeout(() => window.scrollTo(0, 260), 15);
setTimeout(() => resolve(records), 30);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!([
[false, 0, 150, 0],
[true, 0.5, 50, 50],
[false, 0, -110, 0],
])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_honors_root_margin_zero_area_and_no_fake_refires() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0;position:relative">
<div style="height:110px"></div>
<div id="margin-target" style="height:10px"></div>
<div id="zero" style="position:absolute;left:20px;top:50px;width:0;height:0"></div>
<div id="root" style="height:20px;overflow:auto"></div>
<div style="height:300px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
let result = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
const marginRecords = [], zeroRecords = [];
const marginObserver = new IntersectionObserver(
entries => marginRecords.push(...entries.map(entry => [
entry.isIntersecting,
entry.intersectionRatio,
entry.rootBounds.bottom,
])),
{ rootMargin: "0px 0px 20px", threshold: [0, 1] }
);
const zeroObserver = new IntersectionObserver(
entries => zeroRecords.push(...entries.map(entry => [
entry.isIntersecting,
entry.intersectionRatio,
]))
);
marginObserver.observe(document.getElementById("margin-target"));
zeroObserver.observe(document.getElementById("zero"));
let unsupported = "";
try {
new IntersectionObserver(() => {}, {
root: document.getElementById("root")
});
} catch (error) {
unsupported = error.name;
}
setTimeout(() => resolve([
marginRecords, zeroRecords, unsupported,
marginObserver.rootMargin, marginObserver.thresholds,
]), 200);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(
result.value.unwrap(),
serde_json::json!([
[[true, 1, 120]],
[[true, 1]],
"NotSupportedError",
"0px 0px 20px 0px",
[0, 1],
])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn intersection_observer_recomputes_after_style_mutation_and_resize() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div id="spacer" style="height:150px"></div>
<div id="target" style="height:20px"></div>
<div style="height:300px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.execute_script(
"intersection-mutation",
r#"
globalThis.__ioRecords = [];
globalThis.__io = new IntersectionObserver(entries => {
__ioRecords.push(...entries.map(entry => [
entry.isIntersecting,
Math.round(entry.boundingClientRect.top),
]));
});
__io.observe(document.getElementById("target"));
"#,
)
.unwrap();
rt.run_event_loop_bounded(10).await.unwrap();
rt.evaluate(
r#"document.getElementById("spacer").setAttribute("style", "height:120px")"#,
)
.unwrap();
rt.run_event_loop_bounded(10).await.unwrap();
assert_eq!(
rt.evaluate("__ioRecords").unwrap(),
serde_json::json!([[false, 150]])
);
rt.set_viewport(200.0, 160.0);
rt.run_event_loop_bounded(20).await.unwrap();
assert_eq!(
rt.evaluate("__ioRecords").unwrap(),
serde_json::json!([[false, 150], [true, 120]])
);
}
#[cfg(feature = "render")]
#[test]
fn scroll_into_view_aligns_the_root_viewport_and_clamps() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="height:300px"></div>
<div id="target" style="height:40px"></div>
<div style="height:340px"></div>
<div id="bottom" style="height:20px"></div>
<div id="fixed" style="position:fixed;top:10px;height:20px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
let result = rt
.evaluate(
r#"
const target = document.getElementById("target");
const bottom = document.getElementById("bottom");
const fixed = document.getElementById("fixed");
target.scrollIntoView();
const start = scrollY;
scrollTo(0, 0);
target.scrollIntoView({ block: "center" });
const center = scrollY;
scrollTo(0, 0);
target.scrollIntoView({ block: "end" });
const end = scrollY;
scrollTo(0, 0);
target.scrollIntoView({ block: "nearest" });
const nearestOutside = scrollY;
scrollTo(0, 250);
target.scrollIntoView({ block: "nearest" });
const nearestVisible = scrollY;
fixed.scrollIntoView();
const afterFixed = scrollY;
bottom.scrollIntoView({ block: "start" });
const clamped = scrollY;
const max = document.documentElement.scrollHeight - innerHeight;
return [
start, center, end, nearestOutside, nearestVisible,
afterFixed, clamped, max,
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([300, 270, 240, 240, 250, 250, 600, 600])
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn scroll_into_view_emits_events_only_when_the_root_moves() {
let dom = parse_html(
r#"<html style="margin:0"><body style="margin:0">
<div style="height:300px"></div>
<div id="target" style="height:40px"></div>
<div style="height:300px"></div>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(200.0, 100.0);
rt.run_page_init();
rt.evaluate("window.scrollTo(0, 250)").unwrap();
rt.run_event_loop_bounded(20).await.unwrap();
let no_op = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
let win = 0, doc = 0;
window.addEventListener("scroll", () => win++);
document.addEventListener("scroll", () => doc++);
document.getElementById("target").scrollIntoView({ block: "nearest" });
setTimeout(() => resolve([win, doc, scrollY]), 5);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(no_op.value.unwrap(), serde_json::json!([0, 0, 250]));
rt.evaluate("window.scrollTo(0, 0)").unwrap();
rt.run_event_loop_bounded(20).await.unwrap();
let moved = rt
.evaluate_for_cdp(
r#"
new Promise(resolve => {
let win = 0, doc = 0;
window.addEventListener("scroll", () => win++);
document.addEventListener("scroll", () => doc++);
document.getElementById("target").scrollIntoView({ block: "center" });
setTimeout(() => resolve([win, doc, scrollY]), 5);
})
"#,
true,
true,
)
.await
.unwrap();
assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 270]));
}
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so
/// firstChild()/lastChild() must descend into them. FILTER_REJECT must not.
#[test]