script/layout: Ensure a StackingContextTree before IntersectionObserver geometry queries (#38473)

IntersectionObserver needs to be able to query node geometry without
forcing a layout. A previous layout could have run without needing a
`StackingContextTree`. In that case the layout-less query should finish
building the `StackingContextTree` before doing the query.  Add a new
type of layout API which requests that layout finishes building the
StackingContextTree.

This change also slightly simplifies and corrects the naming of
`Element` APIs around client box queries.

Testing: This should fix intermittent failures in WPT tests.
Fixes: #38380.
Fixes: #38390.
Closes: #38400.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
This commit is contained in:
Martin Robinson 2025-08-06 15:46:43 +02:00 committed by GitHub
parent 757dbc0eda
commit 44a11a7c6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 78 additions and 59 deletions

View file

@ -1056,7 +1056,7 @@ impl Document {
// inside other scrollable containers. Ideally this should use an implementation of
// `scrollIntoView` when that is available:
// See https://github.com/servo/servo/issues/24059.
let rect = element.upcast::<Node>().bounding_content_box_or_zero();
let rect = element.upcast::<Node>().content_box().unwrap_or_default();
// In order to align with element edges, we snap to unscaled pixel boundaries, since
// the paint thread currently does the same for drawing elements. This is important
@ -1356,7 +1356,7 @@ impl Document {
// Notify the embedder to display an input method.
if let Some(kind) = elem.input_method_type() {
let rect = elem.upcast::<Node>().bounding_content_box_or_zero();
let rect = elem.upcast::<Node>().content_box().unwrap_or_default();
let rect = Rect::new(
Point2D::new(rect.origin.x.to_px(), rect.origin.y.to_px()),
Size2D::new(rect.size.width.to_px(), rect.size.height.to_px()),

View file

@ -1005,7 +1005,7 @@ impl Element {
block: ScrollLogicalPosition,
inline: ScrollLogicalPosition,
) -> ScrollPosition {
let target_bounding_box = self.upcast::<Node>().bounding_content_box_or_zero();
let target_bounding_box = self.upcast::<Node>().content_box().unwrap_or_default();
let device_pixel_ratio = self
.upcast::<Node>()
@ -1071,7 +1071,7 @@ impl Element {
} else {
// Handle element-specific scrolling
// Scrolling box bounds and current scroll position
let scrolling_box = scrolling_node.bounding_content_box_or_zero();
let scrolling_box = scrolling_node.content_box().unwrap_or_default();
let scrolling_left = scrolling_box.origin.x.to_nearest_pixel(device_pixel_ratio) as f64;
let scrolling_top = scrolling_box.origin.y.to_nearest_pixel(device_pixel_ratio) as f64;
let scrolling_width = scrolling_box
@ -1125,7 +1125,7 @@ impl Element {
if matches!(position, Position::Relative | Position::Absolute) {
// If this element establishes a positioning context,
// Get its bounding box to calculate the offset
let positioning_box = node.bounding_content_box_or_zero();
let positioning_box = node.content_box().unwrap_or_default();
let positioning_left = positioning_box
.origin
.x
@ -3458,7 +3458,7 @@ impl ElementMethods<crate::DomTypeHolder> for Element {
// https://drafts.csswg.org/cssom-view/#dom-element-getboundingclientrect
fn GetBoundingClientRect(&self, can_gc: CanGc) -> DomRoot<DOMRect> {
let win = self.owner_window();
let rect = self.upcast::<Node>().bounding_content_box_or_zero();
let rect = self.upcast::<Node>().content_box().unwrap_or_default();
DOMRect::new(
win.upcast(),
rect.origin.x.to_f64_px(),

View file

@ -329,7 +329,7 @@ impl Activatable for HTMLAnchorElement {
if let Some(element) = target.downcast::<Element>() {
if target.is::<HTMLImageElement>() && element.has_attribute(&local_name!("ismap")) {
let target_node = element.upcast::<Node>();
let rect = target_node.bounding_content_box_or_zero();
let rect = target_node.content_box().unwrap_or_default();
ismap_suffix = Some(format!(
"?{},{}",
mouse_event.ClientX().to_f32().unwrap() - rect.origin.x.to_f32_px(),

View file

@ -1657,10 +1657,9 @@ impl HTMLImageElementMethods<crate::DomTypeHolder> for HTMLImageElement {
// https://html.spec.whatwg.org/multipage/#dom-img-width
fn Width(&self) -> u32 {
let node = self.upcast::<Node>();
match node.bounding_content_box() {
Some(rect) => rect.size.width.to_px() as u32,
None => self.NaturalWidth(),
}
node.content_box()
.map(|rect| rect.size.width.to_px() as u32)
.unwrap_or_else(|| self.NaturalWidth())
}
// https://html.spec.whatwg.org/multipage/#dom-img-width
@ -1671,10 +1670,9 @@ impl HTMLImageElementMethods<crate::DomTypeHolder> for HTMLImageElement {
// https://html.spec.whatwg.org/multipage/#dom-img-height
fn Height(&self) -> u32 {
let node = self.upcast::<Node>();
match node.bounding_content_box() {
Some(rect) => rect.size.height.to_px() as u32,
None => self.NaturalHeight(),
}
node.content_box()
.map(|rect| rect.size.height.to_px() as u32)
.unwrap_or_else(|| self.NaturalHeight())
}
// https://html.spec.whatwg.org/multipage/#dom-img-height

View file

@ -2736,7 +2736,7 @@ impl HTMLInputElement {
let (ipc_sender, ipc_receiver) =
ipc::channel::<Option<RgbColor>>().expect("Failed to create IPC channel!");
let document = self.owner_document();
let rect = self.upcast::<Node>().bounding_content_box_or_zero();
let rect = self.upcast::<Node>().content_box().unwrap_or_default();
let rect = Rect::new(
Point2D::new(rect.origin.x.to_px(), rect.origin.y.to_px()),
Size2D::new(rect.size.width.to_px(), rect.size.height.to_px()),

View file

@ -377,7 +377,7 @@ impl HTMLSelectElement {
})
.collect();
let rect = self.upcast::<Node>().bounding_content_box_or_zero();
let rect = self.upcast::<Node>().content_box().unwrap_or_default();
let rect = Rect::new(
Point2D::new(rect.origin.x.to_px(), rect.origin.y.to_px()),
Size2D::new(rect.size.width.to_px(), rect.size.height.to_px()),

View file

@ -408,6 +408,7 @@ impl IntersectionObserver {
///
/// <https://w3c.github.io/IntersectionObserver/#intersectionobserver-root-intersection-rectangle>
pub(crate) fn root_intersection_rectangle(&self, document: &Document) -> Option<Rect<Au>> {
let window = document.window();
let intersection_rectangle = match &self.root {
// Handle if root is an element.
Some(ElementOrDocument::Element(element)) => {
@ -419,7 +420,7 @@ impl IntersectionObserver {
// > Otherwise, its the result of getting the bounding box for the intersection root.
// TODO: replace this once getBoundingBox() is implemented correctly.
DomRoot::upcast::<Node>(element.clone()).bounding_content_box_no_reflow()
window.content_box_query_without_reflow(&DomRoot::upcast::<Node>(element.clone()))
},
// Handle if root is a Document, which includes implicit root and explicit Document root.
_ => {
@ -503,7 +504,9 @@ impl IntersectionObserver {
// This is what we are currently using for getBoundingBox(). However, it is not correct,
// mainly because it is not considering transform and scroll offset.
// TODO: replace this once getBoundingBox() is implemented correctly.
let maybe_target_rect = target.upcast::<Node>().bounding_content_box_no_reflow();
let maybe_target_rect = document
.window()
.content_box_query_without_reflow(target.upcast::<Node>());
// Following the implementation of Gecko, we will skip further processing if these
// information not available. This would also handle display none element.

View file

@ -942,20 +942,10 @@ impl Node {
TrustedNodeAddress(self as *const Node as *const libc::c_void)
}
/// Returns the rendered bounding content box if the element is rendered,
/// and none otherwise.
pub(crate) fn bounding_content_box(&self) -> Option<Rect<Au>> {
pub(crate) fn content_box(&self) -> Option<Rect<Au>> {
self.owner_window().content_box_query(self)
}
pub(crate) fn bounding_content_box_or_zero(&self) -> Rect<Au> {
self.bounding_content_box().unwrap_or_else(Rect::zero)
}
pub(crate) fn bounding_content_box_no_reflow(&self) -> Option<Rect<Au>> {
self.owner_window().content_box_query_unchecked(self)
}
pub(crate) fn content_boxes(&self) -> Vec<Rect<Au>> {
self.owner_window().content_boxes_query(self)
}

View file

@ -2434,16 +2434,19 @@ impl Window {
)
}
// Query content box without considering any reflow
pub(crate) fn content_box_query_unchecked(&self, node: &Node) -> Option<UntypedRect<Au>> {
self.layout
.borrow()
.query_content_box(node.to_trusted_node_address())
/// Do the same kind of query as `Self::content_box_query`, but do not force a reflow.
/// This is used for things like `IntersectionObserver` which should observe the value
/// from the most recent reflow, but do not need it to reflect the current state of
/// the DOM / style.
pub(crate) fn content_box_query_without_reflow(&self, node: &Node) -> Option<UntypedRect<Au>> {
let layout = self.layout.borrow();
layout.ensure_stacking_context_tree(self.viewport_details.get());
layout.query_content_box(node.to_trusted_node_address())
}
pub(crate) fn content_box_query(&self, node: &Node) -> Option<UntypedRect<Au>> {
self.layout_reflow(QueryMsg::ContentBox);
self.content_box_query_unchecked(node)
self.content_box_query_without_reflow(node)
}
pub(crate) fn content_boxes_query(&self, node: &Node) -> Vec<UntypedRect<Au>> {