csswg-drafts icon indicating copy to clipboard operation
csswg-drafts copied to clipboard

[css-anchor-position] Better handling of scroll position for fixpos elements on first layout

Open tabatkins opened this issue 1 year ago • 6 comments

Anchor Positioning has to make some compromises for scrolling-related behavior, because scrolling is done on the compositor (which has limited abilities, basically just shifting pixels around). Basically, you can shift an anchorpos element according to the current scroll position (as that can be done on the compositor), or you can select a fallback according to the current scroll position (this is done back on the main thread and might lag the scroll, but a fallback is generally a large enough visual change that you don't notice the lag).

This, tho, means you can't size an element according to the current scroll position, ever. The spec currently makes all of its scroll-dependent layout decisions based on the initial scroll position only (except for choosing a fallback, which does respond to the current scroll position). This means you can't, for example, open up a customized <select>, and have the height of the dropdown respond to the current area between the anchor and the bottom of the page. If the element is below-the-fold at the initial scroll position, for example, then position-area: bottom is zero-height, even if the current scroll has the element in the middle of the screen with plenty of space below it!

This is a pretty limiting (and somewhat confusing to authors) restriction, even if it's well-intentioned and necessary in general. However, I think we can improve the behavior in a way that will solve these sorts of use-cases naturally, and reasonably understandably for authors.

Proposal

On first layout of an element whose abspos containing block is a scroller (that is, fixpos elements, or abspos elements with a scrolling CB), we calculate the position-area IMCB based on the current position of the anchor and the scrollport (rather than the initial scroll position), and stick with that going forward. This IMCB is also recalculated whenever we change to a different position-fallbacks entry. (Or when it loses its box and regains it, getting a new "first layout". Aka, very similar, possibly the same, as when an element records or forgets its "last remembered size".)

The size of the IMCB is adjusted by scrolling for the purpose of determining when to do fallback, as normal. This just changes what our base value is, by calculating a value at the moment the positioned element is first rendered.

Example

Check out this example, made by @mfreed7. The demo immediately scrolls slightly down and right on load. If you click the button, it renders a popup that's a little too small; in fact its width and height are precisely the correct size for the "bottom span-right" area at initial scroll position (which you can see if you open it, then scroll back up and left). That information is just useless right now, because we're not at the initial scroll position, so it looks awkward and bad. The further down the button is initially, the worse the behavior is.

My proposal would change this behavior, so that when the popup is opened, it observes the actual space between its anchor and the viewport, and gets a reasonable "bottom span-right" area from that. If you then scroll, it will remain the same size, not responding to the new scroll offset (but if you scroll up or left, reducing the available space, it might trigger fallback). If you later close and reopen the popup, it'll freshly observe the actual space again, and size appropriately for that moment, which might be different than what it was the previous time.

tabatkins avatar Oct 03 '24 22:10 tabatkins

Screenshots to illustrate the problem, based on the example in my previous comment.

The popup is using position-area: bottom span-right; to position itself below the button that invokes it. It's a fixpos element, so it uses the viewport as its containing block; thus, the position area it selects should stretch from the bottom of the button to the bottom of the screen, and from the left edge of the button to the right edge of the screen.


First, this screenshot shows what happens if the popup is opened when the scroller is at its initial scroll position. This is expected behavior, it's doing exactly what the author likely intends, based on the above description of its styling.

Screenshot from 2024-10-15 15-13-15


Now, scroll that scroller a bit down and to the right, so the button is higher and lefter in the scrollport, then click it to open the popup. What the author almost certainly expects to see is:

Screenshot from 2024-10-15 15-26-34

That is, it still stretches between the anchor and the viewport edge, exactly as it did in the first case, just with more space to work with since the anchor has been scrolled away from those edges a bit.


But that's not what happens! Instead, they get this:

Screenshot from 2024-10-15 15-18-25

This almost certainly isn't expected! The popup's position-area doesn't look correct; it no longer stretches to the bottom or the right of the screen! This is because, per spec, we calculate this position-area based on the initial scroll position. That means it's actually the exact size it was in the first example, but that size no longer corresponds to the desired position-area edges.


Arguably the author might expect it to stay sized exactly to those two edges as it scrolls, but as explained in the previous comment, that's intentionally not possible. But it's absolutely possible to trigger this expected sizing once, as the popup starts displaying, and also when we swap the position-try-fallback (since the fallback calculation does correctly take scrolling into account).

tabatkins avatar Oct 15 '24 22:10 tabatkins

The CSS Working Group just discussed [css-anchor-position] Better handling of scroll position for fixpos elements on first layout, and agreed to the following:

  • RESOLVED: accept what I said in the thread with caveat from here. when an anchor position element is first rendered or change fallback position, use the current scroll offset to calculate its position area
The full IRC log of that discussion <TabAtkins> https://github.com/w3c/csswg-drafts/issues/10999#issuecomment-2415284548
<matthieud> TabAtkins: anytime you do something which respond to scroll state, it is generally do on compositor thread
<matthieud> TabAtkins: so no layout or ok do be a frame delayed
<matthieud> TabAtkins: anchor-positioning is careful about being mostly doable on compositor except some spec defined part
<matthieud> TabAtkins: this is about one specific section : customisable select is not responding to scrolling as much as it could
<matthieud> TabAtkins: cf link comment
<matthieud> if you scroll the viewport, and reopen the popup, you don't get what is expected (the second image)
<matthieud> TabAtkins: it doesnt look very right because currently spec mandates that the calcul is done on the initial position
<matthieud> TabAtkins: we generally need this restriction
<matthieud> TabAtkins: some cases we should remove it though : when ??? or when doing fallback
<emilio> q+
<matthieud> PROPOSAL: change the rule for calculating position-area : when an element is first rendered or switch from fallback, calculate its position form the current scroll offset, not the initial scroll position
<flackr> q+
<matthieud> TabAtkins: we need this to make customisable select correct otherwise it's very weird (specially when they start off screen)
<TabAtkins> s/???/first creating boxes/
<matthieud> emilio: same similarity with last remember ??? stuff
<matthieud> emilio: not objecting, we need more details
<TabAtkins> s/???/size/
<Rossen16> ack emilio
<Rossen16> ack flackr
<matthieud> flackr: one case im worried is if you scroll up, recomputing the size based on current scroll, it would then fit and you dont want it to try to resize using the original position area
<emilio> s/we need more details/we need the details very well defined/
<kizu> q+
<matthieud> TabAtkins: when you trigger fallback it might move to a new fallback position
<matthieud> flackr: reusing the first position would be bad
<fserb> (got to go)
<matthieud> TabAtkins: it could be weird, we need to treat this size as tainted
<matthieud> flackr: we need to do something to force it to a different fallback
<matthieud> flackr: like not recomputing the available area
<matthieud> TabAtkins: stuff about remembering fallback ???
<matthieud> kizu: +1
<Rossen16> ack kizu
<matthieud> kizu: I got this issue
<matthieud> TabAtkins: how solved it statically or a frame delayed ?
<matthieud> kizu: it was dynamic, and it would be better if static
<matthieud> kizu: we are using a library for this : they are dynamic
<matthieud> kizu: static is better here
<matthieud> TabAtkins: everybody is okay with this approach
<matthieud> RESOLVED: accept what I said in the thread with caveat from here. when an anchor position element is first rendered or change fallback position, use the current scroll offset to calculate its position area

css-meeting-bot avatar Oct 16 '24 16:10 css-meeting-bot

Okay, first draft of the new spec text committed. It was a larger change than I thought it would be at first, but I think this captures the behavior we wanted (and is slightly better, in fact), while still respecting the scrolling-response constraints that Anchor Pos has to respect.

In short:

  • When an anchor-pos element is first laid out, or switches to new fallback styles, it remembers the relative scroll offset of each of its anchor references. From then on, it pretends that all scrollers are at their initial scroll position (same as the old spec) and adds the remembered scroll offset for that reference (new). This gets reflected into anchor().
  • The scroll adjustment for the default anchor element (now called the "default scroll shift") is redefined as the difference between its remembered scroll offset and its current combined scroll offset. Just a technical fix; it's otherwise treated the same, as a post-layout translation.
  • Like the old spec, the default scroll shift is used to determine if the anchorpos element is overflowing its IMCB; as before, this can be done on the compositor by pre-calculating the ranges of scroll offsets that won't cause overflow, since neither the element's size nor its CB's size can change as a result of the shift.
  • When calculating fallback, we explicitly skip whatever styles we're currently using (so nothing gets recalculated for them), and for all the other styles we're trying out, we record fresh remembered scroll offsets for all the references, which get used and persisted if that fallback gets chosen. Calculating fallbacks always needed to be done on the main thread since it can (and usually does) cause layout, so recapturing scroll offsets is acceptable here.
  • Now that anchor references capture their current scroll offset, we don't need the text in the fallback steps about adding the default scroll shift just to the non-auto edges of the IMCB when calculating if the fallback will overflow. All the edges are just automatically correct at the moment of calculation.

I've also added some new issues to the spec; since we're now respecting the scroll offsets of anchors, even if snapshotted, can we go ahead and respect all the things that change their position, in particular transforms? The lack of ability to respond to transforms has been a complaint from multiple authors, but we really couldn't do anything about it before, but I think it should be amenable now? (Presumably we'd use the axis-aligned bounding box of the transformed margin box, relative to the CB's axises.)

This is in two parts, too: can we include transforms in the remembered scroll offset (the snapshots), and can we include changes to the default anchor's transforms in the default scroll shift (the live one)? I think the first is easier, since we'd be able to tree-walk to gather the combined transform effects and layer them with scrolling; I'm unsure if it's possible to do that as easily in the live case. If we can't do it generally, maybe we can do it just for transforms on the anchor itself, or on its nearest scroll container?

/cc @andruud @lilles

tabatkins avatar Oct 22 '24 21:10 tabatkins

Okay, first draft of the new spec text committed. It was a larger change than I thought it would be at first, but I think this captures the behavior we wanted (and is slightly better, in fact), while still respecting the scrolling-response constraints that Anchor Pos has to respect.

In short:

  • When an anchor-pos element is first laid out, or switches to new fallback styles, it remembers the relative scroll offset of each of its anchor references. From then on, it pretends that all scrollers are at their initial scroll position (same as the old spec) and adds the remembered scroll offset for that reference (new). This gets reflected into anchor().

What's the definition of first laid out? With container queries and scroll timelines there may be multiple passes where a box flips between being rendered and not in different passes during the same rendering update. getComputedStyle().width forces a layout, but should probably not be counted as "first laid out".

I think it would make sense to snapshot the scroll position as part of "run snapshot post-layout state steps", and probably consider the element as "first laid out" for the whole resizeObserver loop in https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model to account for changing scroll offsets (I'm assuming a position for "run snapshot post-layout state steps" after the "Recalculate styles and update layout for doc" which is something that was discussed at TPAC).

lilles avatar Oct 23 '24 08:10 lilles

What's the definition of first laid out? With container queries and scroll timelines there may be multiple passes where a box flips between being rendered and not in different passes during the same rendering update. getComputedStyle().width forces a layout, but should probably not be counted as "first laid out".

The actual spec states it in slightly more detail - it's when it starts generating a box (goes from display:none or display:contents to another value), and also links this to when CSS Animations are created. So at least gCS() won't trigger this, tho indeed, more timing detail is needed in general.

I think it would make sense to snapshot the scroll position as part of "run snapshot post-layout state steps",

Sounds reasonable to me.

and probably consider the element as "first laid out" for the whole resizeObserver loop in https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model to account for changing scroll offsets

By that you mean, in each pass of the RO loop, recalculate the offsets? That sounds reasonable to me.

tabatkins avatar Oct 24 '24 18:10 tabatkins

What's the definition of first laid out? With container queries and scroll timelines there may be multiple passes where a box flips between being rendered and not in different passes during the same rendering update. getComputedStyle().width forces a layout, but should probably not be counted as "first laid out".

The actual spec states it in slightly more detail - it's when it starts generating a box (goes from display:none or display:contents to another value), and also links this to when CSS Animations are created. So at least gCS() won't trigger this, tho indeed, more timing detail is needed in general.

gCS() property access may trigger box generation and animations for sure, but should be irrelevant if we agree on the snapshotting.

I think it would make sense to snapshot the scroll position as part of "run snapshot post-layout state steps",

Sounds reasonable to me.

and probably consider the element as "first laid out" for the whole resizeObserver loop in https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model to account for changing scroll offsets

By that you mean, in each pass of the RO loop, recalculate the offsets? That sounds reasonable to me.

Yes.

lilles avatar Oct 25 '24 07:10 lilles

I'm not convinced that an anchor recalculation point at fallback position change is going to work too well.

<!DOCTYPE html>
<div style="height:100px;"></div>
<div style="width:100px; height:200px; anchor-name:--tjor; background:cyan;"></div>
<div id="elm" style="position:absolute; position-anchor:--tjor; position-area:top; position-try-fallbacks:flip-block; width:50px; height:100%; box-sizing:border-box; border:solid; background:hotpink;"></div>
<div style="height:200vh;"></div>

The height of the positioned element #elm, which should be anchored above (first option) or below (second option) the anchor, is 100%. It's naturally going to fit above initially. Now, if we scroll down a few pixels, it's no longer going to fit above, and we'll place it below. If there really is an anchor recalculation point at that point, we're going to recalculate based on the new scroll offset, so that #elm will fit snugly below the anchor. By doing that, #elm is also going to fit snugly above the anchor again, and we'll place it there (it's the first option, after all, and it fits!), at its new shrunken size.

mstensho avatar Nov 29 '24 07:11 mstensho

Nope, the spec explicitly prevents that from happening; see step 2.1 of "determine fallback styles", where we check if the styles are the currently-used ones and skip over them.

tabatkins avatar Dec 02 '24 22:12 tabatkins

Seems to me that the implementation in Blink in this regard is very different from what the spec says (and has been so for almost a year), which is what confused me. I've filed https://issues.chromium.org/issues/391907168

In short, the way I read it: the spec says to remember the chosen position option across layouts / scroll operations (and only reconsider if it overflows), whereas Blink recalculates it on every layout / scroll operation.

mstensho avatar Jan 24 '25 13:01 mstensho

@tabatkins Can you comment on this, please? This got introduced by commit 99403a8635fae054845320ea5f1453977c84e231. The spec should be more clear, whether my reading of the spec is right or not.

I'm talking about the wording in https://drafts.csswg.org/css-anchor-position-1/#fallback-apply , and how it suggests that the UA should only consider switching to a new option when the current option no longer fits (shouldn't matter whether it's currently at the "default" option with unmodified styles, or at a fallback option defined in position-try-fallbacks?).

What Blink currently does: Every time the options are being reconsidered, we'll just start at the first option, and go through them all until we've found something that fits (or, if position-try-order requires us to, we'll go through them all, and stable-sort them and pick the best one), meaning that if we're at a fallback position, and a preceding option suddenly fits, the preceding option will be chosen, regardless of whether the current fallback option fits.

This part of the spec also seems oblivious to the fact that there may be a specific try order (let the tallest one win, for instance), which complicates the story and calls for further clarifications. It starts with "When a positioned box (shifted by its default scroll shift) overflows its inset-modified containing block", which won't do if position-try-order wants us to consider all viable options and stable-sort them. We cannot wait until it overflows then.

mstensho avatar Jan 29 '25 12:01 mstensho

Sorry for the delay, @mstensho .

Yeah, Blink appears to be violating the spec, tho that might be a reasonable thing to do. I put in the restriction of "only recalculate on overflow" in the hopes of keeping it cheaper (you reevaluate only at the times that you would have previously re-evaluated, you just record a little more data when you do so), but if Blink considers it acceptable to redo all the layout calculations once per frame, that's probably better for authors and users? Have we actually tested to make sure this has acceptable perf, particularly in complex cases (several chained anchorpos elements), or did the impl just do something simple and didn't check the perf beyond the simple case?

This part of the spec also seems oblivious to the fact that there may be a specific try order (let the tallest one win, for instance), which complicates the story and calls for further clarifications. It starts with "When a positioned box (shifted by its default scroll shift) overflows its inset-modified containing block", which won't do if position-try-order wants us to consider all viable options and stable-sort them. We cannot wait until it overflows then.

I'm not sure what you mean by this, tho. You calculate the IMCBs for each try-option, order them according to position-try-order, then try to lay out the anchorpos into them. Then you just wait until the anchorpos overflows before you redo any of these calculations. Per the spec, how does position-try-order complicate this? I can see how Blink's behavior makes it more complicated, but that's not the spec's fault as currently written. ^_^

tabatkins avatar Feb 11 '25 20:02 tabatkins

Sorry for the delay, @mstensho .

Yeah, Blink appears to be violating the spec, tho that might be a reasonable thing to do. I put in the restriction of "only recalculate on overflow" in the hopes of keeping it cheaper (you reevaluate only at the times that you would have previously re-evaluated, you just record a little more data when you do so), but if Blink considers it acceptable to redo all the layout calculations once per frame, that's probably better for authors and users? Have we actually tested to make sure this has acceptable perf, particularly in complex cases (several chained anchorpos elements), or did the impl just do something simple and didn't check the perf beyond the simple case?

I didn't work on anchor positioning when the current implementation was written, so I can't answer very confidently. But I've been assuming that the hard limit on the max number of try options per element (it is 6 in Blink) wouldn't cause unreasonable performance. Anyway, this is really only "scary" if there are chained anchor positioned elements, and some non-default position-try-order, in which case there might be some O(n^m) performance complexity (where n is the number of options, and m being the length of the anchor chain). And if there's non-default position-try-order, we have to always try them all anyway, regardless of how the spec is interpreted (cannot wait for overflow then).

If position-try-order is normal, on the other hand, this matters.

Imagine an anchor, and then a small positioned element that is placed above the anchor, with a fallback of placing it below the anchor. As long as it fits above, it will be placed above. If the document is scrolled so that it no longer fits above, it will go below. If the document then is scrolled just a few pixels back up, so that the element would fit above the anchor again, should we:

A: Move the element above the anchor unconditionally (current behavior in Blink)

B: Leave it below the anchor if it still fits there (the way I read the spec)

I was kind of hoping for B, since that would make it easier (or simply possible?) to let a (fallback) option change be an "anchor recalculation point". See example in https://github.com/w3c/csswg-drafts/issues/10999#issuecomment-2507266529 . Option B also introduces some statefulness, in that a given scroll offset doesn't always result in the same position option; it depends on where you come from. The good thing about it is more option stability. Why should we move away from an option that still fits, even though there's an option higher up on the list that fits?

... which brings me to another question: is the position derived from the base style so special, compared to any fallback options? In the end, aren't they all just options, albeit in a preferred order? Does it make sense to talk about fallbacks vs base style at all?

This part of the spec also seems oblivious to the fact that there may be a specific try order (let the tallest one win, for instance), which complicates the story and calls for further clarifications. It starts with "When a positioned box (shifted by its default scroll shift) overflows its inset-modified containing block", which won't do if position-try-order wants us to consider all viable options and stable-sort them. We cannot wait until it overflows then.

I'm not sure what you mean by this, tho. You calculate the IMCBs for each try-option, order them according to position-try-order, then try to lay out the anchorpos into them. Then you just wait until the anchorpos overflows before you redo any of these calculations. Per the spec, how does position-try-order complicate this? I can see how Blink's behavior makes it more complicated, but that's not the spec's fault as currently written. ^_^

You can't wait for overflow if you always want to pick the tallest option. Which one is the taller one may change as you scroll (the IMCB changes when you scroll), even if the current option doesn't overflow.

Does that make sense?

mstensho avatar Feb 11 '25 22:02 mstensho

And if there's non-default position-try-order, we have to always try them all anyway, regardless of how the spec is interpreted (cannot wait for overflow then).

I don't understand what you mean by this. There should not be any difference in behavior between 'normal' and the other values in this regard. Yes, you calculate all the IMCB sizes so you can sort them (rather than being able to skip some), but that still only happens when you're re-choosing the position, and when that happens doesn't depend on position-try-order. If you're switching positions due to scroll, that's only triggered by overflowing the scroll-adjusted IMCB size.

If the document then is scrolled just a few pixels back up, so that the element would fit above the anchor again, should we:

A: Move the element above the anchor unconditionally (current behavior in Blink)

B: Leave it below the anchor if it still fits there (the way I read the spec)

You read correctly, it should stay below, because you're not triggering a position recalc; you won't until the element actually overflows again. This stability is an intentional feature, both for perf (less recalcing) and for UX (less shifting around when it's not strictly necessary).

is the position derived from the base style so special, compared to any fallback options? In the end, aren't they all just options, albeit in a preferred order? Does it make sense to talk about fallbacks vs base style at all?

It's not special, it is indeed just the first among several options (and not even first, if position-try-order dictates a different ordering).

You can't wait for overflow if you always want to pick the tallest option. Which one is the taller one may change as you scroll (the IMCB changes when you scroll), even if the current option doesn't overflow.

Does that make sense?

In a vacuum, sure, but that's not what the spec says or intends. It doesn't want to always choose the tallest option, it wants to know the tallest option when it's time to re-determine position so it can try to put the element there first. Then it stays with that option until there's a reason to change (the element overflows).

I feel like we've been talking past each other because the impl does something violating the spec, and you've been working under the assumption the impl was correct. ^_^

tabatkins avatar Feb 12 '25 21:02 tabatkins

And if there's non-default position-try-order, we have to always try them all anyway, regardless of how the spec is interpreted (cannot wait for overflow then).

I don't understand what you mean by this. There should not be any difference in behavior between 'normal' and the other values in this regard. Yes, you calculate all the IMCB sizes so you can sort them (rather than being able to skip some), but that still only happens when you're re-choosing the position,

This is the part that I had missed. The spec is clear about it, and this is also what the implementation currently does. Yet, I managed to get confused. Thank you!

If the document then is scrolled just a few pixels back up, so that the element would fit above the anchor again, should we: A: Move the element above the anchor unconditionally (current behavior in Blink) B: Leave it below the anchor if it still fits there (the way I read the spec)

You read correctly, it should stay below, because you're not triggering a position recalc; you won't until the element actually overflows again. This stability is an intentional feature, both for perf (less recalcing) and for UX (less shifting around when it's not strictly necessary).

Very good! This is what I needed to know. If you want to review (or just check out) the tests that I'm adding and modifying, take a look at https://chromium-review.googlesource.com/c/chromium/src/+/6198263

is the position derived from the base style so special, compared to any fallback options? In the end, aren't they all just options, albeit in a preferred order? Does it make sense to talk about fallbacks vs base style at all?

It's not special, it is indeed just the first among several options (and not even first, if position-try-order dictates a different ordering).

Great. Maybe e.g. the term "position fallback styles" in the spec could be changed to make this clearer, since it's not necessarily about one of the fallbacks. Might just be the base style as well.

You can't wait for overflow if you always want to pick the tallest option. Which one is the taller one may change as you scroll (the IMCB changes when you scroll), even if the current option doesn't overflow. Does that make sense?

In a vacuum, sure, but that's not what the spec says or intends. It doesn't want to always choose the tallest option, it wants to know the tallest option when it's time to re-determine position so it can try to put the element there first. Then it stays with that option until there's a reason to change (the element overflows).

Right, this is the part that I had misunderstood, which you cleared up in a previous paragraph here.

I feel like we've been talking past each other because the impl does something violating the spec, and you've been working under the assumption the impl was correct. ^_^

I think we're finally on the same page. I was supposed to implement some new functionality, which was very confusing thanks to discrepancies between the spec and the implementation when I arrived in this land.

Thank you for explaining!

mstensho avatar Feb 13 '25 22:02 mstensho

This probably needs a WG resolution. :) And if it passes review, we should republish the spec.

See Tab's explanation in https://github.com/w3c/csswg-drafts/issues/10999#issuecomment-2430366312

fantasai avatar Mar 11 '25 18:03 fantasai

We already have a resolution for it: https://github.com/w3c/csswg-drafts/issues/10999#issuecomment-2417388395 The comment you link to is just me explaining the details of how I implemented the resolution.

tabatkins avatar Mar 11 '25 19:03 tabatkins