script: Chain up scrollIntoView() scrolling to parent <iframe>s (#39475)

Calling `scrollIntoView()` on an element within an `<iframe>` will now
scroll scrolling boxes from the parent document(s), as long as they have
the same origin.

Testing: One existing subtest passes, and adding a new test.

Signed-off-by: Oriol Brufau <obrufau@igalia.com>
Co-authored-by: Martin Robinson <mrobinson@igalia.com>
This commit is contained in:
Oriol Brufau 2025-09-27 00:12:37 +02:00 committed by GitHub
parent b38bf3e606
commit 89293995f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 262 additions and 124 deletions

View file

@ -17,12 +17,11 @@ use cssparser::match_ignore_ascii_case;
use devtools_traits::AttrInfo; use devtools_traits::AttrInfo;
use dom_struct::dom_struct; use dom_struct::dom_struct;
use embedder_traits::InputMethodType; use embedder_traits::InputMethodType;
use euclid::Vector2D;
use euclid::default::{Rect, Size2D}; use euclid::default::{Rect, Size2D};
use html5ever::serialize::TraversalScope; use html5ever::serialize::TraversalScope;
use html5ever::serialize::TraversalScope::{ChildrenOnly, IncludeNode}; use html5ever::serialize::TraversalScope::{ChildrenOnly, IncludeNode};
use html5ever::{LocalName, Namespace, Prefix, QualName, local_name, namespace_prefix, ns}; use html5ever::{LocalName, Namespace, Prefix, QualName, local_name, namespace_prefix, ns};
use js::jsapi::Heap; use js::jsapi::{Heap, JSAutoRealm};
use js::jsval::JSVal; use js::jsval::JSVal;
use js::rust::HandleObject; use js::rust::HandleObject;
use layout_api::{LayoutDamage, ScrollContainerQueryFlags}; use layout_api::{LayoutDamage, ScrollContainerQueryFlags};
@ -61,7 +60,6 @@ use style::values::{AtomIdent, AtomString, CSSFloat, computed, specified};
use style::{ArcSlice, CaseSensitivityExt, dom_apis, thread_state}; use style::{ArcSlice, CaseSensitivityExt, dom_apis, thread_state};
use stylo_atoms::Atom; use stylo_atoms::Atom;
use stylo_dom::ElementState; use stylo_dom::ElementState;
use webrender_api::units::LayoutVector2D;
use xml5ever::serialize::TraversalScope::{ use xml5ever::serialize::TraversalScope::{
ChildrenOnly as XmlChildrenOnly, IncludeNode as XmlIncludeNode, ChildrenOnly as XmlChildrenOnly, IncludeNode as XmlIncludeNode,
}; };
@ -166,7 +164,7 @@ use crate::dom::node::{
use crate::dom::nodelist::NodeList; use crate::dom::nodelist::NodeList;
use crate::dom::promise::Promise; use crate::dom::promise::Promise;
use crate::dom::raredata::ElementRareData; use crate::dom::raredata::ElementRareData;
use crate::dom::scrolling_box::{ScrollingBox, ScrollingBoxSource}; use crate::dom::scrolling_box::ScrollingBox;
use crate::dom::servoparser::ServoParser; use crate::dom::servoparser::ServoParser;
use crate::dom::shadowroot::{IsUserAgentWidget, ShadowRoot}; use crate::dom::shadowroot::{IsUserAgentWidget, ShadowRoot};
use crate::dom::text::Text; use crate::dom::text::Text;
@ -836,7 +834,19 @@ impl Element {
block: ScrollLogicalPosition, block: ScrollLogicalPosition,
inline: ScrollLogicalPosition, inline: ScrollLogicalPosition,
container: Option<&Element>, container: Option<&Element>,
inner_target_rect: Option<Rect<Au>>,
) { ) {
let get_target_rect = || match inner_target_rect {
None => self.upcast::<Node>().border_box().unwrap_or_default(),
Some(inner_target_rect) => inner_target_rect.translate(
self.upcast::<Node>()
.content_box()
.unwrap_or_default()
.origin
.to_vector(),
),
};
// Step 1: For each ancestor element or viewport that establishes a scrolling box `scrolling // Step 1: For each ancestor element or viewport that establishes a scrolling box `scrolling
// box`, in order of innermost to outermost scrolling box, run these substeps: // box`, in order of innermost to outermost scrolling box, run these substeps:
let mut parent_scrolling_box = self.scrolling_box(ScrollContainerQueryFlags::empty()); let mut parent_scrolling_box = self.scrolling_box(ScrollContainerQueryFlags::empty());
@ -853,7 +863,8 @@ impl Element {
// determine the scroll-into-view position of `target` with `behavior` as the scroll // determine the scroll-into-view position of `target` with `behavior` as the scroll
// behavior, `block` as the block flow position, `inline` as the inline base direction // behavior, `block` as the block flow position, `inline` as the inline base direction
// position and `scrolling box` as the scrolling box. // position and `scrolling box` as the scrolling box.
let position = self.determine_scroll_into_view_position(&scrolling_box, block, inline); let position =
scrolling_box.determine_scroll_into_view_position(block, inline, get_target_rect());
// Step 1.3: If `position` is not the same as `scrolling box`s current scroll position, or // Step 1.3: If `position` is not the same as `scrolling box`s current scroll position, or
// `scrolling box` has an ongoing smooth scroll, // `scrolling box` has an ongoing smooth scroll,
@ -882,127 +893,27 @@ impl Element {
.node() .node()
.is_shadow_including_inclusive_ancestor_of(container_node) .is_shadow_including_inclusive_ancestor_of(container_node)
}) { }) {
break; return;
} }
} }
}
/// <https://drafts.csswg.org/cssom-view/#determine-the-scroll-into-view-position> let window_proxy = self.owner_window().window_proxy();
fn determine_scroll_into_view_position( let Some(frame_element) = window_proxy.frame_element() else {
&self, return;
scrolling_box: &ScrollingBox, };
block: ScrollLogicalPosition,
inline: ScrollLogicalPosition,
) -> LayoutVector2D {
let device_pixel_ratio = self
.upcast::<Node>()
.owner_doc()
.window()
.device_pixel_ratio()
.get();
let to_pixel = |value: Au| value.to_nearest_pixel(device_pixel_ratio);
// > Step 1: Let target bounding border box be the box represented by the return let inner_target_rect = Some(get_target_rect());
// > value of invoking Elements getBoundingClientRect(), if target is an Element, let parent_window = frame_element.owner_window();
// > or Ranges getBoundingClientRect(), if target is a Range. let cx = GlobalScope::get_cx();
let target_border_box = self.upcast::<Node>().border_box().unwrap_or_default(); let _ac = JSAutoRealm::new(*cx, *parent_window.reflector().get_jsobject());
let target_top_left = target_border_box.origin.map(to_pixel).to_untyped(); frame_element.scroll_into_view_with_options(
let target_bottom_right = target_border_box.max().map(to_pixel); behavior,
block,
// The rest of the steps diverge from the specification here, but essentially try inline,
// to follow it using our own geometry types. None,
// inner_target_rect,
// TODO: This makes the code below wrong for the purposes of writing modes.
let (adjusted_element_top_left, adjusted_element_bottom_right) =
match scrolling_box.target() {
ScrollingBoxSource::Viewport(_) => (target_top_left, target_bottom_right),
ScrollingBoxSource::Element(scrolling_element) => {
let scrolling_padding_rect_top_left = scrolling_element
.upcast::<Node>()
.padding_box()
.unwrap_or_default()
.origin
.map(to_pixel);
(
target_top_left - scrolling_padding_rect_top_left.to_vector(),
target_bottom_right - scrolling_padding_rect_top_left.to_vector(),
)
},
};
let scrolling_box_size = scrolling_box.size();
let current_scroll_position = scrolling_box.scroll_position();
Vector2D::new(
self.calculate_scroll_position_one_axis(
inline,
adjusted_element_top_left.x,
adjusted_element_bottom_right.x,
scrolling_box_size.width,
current_scroll_position.x,
),
self.calculate_scroll_position_one_axis(
block,
adjusted_element_top_left.y,
adjusted_element_bottom_right.y,
scrolling_box_size.height,
current_scroll_position.y,
),
) )
} }
/// Step 10 from <https://drafts.csswg.org/cssom-view/#determine-the-scroll-into-view-position>:
fn calculate_scroll_position_one_axis(
&self,
alignment: ScrollLogicalPosition,
element_start: f32,
element_end: f32,
container_size: f32,
current_scroll_offset: f32,
) -> f32 {
let element_size = element_end - element_start;
current_scroll_offset +
match alignment {
// Step 1 & 5: If inline is "start", then align element start edge with scrolling box start edge.
ScrollLogicalPosition::Start => element_start,
// Step 2 & 6: If inline is "end", then align element end edge with
// scrolling box end edge.
ScrollLogicalPosition::End => element_end - container_size,
// Step 3 & 7: If inline is "center", then align the center of target bounding
// border box with the center of scrolling box in scrolling boxs inline base direction.
ScrollLogicalPosition::Center => {
element_start + (element_size - container_size) / 2.0
},
// Step 4 & 8: If inline is "nearest",
ScrollLogicalPosition::Nearest => {
let viewport_start = current_scroll_offset;
let viewport_end = current_scroll_offset + container_size;
// Step 4.2 & 8.2: If element start edge is outside scrolling box start edge and element
// size is less than scrolling box size or If element end edge is outside
// scrolling box end edge and element size is greater than scrolling box size:
// Align element start edge with scrolling box start edge.
if (element_start < viewport_start && element_size <= container_size) ||
(element_end > viewport_end && element_size >= container_size)
{
element_start
}
// Step 4.3 & 8.3: If element end edge is outside scrolling box start edge and element
// size is greater than scrolling box size or If element start edge is outside
// scrolling box end edge and element size is less than scrolling box size:
// Align element end edge with scrolling box end edge.
else if (element_end > viewport_end && element_size < container_size) ||
(element_start < viewport_start && element_size > container_size)
{
element_end - container_size
}
// Step 4.1 & 8.1: If element start edge and element end edge are both outside scrolling
// box start edge and scrolling box end edge or an invalid situation: Do nothing.
else {
current_scroll_offset
}
},
}
}
} }
/// <https://dom.spec.whatwg.org/#valid-shadow-host-name> /// <https://dom.spec.whatwg.org/#valid-shadow-host-name>
@ -3530,7 +3441,7 @@ impl ElementMethods<crate::DomTypeHolder> for Element {
} }
// Step 8: Scroll the element into view with behavior, block, inline, and container. // Step 8: Scroll the element into view with behavior, block, inline, and container.
self.scroll_into_view_with_options(behavior, block, inline, container); self.scroll_into_view_with_options(behavior, block, inline, container, None);
// Step 9: Optionally perform some other action that brings the // Step 9: Optionally perform some other action that brings the
// element to the users attention. // element to the users attention.

