E53D layout: Do not include `position:fixed` children when calculating scrollable overflow for root element by stevennovaryo · Pull Request #38618 · servo/servo · GitHub
[go: up one dir, main page]

Skip to content

Conversation

stevennovaryo
Copy link
Contributor
@stevennovaryo stevennovaryo commented Aug 12, 2025

Reimplementation of: #35931

For a FragmentTree we define a scrollable overflow calculation that includes the overflow all of it's children Fragments. In practice we are using this calculation for scrolling area of the viewport and defining the root scroll frames. However, since uncontained fixed positioned element is located outside of the document and should not be scrolled, and therefore it would make no sense to include them in the calculation of its scrollable overflow as well.

Testing: New and existing WPT tests
Fixes: #38617
Fixes: #38182

@stevennovaryo stevennovaryo added the T-linux-wpt Do a try run of the WPT label Aug 12, 2025
@github-actions github-actions bot removed the T-linux-wpt Do a try run of the WPT label Aug 12, 2025
Copy link

🔨 Triggering try run (#16902599839) for Linux (WPT)

@servo-wpt-sync
Copy link
Collaborator

🤖 Opened new upstream WPT pull request (web-platform-tests/wpt#54256) with upstreamable changes.

@stevennovaryo stevennovaryo force-pushed the initial-cb-scrollable-overflow branch from e1a6ef2 to 48447fb Compare August 12, 2025 08:11
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

@simonwuelker
Copy link
Contributor

Can you check whether this fixes #38182 too?

Copy link

Test results for linux-wpt from try job (#16902599839):

Flaky unexpected result (25)
  • OK /FileAPI/url/url-with-fetch.any.html (#21517)
    • FAIL [expected PASS] subtest: Revoke blob URL after calling fetch, fetch should succeed

      promise_test: Unhandled rejection with value: object "TypeError: Network error occurred"
      

  • FAIL [expected PASS] /_mozilla/gfx-rs-gecko/descriptor-ranges.html (#23258)
  • OK /css/css-cascade/layer-cssom-order-reverse.html (#36094)
    • FAIL [expected PASS] subtest: Delete layer invalidates @font-face

      assert_equals: expected "220px" but got "133px"
      

  • FAIL [expected PASS] /css/css-fonts/downloadable-font-scoped-to-document.html
  • OK /css/css-fonts/generic-family-keywords-001.html (#37467)
    • PASS [expected FAIL] subtest: @font-face matching for quoted and unquoted generic(nastaliq)
  • OK /css/css-grid/grid-definition/grid-inline-support-named-grid-lines-001.html
    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: [] auto [ ]; and grid-template-rows: [ ] auto [];

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: [a]; and grid-template-rows: [a];

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: [a b]; and grid-template-rows: [a b];

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: [a] none [b]; and grid-template-rows: [a] none [b];

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: [a] [b]; and grid-template-rows: [a] [b];

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: a auto b; and grid-template-rows: a auto b;

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: (a) auto (b); and grid-template-rows: (a) auto (b);

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: 'a' auto 'b'; and grid-template-rows: 'a' auto 'b';

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: "a" auto "b"; and grid-template-rows: "a" auto "b";

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • FAIL [expected PASS] subtest: 'grid' with: grid-template-columns: [a, b] auto [a, b]; and grid-template-rows: [a, b] auto [a, b];

      assert_in_array: gridTemplateColumns value "59px" not in array ["90px"]
      

    • And 12 more unexpected results...
  • FAIL [expected PASS] /css/css-tables/paint/table-border-paint-caption-change.html (#38036)
  • TIMEOUT [expected FAIL] /dom/xslt/large-cdata.html (#38029)
  • OK [expected ERROR] /fetch/fetch-later/quota/same-origin-iframe/multiple-iframes.tentative.https.window.html (#35176)
  • OK /fetch/metadata/generated/css-font-face.https.sub.tentative.html (#32732)
    • PASS [expected FAIL] subtest: sec-fetch-user
  • OK /html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/load-pageshow-events-iframe-contentWindow.html (#28681)
    • FAIL [expected PASS] subtest: load & pageshow events do not fire on contentWindow of <iframe> element created with src='about:blank'

      assert_unreached: load should not be fired Reached unreachable code
      

  • OK /html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/load-pageshow-events-window-open.html (#28691)
    • PASS [expected FAIL] subtest: load event does not fire on window.open('about:blank')
  • OK /html/browsers/browsing-the-web/navigating-across-documents/javascript-url-return-value-handling-dynamic.html (#28066)
    • FAIL [expected PASS] subtest: 0041 set in href="" targeting a frame and clicked

      assert_equals: expected "A" but got ""
      

    • FAIL [expected PASS] subtest: 0080 00FF set in href="" targeting a frame and clicked

      assert_equals: expected "�ÿ" but got ""
      

    • FAIL [expected PASS] subtest: 0080 00FF 0100 set in href="" targeting a frame and clicked

      assert_equals: expected "�ÿĀ" but got ""
      

    • FAIL [expected PASS] subtest: D83D DE0D set in href="" targeting a frame and clicked

      assert_equals: expected "😍" but got ""
      

    • FAIL [expected PASS] subtest: DE0D 0041 set in href="" targeting a frame and clicked

      assert_equals: expected "\ufffdA" but got ""
      

  • OK /html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-same-origin.window.html (#29049)
    • PASS [expected FAIL] subtest: Same-origin navigation started from unload handler must be ignored
  • TIMEOUT [expected OK] /html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/form-requestsubmit.html (#28716)
    • TIMEOUT [expected FAIL] subtest: Replace before load, triggered by formElement.requestSubmit()

      Test timed out
      

  • PASS [expected FAIL] /html/canvas/element/manual/drawing-text-to-the-canvas/canvas.2d.disconnected-font-size-math.html (#30063)
  • TIMEOUT [expected OK] /html/interaction/focus/the-autofocus-attribute/update-the-rendering.html (#24145)
    • TIMEOUT [expected FAIL] subtest: "Flush autofocus candidates" should be happen before a scroll event and animation frame callbacks

      Test timed out
      

  • OK /html/semantics/embedded-content/the-iframe-element/iframe-loading-lazy-nav-form-submit.html (#32607)
    • FAIL [expected PASS] subtest: Navigating iframe loading='lazy' before it is loaded: form submit

      uncaught exception: Error: assert_equals: expected "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?" but got "http://web-platform.test:8000/html/semantics/embedded-content/the-iframe-element/support/blank.htm?src"
      

  • OK /html/semantics/forms/historical.html (#28568)
    • FAIL [expected PASS] subtest: <input name=isindex> should not be supported

      assert_regexp_match: expected object "/\?isindex=x$/" but got "about:blank"
      

  • TIMEOUT /preload/preload-resource-match.https.html (#38088)
    • TIMEOUT [expected FAIL] subtest: Loading script (use-credentials) with link (no-cors) should discard the preloaded response

      Test timed out
      

    • NOTRUN [expected TIMEOUT] subtest: Loading script (use-credentials) with link (anonymous) should discard the preloaded response
  • TIMEOUT [expected OK] /resource-timing/nested-context-navigations-iframe.html (#24311)
    • TIMEOUT [expected PASS] subtest: Test that cross-site iframe navigations are not observable by the parent, even after history navigations by the parent

      Test timed out
      

    • NOTRUN [expected PASS] subtest: Test that iframe navigations are not observable by the parent
    • NOTRUN [expected PASS] subtest: Test that crossorigin iframe navigations are not observable by the parent
    • NOTRUN [expected PASS] subtest: Test that cross-site iframe navigations are not observable by the parent
    • NOTRUN [expected PASS] subtest: Test that iframe refreshes are not observable by the parent
    • NOTRUN [expected PASS] subtest: Test that crossorigin iframe refreshes are not observable by the parent
    • NOTRUN [expected PASS] subtest: Test that cross-site iframe refreshes are not observable by the parent
  • TIMEOUT [expected OK] /resource-timing/tentative/document-initiated.html (#37785)
  • TIMEOUT [expected OK] /webstorage/localstorage-about-blank-3P-iframe-opens-3P-window.partitioned.html (#29053)
    • TIMEOUT [expected PASS] subtest: StorageKey: test 3P about:blank window opened from a 3P iframe

      Test timed out
      

  • OK [expected ERROR] /webxr/render_state_update.https.html (#27535)
  • OK /xhr/open-url-multi-window-5.htm (#23360)
    • FAIL [expected PASS] subtest: XMLHttpRequest: open() resolving URLs (multi-Window; 5)

      assert_throws_dom: function "function() {client.open("GET", "...") }" did not throw
      

Stable unexpected results that are known to be intermittent (22)
  • FAIL [expected PASS] /_mozilla/css/stacked_layers.html (#15988)
  • FAIL [expected PASS] /_mozilla/mozilla/sslfail.html (#10760)
  • TIMEOUT [expected OK] /_mozilla/mozilla/window_resize_event.html (#36741)
    • TIMEOUT [expected PASS] subtest: Popup onresize event fires after resizeTo

      Test timed out
      

  • OK /fetch/metadata/generated/css-font-face.sub.tentative.html (#34624)
    • PASS [expected FAIL] subtest: sec-fetch-storage-access - Not sent to non-trustworthy same-origin destination
  • OK /html/browsers/browsing-the-web/navigating-across-documents/008.html (#24456)
    • PASS [expected FAIL] subtest: Link with onclick form submit to javascript url and href navigation
  • OK /html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/iframe-src-aboutblank-navigate-immediately.html (#29048)
    • PASS [expected FAIL] subtest: Navigating to a different document with location.href
    • PASS [expected FAIL] subtest: Navigating to a different document with location.assign
  • OK /html/browsers/browsing-the-web/navigating-across-documents/navigation-unload-cross-origin.sub.window.html (#29056)
    • PASS [expected FAIL] subtest: Cross-origin navigation started from unload handler must be ignored
  • OK /html/browsers/browsing-the-web/navigating-across-documents/replace-before-load/a-click.html (#28697)
    • PASS [expected FAIL] subtest: aElement.click() before the load event must NOT replace
  • OK /html/browsers/history/the-history-interface/traverse_the_history_5.html (#21383)
    • PASS [expected FAIL] subtest: Multiple history traversals, last would be aborted
  • TIMEOUT [expected OK] /html/interaction/focus/the-autofocus-attribute/document-with-fragment-empty.html (#28259)
    • TIMEOUT [expected FAIL] subtest: Autofocus elements in top-level browsing context's documents with empty fragments should work.

      Test timed out
      

  • TIMEOUT /html/interaction/focus/the-autofocus-attribute/supported-elements.html (#24145)
    • TIMEOUT [expected PASS] subtest: Non-HTMLElement should not support autofocus

      Test timed out
      

    • NOTRUN [expected FAIL] subtest: Host element with delegatesFocus should support autofocus
    • NOTRUN [expected FAIL] subtest: Host element with delegatesFocus including no focusable descendants should be skipped
  • ERROR [expected OK] /html/semantics/embedded-content/media-elements/event_timeupdate_noautoplay.html (#25046)
  • TIMEOUT [expected CRASH] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_escaping-1.html (#22647)
  • CRASH [expected OK] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_escaping-2.html (#22667)
  • CRASH [expected OK] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_escaping-3.html (#24057)
  • TIMEOUT [expected CRASH] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_nonescaping-1.html (#24066)
    • NOTRUN [expected FAIL] subtest: Check that popups from a sandboxed iframe do not escape the sandbox
  • OK [expected TIMEOUT] /html/semantics/embedded-content/the-iframe-element/iframe_sandbox_popups_nonescaping-3.html (#24066)
    • FAIL [expected NOTRUN] subtest: Check that popups from a sandboxed iframe do not escape the sandbox

      assert_equals: It came from a sandboxed iframe expected "null" but got "http://web-platform.test:8000"
      

  • OK [expected TIMEOUT] /html/semantics/forms/form-submission-0/reparent-form-during-planned-navigation-task.html (#29724)
    • PASS [expected TIMEOUT] subtest: reparent-form-during-planned-navigation-task
  • OK /preload/preload-error.sub.html (#37177)
    • PASS [expected FAIL] subtest: success (style): main
    • PASS [expected FAIL] subtest: CORS (style): main
    • PASS [expected FAIL] subtest: success (xhr): main
    • PASS [expected FAIL] subtest: 404 (xhr): main
    • PASS [expected FAIL] subtest: CORS (xhr): main
    • PASS [expected FAIL] subtest: Decode-error (style): main
    • PASS [expected FAIL] subtest: Decode-error (script): main
  • ERROR /service-workers/idlharness.https.any.html (#36250)
    • TIMEOUT [expected PASS] subtest: ServiceWorkerContainer interface: operation register((TrustedScriptURL or USVString), optional RegistrationOptions)

      Test timed out
      

    • TIMEOUT [expected PASS] subtest: NavigationPreloadManager interface: operation enable()

      Test timed out
      

    • TIMEOUT [expected PASS] subtest: NavigationPreloadManager interface: operation disable()

      Test timed out
      

    • TIMEOUT [expected PASS] subtest: NavigationPreloadManager interface: operation setHeaderValue(ByteString)

      Test timed out
      

    • TIMEOUT [expected PASS] subtest: NavigationPreloadManager interface: operation getState()

      Test timed out
      

  • OK [expected TIMEOUT] /webmessaging/with-ports/017.html (#24486)
    • PASS [expected TIMEOUT] subtest: origin of the script that invoked the method, about:blank
  • OK [expected TIMEOUT] /webmessaging/without-ports/017.html (#24486)
    • PASS [expected TIMEOUT] subtest: origin of the script that invoked the method, about:blank
Stable unexpected results (1)
  • PASS [expected FAIL] /css/css-masking/clip-path/clip-path-fixed-scroll.html

Copy link

⚠️ Try run (#16902599839) failed.

Comment on lines 121 to 141
// We need to calculate the overflow for each fragments within the tree
// to be processed in the further stages of reflow.
let calculated_overflow = fragment.calculate_scrollable_overflow_for_parent();

// Uncontained fixed positioned element while placed as a child of the
// fragment tree, is not contained by the initial containing block,
// cannot be scrolled, and should not included in the calculation.
// <https://drafts.csswg.org/css-position/#fixed-cb>
let should_include_overflow =
fragment
.retrieve_box_fragment()
.is_some_and(|box_fragment| {
box_fragment.borrow().style.get_box().position != Position::Fixed
});

match should_include_overflow {
true => overflow.union(&calculated_overflow),
false => overflow,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make more sense to move this to BoxFragment::scrollable_overflow_for_parent. You can add an early return of Zero::zero() if the box uses fixed positioning.

What does "uncontained" mean in this case?

Copy link
Contributor Author
@stevennovaryo stevennovaryo Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make more sense to move this to BoxFragment::scrollable_overflow_for_parent. You can add an early return of Zero::zero() if the box uses fixed positioning.

AFAIU, the fragment tree is arranged so that a fragment's parent is its containing block, so we doesn't to care about position there. But for fragment tree it would be different, since it contains all of the fragments, and this would the corner cases of the scrollable overflow.

What does "uncontained" mean in this case?

Just to define fixed element without ancestor that would "contain it". Adding more context..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollable_overflow_for_parent is only called one place, this place, so it is safe to move it there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollable_overflow_for_parent is only called one place, this place, so it is safe to move it there.

Since it is used to calculate overflow of all Fragments, we will need to checks whether the fixed positioned fragment is contained by its parent or not. And, I am afraid that it would introduce unnecessary checks. We could introduce scrollable_overflow_for_fragment_tree (or something similar) though. 🤔

Please correct me if I am missing something though.

@stevennovaryo
Copy link
Contributor Author
stevennovaryo commented Aug 12, 2025

Can you check whether this fixes #38182 too?

Missed that issue, seems to be working fine after this fix. 👍

@servo-wpt-sync
Copy link
Collaborator

✍ Updated existing upstream WPT pull request (web-platform-tests/wpt#54256) title and body.

@stevennovaryo stevennovaryo force-pushed the initial-cb-scrollable-overflow branch from 48447fb to b56f3aa Compare August 12, 2025 08:43
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

Comment on lines 22 to 28
promise_test(async (t) => {
await new Promise(resolve => target.addEventListener('load', resolve));

assert_less_than(target.contentDocument.documentElement.scrollHeight, 1000);
assert_less_than(target.contentDocument.documentElement.scrollWidth, 1000);

}, "Uncontained fixed positioned shouldn't affect scrollable overflow of a document element.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use an iframe here? Couldn't you just check the scrollHeight and scrollWidth of the current document? I'm also a bit confused because in this case, the fixed position child does not have a parent in the fragment tree...so should be taken into account in FragmentTree::calculate_scrollable_overflow. You can see this by running servo with -Z dump-flow-tree.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use an iframe here? Couldn't you just check the scrollHeight and scrollWidth of the current document?

Little bit afraid of about messing with the Document itself, where the ICB size, window size matters. Iframe it not perfect, by the means that it add another dependency, but it acts like a sandboxes here. Do you have a recommendation for adding a test with the document?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could avoid the <iframe> and make the test a bit stronger by:

  1. Measuring the scrollHeight and scrollWidth of the main document.
  2. Adding the big position: fixed element to the page contents. You can make it programmatically twice as large as the dimensions you measured before.
  3. Measure the scrollHeight and scrollWidth of the page after and assert that they are the same.

<iframe> loading and such add a lot of complications to the test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, please check!

Comment on lines 125 to 128
// Fixed positioned element that is not contained by any box is placed
// as a child of the fragment tree. But, it is not contained by the
// initial containing block, cannot be scrolled, and should not included
// in the calculation.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"not contained by any box" is still a very weird thing in terms of CSS terminology. All boxes have a containing block and we don't commonly say that an element that establishes another element's containing block "contains that element." The problem is that it's very ambiguous. Better to say that outright that an "element establishes the containing block for a fixed position descendant."

Wording aside, I think this change isn't quite right. If the child is fixed position, then the parent here is the fixed positioned element's containing block, ie it's an element that establishes a containing block for all descendants. For instance, an element with a transform establishes a containing block for fixed ancestors and indeed affects its scroll overflow. If it doesn't have a parent (is a child of the root), then it's containing block is the initial containing block.

In this test case:

<!DOCTYPE html>

<div id="foo" style="overflow: scroll; transform: translateX(100px); width: 200px; height: 200px; outline: solid; overflow: auto;">
    <div style="position: fixed; height: 300px; width: 100px; background: pink;">hello</div>
</div>

The overflow: scroll parent establishes a containing block for the fixed position child. This doesn't happen if you remove the transform on the parent.

Indeed the specification that really matters here says nothing about fixed position boxes, but it does say this:

The border boxes of all boxes for which it is the containing block and whose border boxes are positioned not wholly in the negative scrollable overflow region, accounting for transforms by projecting each box onto the plane of the element that establishes its 3D rendering context. [CSS3-TRANSFORMS]

I think this means that fixed positioned boxes that are children of the root (no parent) should not be taken into account for scrollable overflow.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"not contained by any box" is still a very weird thing in terms of CSS terminology. All boxes have a containing block and we don't commonly say that an element that establishes another element's containing block "contains that element." The problem is that it's very ambiguous. Better to say that outright that an "element establishes the containing block for a fixed position descendant."

We also have specific cases when there are no ancestor (element) established one such containing block, but, I agree that "box" could be changed to "ancestor". Maybe like "Fixed positioned element without any ancestor that establishes fixed containing block...".

Nevertheless, the important things here is about the understanding of the spec.

I think this means that fixed positioned boxes that are children of the root (no parent) should not be taken into account for scrollable overflow.

This is not correct, the spec says (albeit in quite convoluted way) that "For a box A, any boxes that the box A is the containing block, we should include their border boxes if the border boxes are positioned not wholly....".

This is shown by this case:

<style>
    .target-1 {
        width: 100%;
        height: 200px;
        background-color: green;
        margin-top: 200px;
        position: fixed;
    }
    .target-2 {
        width: 100%;
        height: 200px;
        background-color: red;
        position: fixed;
    }
    .container {
        width: 100px;
        height: 100px;
        will-change: transform;
        overflow: scroll;
    }
</style>

<div class="container">
    <div class="target-1"></div>
    <div class="target-2"></div>
</div>

Where we could scroll the container that established a fixed containing block.

I'm also a bit confused because in this case, the fixed position child does not have a parent in the fragment tree...so should be taken into account in FragmentTree::calculate_scrollable_overflow. You can see this by running servo with -Z dump-flow-tree

Looking at the implementation, for Servo, the "parent" of fixed positioned child is the FragmentTree itself. We calculated the scrollable_overflow of a FragmentTree as the union of all it's children. This is correct for a Fragment but for a FragmentTree we also have the fixed positioned child without any ancestor that establishes a fixed containing block.

And, we used these for the root scroll node's content_rect, viewport ScrollHeight, and viewport ScrollWidth. For root scroll node, it does not make sense to include them since the "containing block" for them is the reference frame. See

let root_scroll_node_id = compositor_info.root_scroll_node_id;
let cb_for_non_fixed_descendants = ContainingBlock::new(
fragment_tree.initial_containing_block,
root_scroll_node_id,
Some(viewport_size),
ClipId::INVALID,
);
let cb_for_fixed_descendants = ContainingBlock::new(
fragment_tree.initial_containing_block,
compositor_info.root_reference_frame_id,
None,
ClipId::INVALID,
);

Thus, they are not scrollable by it in the first hand.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also have specific cases when there are no ancestor (element) established one such containing block, but, I agree that "box" could be changed to "ancestor". Maybe like "Fixed positioned element without any ancestor that establishes fixed containing block...".

Nope, this is still wrong. A fixed position element can have the initial containing block as its containing block, but still have an ancestor in the flat tree. At the very least, unless the fixed position element is the root element, then it always has an ancestor. I think the thing here is that we are getting confused talking about the ancestor of the element and the ancestor of the fragment in the fragment tree. The fixed positioned element has an ancestor, but the fixed positioned fragment is much more likely to not have an ancestor in the fragment tree.

I think this means that fixed positioned boxes that are children of the root (no parent) should not be taken into account for scrollable overflow.

This is not correct, the spec says (albeit in quite convoluted way) that "For a box A, any boxes that the box A is the containing block, we should include their border boxes if the border boxes are positioned not wholly....".

This is shown by this case:

<style>
    .target-1 {
        width: 100%;
        height: 200px;
        background-color: green;
        margin-top: 200px;
        position: fixed;
    }
    .target-2 {
        width: 100%;
        height: 200px;
        background-color: red;
        position: fixed;
    }
    .container {
        width: 100px;
        height: 100px;
        will-change: transform;
        overflow: scroll;
    }
</style>

<div class="container">
    <div class="target-1"></div>
    <div class="target-2"></div>
</div>

Where we could scroll the container that established a fixed containing block.

This isn't the case that I was trying to refer to though. This is the case where a fixed position child has a containing block which isn't the initial containing block. What I'm suggesting is that when the containing block of a fixed positioned element is the initial containing block, perhaps it should not be considered for scroll overflow calculation. The specification says that "For a box A, any boxes that the box A is the containing block, we should include their border boxes if the border boxes are positioned not wholly....". When the containing block is the initial containing block, I think that means that there is no box A that is the containing block for the fixed positioned element.

I'm also a bit confused because in this case, the fixed position child does not have a parent in the fragment tree...so should be taken into account in FragmentTree::calculate_scrollable_overflow. You can see this by running servo with -Z dump-flow-tree

Looking at the implementation, for Servo, the "parent" of fixed positioned child is the FragmentTree itself. We calculated the scrollable_overflow of a FragmentTree as the union of all it's children. This is correct for a Fragment but for a FragmentTree we also have the fixed positioned child without any ancestor that establishes a fixed containing block.

In this case, the fixed positioned Fragment is right under the root of the FragmentTree, but your change doesn't address that case, so I'm confused how it affects the outcome.

Copy link
Contributor Author
@stevennovaryo stevennovaryo Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, ah sorry, I get the point now.

In this case, the fixed positioned Fragment is right under the root of the Fra 3933 gmentTree, but your change doesn't address that case, so I'm confused how it affects the outcome.

Not sure are we referring to the same cases or not, but for cases like data:text/html, <div style="position: fixed;"></div>, the fragment(s) is placed inside the FragmentTree.root_fragments alongside root fragment which is the first in the list.

AFAIU, fixed positioned fragment will reside under root fragment when root fragment establishes containing block for fixed positioned fragment, which seems conformant to the spec. They are scrollable, and could extend the scrollable area.

https://drafts.csswg.org/css-position/#fixed-cb

If no ancestor establishes one, the fixed positioning containing block is:

Print Flow Tree Result
┌ Fragment Tree
│  ├─ Box
│  │         base=BaseFragment { tag: Some(Tag { node: OpaqueNode(43961552470312), pseudo: None }), flags: FragmentFlags(IS_ROOT_ELEMENT) }
│  │         content=Rect(1024pxx8px at (0px, 0px))
│  │         padding rect=Rect(1024pxx8px at (0px, 0px))
│  │         border rect=Rect(1024pxx8px at (0px, 0px))
│  │         margin=(0px,0px,0px,0px)
│  │         scrollable_overflow=Rect(1024pxx8px at (0px, 0px))
│  │         baselines=Baselines { first: None, last: None }
│  │         overflow=AxesOverflow { x: Visible, y: Visible }
│  │  ├─ Box
│  │  │         base=BaseFragment { tag: Some(Tag { node: OpaqueNode(43961552470392), pseudo: None }), flags: FragmentFlags(IS_BODY_ELEMENT_OF_HTML_ELEMENT_ROOT) }
│  │  │         content=Rect(1008pxx0px at (8px, 8px))
│  │  │         padding rect=Rect(1008pxx0px at (8px, 8px))
│  │  │         border rect=Rect(1008pxx0px at (8px, 8px))
│  │  │         margin=(8px,8px,8px,8px)
│  │  │         scrollable_overflow=Rect(1008pxx0px at (8px, 8px))
│  │  │         baselines=Baselines { first: None, last: None }
│  │  │         overflow=AxesOverflow { x: Visible, y: Visible }
│  │  │  └─ AbsoluteOrFixedPositioned
│  ├─ Box
│  │         base=BaseFragment { tag: Some(Tag { node: OpaqueNode(43961552470432), pseudo: None }), flags: FragmentFlags(0x0) }
│  │         content=Rect(0pxx0px at (8px, 8px))
│  │         padding rect=Rect(0pxx0px at (8px, 8px))
│  │         border rect=Rect(0pxx0px at (8px, 8px))
│  │         margin=(0px,0px,0px,0px)
│  │         scrollable_overflow=Rect(0pxx0px at (8px, 8px))
│  │         baselines=Baselines { first: None, last: None }
│  │         overflow=AxesOverflow { x: Visible, y: Visible }

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure are we referring to the same cases or not, but for cases like data:text/html, <div style="position: fixed;"></div>, the fragment(s) is placed inside the FragmentTree.root_fragments alongside root fragment which is the first in the list.

Perhaps the fix is to ignore just the fixed position fragments that are directly in the FragmentTree::root_fragments vector as these are the fragments that have the initial containing block as their containing blocks?

Copy link
Contributor Author
@stevennovaryo stevennovaryo Aug 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the fix is to ignore just the fixed position fragments that are directly in the FragmentTree::root_fragments vector as these are the fragments that have the initial containing block as their containing blocks?

Yes, I believe that is what is PR planning to do. What do you think about the current changes?

Signed-off-by: Jo Steven Novaryo <jo.steven.novaryo@huawei.com>
@stevennovaryo stevennovaryo force-pushed the initial-cb-scrollable-overflow branch from b56f3aa to b394565 Compare August 14, 2025 07:17
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

Copy link
Member
@mrobinson mrobinson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just some minor comments here:

Comment on lines 22 to 28
promise_test(async (t) => {
await new Promise(resolve => target.addEventListener('load', resolve));

assert_less_than(target.contentDocument.documentElement.scrollHeight, 1000);
assert_less_than(target.contentDocument.documentElement.scrollWidth, 1000);

}, "Uncontained fixed positioned shouldn't affect scrollable overflow of a document element.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could avoid the <iframe> and make the test a bit stronger by:

  1. Measuring the scrollHeight and scrollWidth of the main document.
  2. Adding the big position: fixed element to the page contents. You can make it programmatically twice as large as the dimensions you measured before.
  3. Measure the scrollHeight and scrollWidth of the page after and assert that they are the same.

<iframe> loading and such add a lot of complications to the test.

Comment on lines 131 to 141
let should_include_overflow =
fragment
.retrieve_box_fragment()
.is_some_and(|box_fragment| {
box_fragment.borrow().style.get_box().position != Position::Fixed
});

match should_include_overflow {
true => overflow.union(&calculated_overflow),
false => overflow,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think an early return is a little bit nicer here rather than a match on a boolean which always feels a bit odd:

                    if fragment
                            .retrieve_box_fragment()
                            .is_some_and(|box_fragment| {
                                box_fragment.borrow().style.get_box().position != Position::Fixed
                            })
                      {
                            return overflow;
                       }
                      fragment
                          .calculate_scrollable_overflow_for_parent()
                          .union(&overflow),

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks! It does looks weird to have boolean matches.

Comment on lines 121 to 123
// We need to calculate the overflow for each fragments within the tree
// to be processed in the further stages of reflow.
let calculated_overflow = fragment.calculate_scrollable_overflow_for_parent();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't do this is the fragment is position fixed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calculation is still required by stacking context tree when we are defining the scroll frame. I have modified it to be more appropriate. Please let me know if you have more concern.

Comment on lines 125 to 130
// Fixed positioned fragment without any ancestor that establishes
// its containing block is placed as a child of the fragment tree.
// But, it is not contained by the initial containing block, cannot
// be scrolled, and should not included in the calculation. These
// fragments should be ignored.
// <https://drafts.csswg.org/css-position/#fixed-cb>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Fixed positioned fragment without any ancestor that establishes
// its containing block is placed as a child of the fragment tree.
// But, it is not contained by the initial containing block, cannot
// be scrolled, and should not included in the calculation. These
// fragments should be ignored.
// <https://drafts.csswg.org/css-position/#fixed-cb>
// Scrollable overflow should be accumulated in the block that
// establishes the containing block for the element. Thus, fixed
// positioned fragments whose containing block is the initial
// containing block should not be included in overflow calculation.
// See <https://www.w3.org/TR/css-overflow-3/#scrollable>.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, thanks.

@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

@stevennovaryo stevennovaryo force-pushed the initial-cb-scrollable-overflow branch from 329a3bf to 185776c Compare August 18, 2025 05:59
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

@mrobinson mrobinson force-pushed the initial-cb-scrollable-overflow branch 2 times, most recently from aac042c to bee8813 Compare August 18, 2025 08:33
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

@mrobinson mrobinson force-pushed the initial-cb-scrollable-overflow branch from bee8813 to 6047724 Compare August 18, 2025 08:34
@mrobinson
Copy link
Member

Thanks!

@mrobinson mrobinson enabled auto-merge August 18, 2025 08:35
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

@mrobinson mrobinson added this pull request to the merge queue Aug 18, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Aug 18, 2025
Signed-off-by: Jo Steven Novaryo <jo.steven.novaryo@huawei.com>
@mrobinson mrobinson force-pushed the initial-cb-scrollable-overflow branch from 6047724 to f4cfcaa Compare August 18, 2025 10:50
@servo-wpt-sync
Copy link
Collaborator

📝 Transplanted new upstreamable changes to existing upstream WPT pull request (web-platform-tests/wpt#54256).

@mrobinson mrobinson enabled auto-merge August 18, 2025 10:56
@mrobinson mrobinson changed the title layout: Fix scrolling area calculation for uncontained fixed element layout: Do not include position:fixed children when calculating scrollable overflow for root element Aug 18, 2025
@servo-wpt-sync
Copy link
Collaborator

✍ Updated existing upstream WPT pull request (web-platform-tests/wpt#54256) title and body.

@mrobinson mrobinson added this pull request to the merge queue Aug 18, 2025
Merged via the queue into servo:main with commit 7489a03 Aug 18, 2025
23 checks passed
PotatoCP pushed a commit to PotatoCP/servo that referenced this pull request Aug 20, 2025
…ollable overflow for root element (servo#38618)

Reimplementation of: servo#35931

For a `FragmentTree` we define a scrollable overflow calculation that
includes the overflow all of it's children `Fragments`. In practice we
are using this calculation for scrolling area of the viewport and
defining the root scroll frames. However, since uncontained fixed
positioned element is located outside of the document and should not be
scrolled, and therefore it would make no sense to include them in the
calculation of its scrollable overflow as well.

Testing: New and existing WPT tests
Fixes: servo#38617
Fixes: servo#38182

---------

Signed-off-by: Jo Steven Novaryo <jo.steven.novaryo@huawei.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Weird behavior of scrolling a html with uncontained fixed positioned element Servo allows horizontal scrolling where it shouldn't
4 participants
0