script/layout: Implement HTMLElement.scrollParent (#39110)

This new API allows getting the element which establishes an element's
scroll container. This will be used to properly implement
`scrollIntoView`. There is still work to do for this API and
`offsetParent` to properly handle ancestors which are
closed-shadow-hidden from the original query element.

In addition, fix an issue where inline boxes were establishing scrolling
containers (they shouldn't do that).

Testing: There are tests for this change.
Fixes: #39096.

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
Co-authored-by: Oriol Brufau <obrufau@igalia.com>
This commit is contained in:
Martin Robinson 2025-09-03 11:52:15 -07:00 committed by GitHub
parent 5c7ea4bdee
commit 2c7866eb24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 173 additions and 49 deletions

View file

@ -29,7 +29,8 @@ use layout_api::wrapper_traits::LayoutNode;
use layout_api::{ use layout_api::{
BoxAreaType, IFrameSizes, Layout, LayoutConfig, LayoutDamage, LayoutFactory, BoxAreaType, IFrameSizes, Layout, LayoutConfig, LayoutDamage, LayoutFactory,
OffsetParentResponse, PropertyRegistration, QueryMsg, ReflowGoal, ReflowPhasesRun, OffsetParentResponse, PropertyRegistration, QueryMsg, ReflowGoal, ReflowPhasesRun,
ReflowRequest, ReflowRequestRestyle, ReflowResult, RegisterPropertyError, TrustedNodeAddress, ReflowRequest, ReflowRequestRestyle, ReflowResult, RegisterPropertyError, ScrollParentResponse,
TrustedNodeAddress,
}; };
use log::{debug, error, warn}; use log::{debug, error, warn};
use malloc_size_of::{MallocConditionalSizeOf, MallocSizeOf, MallocSizeOfOps}; use malloc_size_of::{MallocConditionalSizeOf, MallocSizeOf, MallocSizeOfOps};
@ -91,7 +92,8 @@ use crate::display_list::{DisplayListBuilder, HitTest, StackingContextTree};
use crate::query::{ use crate::query::{
get_the_text_steps, process_box_area_request, process_box_areas_request, get_the_text_steps, process_box_area_request, process_box_areas_request,
process_client_rect_request, process_node_scroll_area_request, process_offset_parent_query, process_client_rect_request, process_node_scroll_area_request, process_offset_parent_query,
process_resolved_font_style_query, process_resolved_style_request, process_text_index_request, process_resolved_font_style_query, process_resolved_style_request, process_scroll_parent_query,
process_text_index_request,
}; };
use crate::traversal::{RecalcStyle, compute_damage_and_repair_style}; use crate::traversal::{RecalcStyle, compute_damage_and_repair_style};
use crate::{BoxTree, FragmentTree}; use crate::{BoxTree, FragmentTree};
@ -323,6 +325,12 @@ impl Layout for LayoutThread {
process_offset_parent_query(node).unwrap_or_default() process_offset_parent_query(node).unwrap_or_default()
} }
#[servo_tracing::instrument(skip_all)]
fn query_scroll_parent(&self, node: TrustedNodeAddress) -> Option<ScrollParentResponse> {
let node = unsafe { ServoLayoutNode::new(&node) };
process_scroll_parent_query(node)
}
#[servo_tracing::instrument(skip_all)] #[servo_tracing::instrument(skip_all)]
fn query_resolved_style( fn query_resolved_style(
&self, &self,
@ -1608,6 +1616,7 @@ impl ReflowPhases {
QueryMsg::ElementInnerOuterTextQuery | QueryMsg::ElementInnerOuterTextQuery |
QueryMsg::InnerWindowDimensionsQuery | QueryMsg::InnerWindowDimensionsQuery |
QueryMsg::OffsetParentQuery | QueryMsg::OffsetParentQuery |
QueryMsg::ScrollParentQuery |
QueryMsg::ResolvedFontStyleQuery | QueryMsg::ResolvedFontStyleQuery |
QueryMsg::TextIndexQuery | QueryMsg::TextIndexQuery |
QueryMsg::StyleQuery => Self::empty(), QueryMsg::StyleQuery => Self::empty(),

View file

@ -11,7 +11,9 @@ use euclid::default::{Point2D, Rect};
use euclid::{SideOffsets2D, Size2D}; use euclid::{SideOffsets2D, Size2D};
use itertools::Itertools; use itertools::Itertools;
use layout_api::wrapper_traits::{LayoutNode, ThreadSafeLayoutElement, ThreadSafeLayoutNode}; use layout_api::wrapper_traits::{LayoutNode, ThreadSafeLayoutElement, ThreadSafeLayoutNode};
use layout_api::{BoxAreaType, LayoutElementType, LayoutNodeType, OffsetParentResponse}; use layout_api::{
BoxAreaType, LayoutElementType, LayoutNodeType, OffsetParentResponse, ScrollParentResponse,
};
use script::layout_dom::{ServoLayoutNode, ServoThreadSafeLayoutNode}; use script::layout_dom::{ServoLayoutNode, ServoThreadSafeLayoutNode};
use servo_arc::Arc as ServoArc; use servo_arc::Arc as ServoArc;
use servo_geometry::{FastLayoutTransform, au_rect_to_f32_rect, f32_rect_to_au_rect}; use servo_geometry::{FastLayoutTransform, au_rect_to_f32_rect, f32_rect_to_au_rect};
@ -47,6 +49,7 @@ use crate::flow::inline::construct::{TextTransformation, WhitespaceCollapse, cap
use crate::fragment_tree::{ use crate::fragment_tree::{
BoxFragment, Fragment, FragmentFlags, FragmentTree, SpecificLayoutInfo, BoxFragment, Fragment, FragmentFlags, FragmentTree, SpecificLayoutInfo,
}; };
use crate::style_ext::ComputedValuesExt;
use crate::taffy::SpecificTaffyGridInfo; use crate::taffy::SpecificTaffyGridInfo;
/// Get a scroll node that would represents this [`ServoLayoutNode`]'s transform and /// Get a scroll node that would represents this [`ServoLayoutNode`]'s transform and
@ -638,6 +641,95 @@ pub fn process_offset_parent_query(node: ServoLayoutNode<'_>) -> Option<OffsetPa
}) })
} }
/// This is an implementation of
/// <https://drafts.csswg.org/cssom-view/#dom-htmlelement-scrollparent>.
#[inline]
pub(crate) fn process_scroll_parent_query(
node: ServoLayoutNode<'_>,
) -> Option<ScrollParentResponse> {
let layout_data = node.to_threadsafe().inner_layout_data()?;
// 1. If any of the following holds true, return null and terminate this algorithm:
// - The element does not have an associated box.
let layout_box = layout_data.self_box.borrow();
let layout_box = layout_box.as_ref()?;
let (mut current_position_value, flags) = layout_box
.with_base_flat(|base| vec![(base.style.clone_position(), base.base_fragment_info.flags)])
.first()
.cloned()?;
// - The element is the root element.
// - The element is the body element.
// - The elements computed value of the position property is fixed and no ancestor
// establishes a fixed position containing block.
if flags.intersects(
FragmentFlags::IS_ROOT_ELEMENT | FragmentFlags::IS_BODY_ELEMENT_OF_HTML_ELEMENT_ROOT,
) {
return None;
}
// 2. Let ancestor be the containing block of the element in the flat tree and repeat these substeps:
// - If ancestor is the initial containing block, return the scrollingElement for the
// elements document if it is not closed-shadow-hidden from the element, otherwise
// return null.
// - If ancestor is not closed-shadow-hidden from the element, and is a scroll
// container, terminate this algorithm and return ancestor.
// - If the computed value of the position property of ancestor is fixed, and no
// ancestor establishes a fixed position containing block, terminate this algorithm
// and return null.
// - Let ancestor be the containing block of ancestor in the flat tree.
//
// Notes: We don't follow the specification exactly below, but we follow the spirit.
//
// TODO: Handle the situation where the ancestor is "closed-shadow-hidden" from the element.
let mut current_ancestor = node.as_element()?;
while let Some(ancestor) = current_ancestor.traversal_parent() {
current_ancestor = ancestor;
let Some(layout_data) = ancestor.as_node().to_threadsafe().inner_layout_data() else {
continue;
};
let ancestor_layout_box = layout_data.self_box.borrow();
let Some(ancestor_layout_box) = ancestor_layout_box.as_ref() else {
continue;
};
let (ancestor_style, ancestor_flags) = ancestor_layout_box
.with_base_flat(|base| vec![(base.style.clone(), base.base_fragment_info.flags)])
.first()
.cloned()?;
let is_containing_block = match current_position_value {
Position::Static | Position::Relative | Position::Sticky => {
!ancestor_style.is_inline_box(ancestor_flags)
},
Position::Absolute => {
ancestor_style.establishes_containing_block_for_absolute_descendants(ancestor_flags)
},
Position::Fixed => {
ancestor_style.establishes_containing_block_for_all_descendants(ancestor_flags)
},
};
if !is_containing_block {
continue;
}
if ancestor_style.establishes_scroll_container(ancestor_flags) {
return Some(ScrollParentResponse::Element(
ancestor.as_node().opaque().into(),
));
}
current_position_value = ancestor_style.clone_position();
}
match current_position_value {
Position::Fixed => None,
_ => Some(ScrollParentResponse::DocumentScrollingElement),
}
}
/// <https://html.spec.whatwg.org/multipage/#get-the-text-steps> /// <https://html.spec.whatwg.org/multipage/#get-the-text-steps>
pub fn get_the_text_steps(node: ServoLayoutNode<'_>) -> String { pub fn get_the_text_steps(node: ServoLayoutNode<'_>) -> String {
// Step 1: If element is not being rendered or if the user agent is a non-CSS user agent, then // Step 1: If element is not being rendered or if the user agent is a non-CSS user agent, then

View file

@ -598,6 +598,14 @@ impl ComputedValuesExt for ComputedValues {
let mut overflow_x = style_box.overflow_x; let mut overflow_x = style_box.overflow_x;
let mut overflow_y = style_box.overflow_y; let mut overflow_y = style_box.overflow_y;
// Inline boxes should never establish scroll containers.
if self.is_inline_box(fragment_flags) {
return AxesOverflow {
x: Overflow::Visible,
y: Overflow::Visible,
};
}
// https://www.w3.org/TR/css-overflow-3/#overflow-propagation // https://www.w3.org/TR/css-overflow-3/#overflow-propagation
// The element from which the value is propagated must then have a used overflow value of visible. // The element from which the value is propagated must then have a used overflow value of visible.
if fragment_flags.contains(FragmentFlags::PROPAGATED_OVERFLOW_TO_VIEWPORT) { if fragment_flags.contains(FragmentFlags::PROPAGATED_OVERFLOW_TO_VIEWPORT) {

View file

@ -444,6 +444,11 @@ impl HTMLElementMethods<crate::DomTypeHolder> for HTMLElement {
document.request_focus(None, FocusInitiator::Local, can_gc); document.request_focus(None, FocusInitiator::Local, can_gc);
} }
/// <https://drafts.csswg.org/cssom-view/#dom-htmlelement-scrollparent>
fn GetScrollParent(&self) -> Option<DomRoot<Element>> {
self.owner_window().scroll_parent_query(self.upcast())
}
/// <https://drafts.csswg.org/cssom-view/#dom-htmlelement-offsetparent> /// <https://drafts.csswg.org/cssom-view/#dom-htmlelement-offsetparent>
fn GetOffsetParent(&self) -> Option<DomRoot<Element>> { fn GetOffsetParent(&self) -> Option<DomRoot<Element>> {
if self.is_body_element() || self.is::<HTMLHtmlElement>() { if self.is_body_element() || self.is::<HTMLHtmlElement>() {

View file

@ -56,8 +56,8 @@ use js::rust::{
use layout_api::{ use layout_api::{
BoxAreaType, ElementsFromPointFlags, ElementsFromPointResult, FragmentType, Layout, BoxAreaType, ElementsFromPointFlags, ElementsFromPointResult, FragmentType, Layout,
PendingImage, PendingImageState, PendingRasterizationImage, QueryMsg, ReflowGoal, PendingImage, PendingImageState, PendingRasterizationImage, QueryMsg, ReflowGoal,
ReflowPhasesRun, ReflowRequest, ReflowRequestRestyle, RestyleReason, TrustedNodeAddress, ReflowPhasesRun, ReflowRequest, ReflowRequestRestyle, RestyleReason, ScrollParentResponse,
combine_id_with_fragment_type, TrustedNodeAddress, combine_id_with_fragment_type,
}; };
use malloc_size_of::MallocSizeOf; use malloc_size_of::MallocSizeOf;
use media::WindowGLContext; use media::WindowGLContext;
@ -2599,6 +2599,23 @@ impl Window {
(element, response.rect) (element, response.rect)
} }
#[allow(unsafe_code)]
pub(crate) fn scroll_parent_query(&self, node: &Node) -> Option<DomRoot<Element>> {
self.layout_reflow(QueryMsg::ScrollParentQuery);
self.layout
.borrow()
.query_scroll_parent(node.to_trusted_node_address())
.and_then(|response| match response {
ScrollParentResponse::DocumentScrollingElement => {
self.Document().GetScrollingElement()
},
ScrollParentResponse::Element(parent_node_address) => {
let node = unsafe { from_untrusted_node_address(parent_node_address) };
DomRoot::downcast(node)
},
})
}
pub(crate) fn text_index_query( pub(crate) fn text_index_query(
&self, &self,
node: &Node, node: &Node,

View file

@ -64,6 +64,7 @@ interface HTMLElement : Element {
// http://dev.w3.org/csswg/cssom-view/#extensions-to-the-htmlelement-interface // http://dev.w3.org/csswg/cssom-view/#extensions-to-the-htmlelement-interface
partial interface HTMLElement { partial interface HTMLElement {
// CSSOM things are not [Pure] because they can flush // CSSOM things are not [Pure] because they can flush
readonly attribute Element? scrollParent;
readonly attribute Element? offsetParent; readonly attribute Element? offsetParent;
readonly attribute long offsetTop; readonly attribute long offsetTop;
readonly attribute long offsetLeft; readonly attribute long offsetLeft;

View file

@ -295,6 +295,7 @@ pub trait Layout {
fn query_client_rect(&self, node: TrustedNodeAddress) -> Rect<i32>; fn query_client_rect(&self, node: TrustedNodeAddress) -> Rect<i32>;
fn query_element_inner_outer_text(&self, node: TrustedNodeAddress) -> String; fn query_element_inner_outer_text(&self, node: TrustedNodeAddress) -> String;
fn query_offset_parent(&self, node: TrustedNodeAddress) -> OffsetParentResponse; fn query_offset_parent(&self, node: TrustedNodeAddress) -> OffsetParentResponse;
fn query_scroll_parent(&self, node: TrustedNodeAddress) -> Option<ScrollParentResponse>;
fn query_resolved_style( fn query_resolved_style(
&self, &self,
node: TrustedNodeAddress, node: TrustedNodeAddress,
@ -351,6 +352,12 @@ pub struct OffsetParentResponse {
pub rect: Rect<Au>, pub rect: Rect<Au>,
} }
#[derive(Clone)]
pub enum ScrollParentResponse {
DocumentScrollingElement,
Element(UntrustedNodeAddress),
}
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum QueryMsg { pub enum QueryMsg {
BoxArea, BoxArea,
@ -361,6 +368,7 @@ pub enum QueryMsg {
InnerWindowDimensionsQuery, InnerWindowDimensionsQuery,
NodesFromPointQuery, NodesFromPointQuery,
OffsetParentQuery, OffsetParentQuery,
ScrollParentQuery,
ResolvedFontStyleQuery, ResolvedFontStyleQuery,
ResolvedStyleQuery, ResolvedStyleQuery,
ScrollingAreaOrOffsetQuery, ScrollingAreaOrOffsetQuery,

View file

@ -232173,6 +232173,19 @@
{} {}
] ]
], ],
"overflow-does-not-apply-to-inline-box.html": [
"f4c8a1816399ed368fb219bd1a4e49f61f18d083",
[
null,
[
[
"/css/reference/ref-filled-green-100px-square.xht",
"=="
]
],
{}
]
],
"overflow-ellipsis-dynamic-001.html": [ "overflow-ellipsis-dynamic-001.html": [
"2a9edba9308bf06009d7b9a27f21f1e0f1a231c7", "2a9edba9308bf06009d7b9a27f21f1e0f1a231c7",
[ [
@ -630023,7 +630036,7 @@
] ]
], ],
"scrollParent.html": [ "scrollParent.html": [
"9faa0c9a3e92e38662ac77d25b08731fe00a7db3", "344ee522ef2664e0b963e79812074f08f343f5bf",
[ [
null, null,
{} {}

View file

@ -574,12 +574,3 @@
[Document interface: calling caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions) on document with too few arguments must throw TypeError] [Document interface: calling caretPositionFromPoint(double, double, optional CaretPositionFromPointOptions) on document with too few arguments must throw TypeError]
expected: FAIL expected: FAIL
[HTMLElement interface: attribute scrollParent]
expected: FAIL
[HTMLElement interface: document.createElement("div") must inherit property "scrollParent" with the proper type]
expected: FAIL
[HTMLElement interface: document.createElement("img") must inherit property "scrollParent" with the proper type]
expected: FAIL

View file

@ -4,9 +4,3 @@
[scrollParent skips intermediate open shadow tree nodes] [scrollParent skips intermediate open shadow tree nodes]
expected: FAIL expected: FAIL
[scrollParent from inside closed shadow tree]
expected: FAIL
[scrollParent from inside open shadow tree]
expected: FAIL

View file

@ -1,27 +0,0 @@
[scrollParent.html]
[scrollParent returns the nearest scroll container.]
expected: FAIL
[hidden element is a scroll container.]
expected: FAIL
[Element with no box has null scrollParent.]
expected: FAIL
[scrollParent follows absolute positioned containing block chain.]
expected: FAIL
[scrollParent follows fixed positioned containing block chain.]
expected: FAIL
[scrollParent of element fixed to root is null.]
expected: FAIL
[scrollParent of child in root viewport returns document scrolling element.]
expected: FAIL
[scrollParent of fixed element contained within root is document scrolling element.]
expected: FAIL
[scrollParent of body is null.]
expected: FAIL

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<link rel="author" title="Oriol Brufau" href="mailto:obrufau@igalia.com">
<link rel="help" href="https://drafts.csswg.org/css-overflow-3/#overflow-control">
<meta name="assert" content="Overflow does not apply to inline boxes so they do not establish scroll containers.">
<link rel="match" href="../reference/ref-filled-green-100px-square.xht">
<link rel="stylesheet" type="text/css" href="/fonts/ahem.css">
<p>Test passes if there is a filled green square and <strong>no red</strong>.</p>
<div style="font: 100px/1 Ahem; background: red; width: 100px">
<span style="overflow: hidden; border-radius: 50%; color: green">X</span>
</div>

View file

@ -29,6 +29,7 @@
display: none; display: none;
} }
.contains-fixed { .contains-fixed {
transform: scale(1);
contain: paint; contain: paint;
} }
</style> </style>
@ -83,4 +84,4 @@ test(() => { assert_equals(document.body.scrollParent, null); },
"scrollParent of body is null."); "scrollParent of body is null.");
</script> </script>
</html> </html>