View file

@ -4,6 +4,9 @@
use std::cell::Cell; use std::cell::Cell;
use app_units::Au;
use euclid::Vector2D;
use euclid::default::Rect;
use layout_api::{AxesOverflow, ScrollContainerQueryFlags}; use layout_api::{AxesOverflow, ScrollContainerQueryFlags};
use script_bindings::codegen::GenericBindings::WindowBinding::ScrollBehavior; use script_bindings::codegen::GenericBindings::WindowBinding::ScrollBehavior;
use script_bindings::inheritance::Castable; use script_bindings::inheritance::Castable;
@ -11,6 +14,7 @@ use script_bindings::root::DomRoot;
use style::values::computed::Overflow; use style::values::computed::Overflow;
use webrender_api::units::{LayoutSize, LayoutVector2D}; use webrender_api::units::{LayoutSize, LayoutVector2D};
use crate::dom::bindings::codegen::Bindings::ElementBinding::ScrollLogicalPosition;
use crate::dom::node::{Node, NodeTraits}; use crate::dom::node::{Node, NodeTraits};
use crate::dom::types::{Document, Element}; use crate::dom::types::{Document, Element};
@ -140,4 +144,114 @@ impl ScrollingBox {
ScrollingBoxAxis::Y => self.content_size().height > self.size().height, ScrollingBoxAxis::Y => self.content_size().height > self.size().height,
} }
} }
/// <https://drafts.csswg.org/cssom-view/#determine-the-scroll-into-view-position>
pub(crate) fn determine_scroll_into_view_position(
&self,
block: ScrollLogicalPosition,
inline: ScrollLogicalPosition,
target_rect: Rect<Au>,
) -> LayoutVector2D {
let device_pixel_ratio = self.node().owner_window().device_pixel_ratio().get();
let to_pixel = |value: Au| value.to_nearest_pixel(device_pixel_ratio);
// Step 1 should be handled by the caller, and provided as |target_rect|.
// > Let target bounding border box be the box represented by the return value
// > of invoking Elements getBoundingClientRect(), if target is an Element,
// > or Ranges getBoundingClientRect(), if target is a Range.
let target_top_left = target_rect.origin.map(to_pixel).to_untyped();
let target_bottom_right = target_rect.max().map(to_pixel);
// The rest of the steps diverge from the specification here, but essentially try
// to follow it using our own geometry types.
//
// TODO: This makes the code below wrong for the purposes of writing modes.
let (adjusted_element_top_left, adjusted_element_bottom_right) = match self.target() {
ScrollingBoxSource::Viewport(_) => (target_top_left, target_bottom_right),
ScrollingBoxSource::Element(scrolling_element) => {
let scrolling_padding_rect_top_left = scrolling_element
.upcast::<Node>()
.padding_box()
.unwrap_or_default()
.origin
.map(to_pixel);
(
target_top_left - scrolling_padding_rect_top_left.to_vector(),
target_bottom_right - scrolling_padding_rect_top_left.to_vector(),
)
},
};
let size = self.size();
let current_scroll_position = self.scroll_position();
Vector2D::new(
Self::calculate_scroll_position_one_axis(
inline,
adjusted_element_top_left.x,
adjusted_element_bottom_right.x,
size.width,
current_scroll_position.x,
),
Self::calculate_scroll_position_one_axis(
block,
adjusted_element_top_left.y,
adjusted_element_bottom_right.y,
size.height,
current_scroll_position.y,
),
)
}
/// Step 10 from <https://drafts.csswg.org/cssom-view/#determine-the-scroll-into-view-position>:
fn calculate_scroll_position_one_axis(
alignment: ScrollLogicalPosition,
element_start: f32,
element_end: f32,
container_size: f32,
current_scroll_offset: f32,
) -> f32 {
let element_size = element_end - element_start;
current_scroll_offset +
match alignment {
// Step 1 & 5: If inline is "start", then align element start edge with scrolling box start edge.
ScrollLogicalPosition::Start => element_start,
// Step 2 & 6: If inline is "end", then align element end edge with
// scrolling box end edge.
ScrollLogicalPosition::End => element_end - container_size,
// Step 3 & 7: If inline is "center", then align the center of target bounding
// border box with the center of scrolling box in scrolling boxs inline base direction.
ScrollLogicalPosition::Center => {
element_start + (element_size - container_size) / 2.0
},
// Step 4 & 8: If inline is "nearest",
ScrollLogicalPosition::Nearest => {
let viewport_start = current_scroll_offset;
let viewport_end = current_scroll_offset + container_size;
// Step 4.2 & 8.2: If element start edge is outside scrolling box start edge and element
// size is less than scrolling box size or If element end edge is outside
// scrolling box end edge and element size is greater than scrolling box size:
// Align element start edge with scrolling box start edge.
if (element_start < viewport_start && element_size <= container_size) ||
(element_end > viewport_end && element_size >= container_size)
{
element_start
}
// Step 4.3 & 8.3: If element end edge is outside scrolling box start edge and element
// size is greater than scrolling box size or If element start edge is outside
// scrolling box end edge and element size is less than scrolling box size:
// Align element end edge with scrolling box end edge.
else if (element_end > viewport_end && element_size < container_size) ||
(element_start < viewport_start && element_size > container_size)
{
element_end - container_size
}
// Step 4.1 & 8.1: If element start edge and element end edge are both outside scrolling
// box start edge and scrolling box end edge or an invalid situation: Do nothing.
else {
current_scroll_offset
}
},
}
}
} }

