commit a1f83ff855ca3c4eed32454010974c39281cda8c
parent 7fec13ce81264909dabae5cb61d687db13fe4eb9
Author: Kiet Ho <kiet.ho@apple.com>
Date: Sat, 22 Nov 2025 21:12:23 +0000
Bug 2001499 [wpt PR 56168] - WebKit export of https://bugs.webkit.org/show_bug.cgi?id=302732, a=testonly
Automatic update from web-platform-tests
WebKit export of https://bugs.webkit.org/show_bug.cgi?id=302732
--
wpt-commits: 00000c1ec61217822212986483cc1d6b0a81542d
wpt-pr: 56168
Diffstat:
4 files changed, 223 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/tests/intersection-observer/resources/scroll-margin-propagation-iframe-1.html b/testing/web-platform/tests/intersection-observer/resources/scroll-margin-propagation-iframe-1.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+
+<script src="/common/get-host-info.sub.js"></script>
+
+<p>Iframe 1</p>
+
+<div style="width: 300px; height: 300px; overflow-y: scroll; outline: 1px red solid" id="scroller">
+ <!-- Spacer to trigger scrolling -->
+ <div style="height: 400px"></div>
+
+ <iframe id="iframe" width=250 height=300></iframe>
+</div>
+
+<script>
+ iframe.src = get_host_info().ORIGIN + "/intersection-observer/resources/scroll-margin-propagation-iframe-2.html";
+
+ window.addEventListener("message", event => {
+ const data = event.data;
+
+ if (data.msgName === "setScrollTop") {
+ if (data.target === "iframe1") {
+ scroller.scrollTop = data.scrollTop;
+ window.top.postMessage({ msgName: "scrollEnd", source: "iframe1" }, "*");
+ } else
+ iframe.contentWindow.postMessage(data, "*");
+ }
+ });
+</script>
+\ No newline at end of file
diff --git a/testing/web-platform/tests/intersection-observer/resources/scroll-margin-propagation-iframe-2.html b/testing/web-platform/tests/intersection-observer/resources/scroll-margin-propagation-iframe-2.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+
+<script src="/common/get-host-info.sub.js"></script>
+
+<p>Iframe 2</p>
+
+<div style="width: 200px; height: 200px; overflow-y: scroll; outline: 1px solid purple" id="scroller">
+ <!-- Spacer to trigger scrolling -->
+ <div style="height: 300px"></div>
+
+ <iframe id="iframe" width=150 height=200></iframe>
+</div>
+
+<script>
+ iframe.src = get_host_info().ORIGIN + "/intersection-observer/resources/scroll-margin-propagation-iframe-3.html";
+
+ window.addEventListener("message", event => {
+ const data = event.data;
+
+ if (data.msgName === "setScrollTop" && data.target === "iframe2") {
+ scroller.scrollTop = data.scrollTop;
+ window.top.postMessage({ msgName: "scrollEnd", source: "iframe2" }, "*");
+ }
+});
+</script>
+\ No newline at end of file
diff --git a/testing/web-platform/tests/intersection-observer/resources/scroll-margin-propagation-iframe-3.html b/testing/web-platform/tests/intersection-observer/resources/scroll-margin-propagation-iframe-3.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+
+<p>Iframe 3</p>
+<div style="width: 100px; height: 100px; background: green" id="target">Target</div>
+
+<script>
+const options = {
+ root: null,
+ scrollMargin: "50px"
+};
+
+const observer = new IntersectionObserver(records => {
+ window.top.postMessage({ msgName: "isIntersectingChanged", value: records[0].isIntersecting }, "*");
+}, options);
+
+observer.observe(target);
+</script>
+\ No newline at end of file
diff --git a/testing/web-platform/tests/intersection-observer/scroll-margin-propagation.html b/testing/web-platform/tests/intersection-observer/scroll-margin-propagation.html
@@ -0,0 +1,150 @@
+<!DOCTYPE html>
+
+<meta charset=utf-8>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+
+<title>Scroll margin propagation from descendant frame to top page</title>
+<link rel="author" title="Kiet Ho" href="mailto:kiet.ho@apple.com">
+<meta name="timeout" content="long">
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+<script src="./resources/intersection-observer-test-utils.js"></script>
+
+<!--
+ This tests that when
+ (1) an implicit root intersection observer includes a scroll margin
+ (2) the observer target is in a frame descendant of the top page
+
+ Then the scroll margin is applied up to, and excluding, the first cross-origin-domain
+ frame in the chain from the target to the top page. Then, subsequent frames won't
+ have scroll margin applied, even if any of subsequent frames are same-origin-domain.
+
+ This follows the discussion at [1] that says:
+ > Implementation notes:
+ > * [...]
+ > * Should stop margins at a cross-origin iframe boundary for security
+
+ [1]: https://github.com/w3c/IntersectionObserver/issues/431#issuecomment-1542502858
+
+ The setup:
+ * 3-level iframe nesting: top page -> iframe 1 -> iframe 2 -> iframe 3
+ * Iframe 1 is cross-origin-domain with top page, iframe 2/3 are same-origin-domain
+ * Top page and iframe 1/2 have a scroller, which consists of a spacer to trigger
+ scrolling, and an iframe to the next level.
+ * Iframe 3 has an implicit root intersection observer and the target.
+ * The observer specifies a scroll margin, which should be applied to iframe 2,
+ and not to iframe 1 and top page.
+
+ Communication between frames:
+ * Iframe 3 sends a "isIntersectingChanged" to the top page when the target's
+ isIntersecting changed.
+ * Iframe 1, 2 accepts a "setScrollTop" message to set the scrollTop of its scroller.
+ The message contains a destination, if the destination matches, it sets the scrollTop,
+ otherwise it passes the message down the chain. After setting scrollTop, the iframe emits
+ a "scrollEnd" message to the top frame.
+-->
+
+<p>Top page</p>
+<div style="width: 400px; height: 400px; outline: 1px solid blue; overflow-y: scroll" id="scroller">
+ <!-- Spacer to trigger scrolling -->
+ <div style="height: 500px"></div>
+
+ <iframe width=350 height=400 id="iframe"></iframe>
+</div>
+
+<script>
+iframe.src =
+ get_host_info().HTTP_NOTSAMESITE_ORIGIN + "/intersection-observer/resources/scroll-margin-propagation-iframe-1.html";
+const iframeWindow = iframe.contentWindow;
+
+// Set the scrollTop of the scroller in the frame specified by `target`:
+// "this" - top frame, "iframe1" - iframe 1, "iframe2" - iframe2
+// When setting scrollTop of remote frames, remote frame will send a "scrollEnd"
+// message to indicate the scroll has been set. Wait for this message before returning.
+async function setScrollTop(target, scrollTop) {
+ if (target === "this") {
+ scroller.scrollTop = scrollTop;
+ } else {
+ iframeWindow.postMessage({
+ msgName: "setScrollTop",
+ target: target,
+ scrollTop: scrollTop
+ }, "*");
+
+ await new Promise(resolve => {
+ window.addEventListener("message", event => {
+ if (event.data.msgName === "scrollEnd" && event.data.source === target)
+ resolve();
+
+ }, { once: true })
+ })
+ }
+
+ // Wait for IntersectionObserver notifications to be generated.
+ await new Promise(resolve => waitForNotification(null, resolve));
+ await new Promise(resolve => waitForNotification(null, resolve));
+}
+
+var grandchildFrameIsIntersecting = null;
+
+promise_setup(() => {
+ // Wait for the initial IntersectionObserver notification.
+ // This indicates iframe 3 is fully ready for test.
+ return new Promise(resolve => {
+ window.addEventListener("message", event => {
+ if (event.data.msgName === "isIntersectingChanged") {
+ grandchildFrameIsIntersecting = event.data.value;
+
+ // Install a long-lasting event listener, since this listerner is one-shot
+ window.addEventListener("message", event => {
+ if (event.data.msgName === "isIntersectingChanged")
+ grandchildFrameIsIntersecting = event.data.value;
+ });
+
+ resolve();
+ }
+ }, { once: true });
+ });
+});
+
+promise_test(async t => {
+ // Scroll everything to bottom, so target is fully visible
+ await setScrollTop("this", 99999);
+ await setScrollTop("iframe1", 99999);
+ await setScrollTop("iframe2", 99999);
+ assert_true(grandchildFrameIsIntersecting, "Target is fully visible and intersecting");
+
+ // Scroll iframe 2 up a bit so that target is not visible, but still intersecting
+ // because of scroll margin.
+ await setScrollTop("iframe2", 130);
+ assert_true(grandchildFrameIsIntersecting, "Target is not visible, but in the scroll margin zone, so still intersects");
+
+ await setScrollTop("iframe2", 85);
+ assert_false(grandchildFrameIsIntersecting, "Target is fully outside the visible and scroll margin zone");
+}, "Scroll margin is applied to iframe 2, because it's same-origin-domain with iframe 3");
+
+promise_test(async t => {
+ // Scroll everything to bottom, so target is fully visible
+ await setScrollTop("this", 99999);
+ await setScrollTop("iframe1", 99999);
+ await setScrollTop("iframe2", 99999);
+ assert_true(grandchildFrameIsIntersecting, "Target is fully visible");
+
+ await setScrollTop("iframe1", 180);
+ assert_false(grandchildFrameIsIntersecting, "Target is not visible, in the scroll margin zone, but not intersecting because scroll margin doesn't apply to cross-origin-domain frames");
+}, "Scroll margin is not applied to iframe 1, because it's cross-origin-domain with iframe 3");
+
+promise_test(async t => {
+ // Scroll everything to bottom, so target is fully visible
+ await setScrollTop("this", 99999);
+ await setScrollTop("iframe1", 99999);
+ await setScrollTop("iframe2", 99999);
+ assert_true(grandchildFrameIsIntersecting, "Target is fully visible");
+
+ await setScrollTop("this", 235);
+ assert_false(grandchildFrameIsIntersecting, "Target is not visible, in the scroll margin zone, but not intersecting because scroll margin doesn't apply to frames beyond cross-origin-domain frames");
+
+}, "Scroll margin is not applied to top page, because scroll margin doesn't propagate past cross-origin-domain iframe 1");
+</script>