View file

@ -468191,6 +468191,10 @@
"b22ebf8c21f7216507988bb85df4cf4e09ec7698", "b22ebf8c21f7216507988bb85df4cf4e09ec7698",
[] []
], ],
"scrollIntoView-iframes-child.html": [
"04c4b2f913fa8a5d908efb1f838c5195269c9cae",
[]
],
"square-purple.png": [ "square-purple.png": [
"0f522d78728417b0f74b694e2e47cd41c00359d1", "0f522d78728417b0f74b694e2e47cd41c00359d1",
[] []
@ -632494,6 +632498,13 @@
{} {}
] ]
], ],
"scrollIntoView-iframes.html": [
"4e7b28f94e5ddcc22e676d850b110d1b4dbc0f26",
[
null,
{}
]
],
"scrollIntoView-inline-image.html": [ "scrollIntoView-inline-image.html": [
"1bdc75a27a78823e4084d4f97a567813c96ac70f", "1bdc75a27a78823e4084d4f97a567813c96ac70f",
[ [

View file

@ -1,6 +1,3 @@
[scrollIntoView-fixed.html] [scrollIntoView-fixed.html]
[[Box B\] scrollIntoView from unscrollable position:fixed in iframe]
expected: FAIL
[[Box D\] scrollIntoView from scrollable position:fixed in iframe] [[Box D\] scrollIntoView from scrollable position:fixed in iframe]
expected: FAIL expected: FAIL

View file

@ -0,0 +1,82 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>CSSOM View Test: scrollIntoView in iframes</title>
<link rel="author" title="Oriol Brufau" href="mailto:obrufau@igalia.com">
<link rel="author" title="Martin Robinson" href="mailto:mrobinson@igalia.com">
<link rel="help" href="https://drafts.csswg.org/cssom-view/#dom-element-scrollintoview">
<meta name="assert" content="Checks that scrollIntoView inside and iframe can scroll in the parent document if it has the same origin.">
<style>
.scroller {
overflow: hidden;
height: 500px;
border: solid;
}
.scroller::before {
content: "";
display: block;
height: 500px;
}
.scroller::after {
content: "";
display: block;
height: 300px;
}
iframe {
height: 1000px;
border: none;
}
</style>
<div id="log"></div>
<div class="scroller">
<iframe id="same-origin-iframe"></iframe>
</div>
<div class="scroller">
<iframe id="cross-origin-iframe" sandbox="allow-scripts"></iframe>
</div>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script>
let sameOriginIframe = document.getElementById("same-origin-iframe");
let crossOriginIframe = document.getElementById("cross-origin-iframe");
let sameOriginWindow = sameOriginIframe.contentWindow;
let crossOriginWindow = crossOriginIframe.contentWindow;
promise_setup(() => Promise.all([
new Promise(resolve => {
sameOriginIframe.addEventListener("load", resolve);
sameOriginIframe.src = "support/scrollIntoView-iframes-child.html";
}),
new Promise(resolve => {
crossOriginIframe.addEventListener("load", resolve);
crossOriginIframe.src = "support/scrollIntoView-iframes-child.html";
})
]));
promise_test(async () => {
assert_equals(sameOriginWindow.scrollY, 100, "scrollY");
}, "scrollIntoView in same-origin iframe can scroll in inner window");
promise_test(async () => {
assert_equals(sameOriginIframe.parentElement.scrollTop, 1200, "scrollTop");
}, "scrollIntoView in same-origin iframe can scroll in parent window");
promise_test(async () => {
let scrollY = await new Promise(resolve => {
addEventListener("message", event => {
if (event.source === crossOriginWindow) {
resolve(event.data);
}
});
crossOriginWindow.postMessage("scrollY", "*");
});
assert_equals(scrollY, 100, "scrollY");
}, "scrollIntoView in cross-origin iframe can scroll in inner window");
promise_test(async () => {
assert_equals(crossOriginIframe.parentElement.scrollTop, 0, "scrollTop");
}, "scrollIntoView in cross-origin iframe can't scroll in parent window");
</script>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
margin: 0;
padding-top: 1000px;
}
#target {
height: 100px;
width: 100px;
background: green;
}
</style>
<div id="target"></div>
<script>
target.scrollIntoView({block: "center"});
addEventListener("message", event => {
if (event.data === "scrollY") {
event.source.postMessage(scrollY, "*");
}
});
</script>