html icon indicating copy to clipboard operation
html copied to clipboard

Add BoundingClientRectObserver

Open jakub-trzebiatowski opened this issue 2 years ago • 8 comments

It's easy to query the bounds of a given element by calling the getBoundingClientRect method on that element.

It's also easy to observe the size of a given element, by using the ResizeObserver class.

What's nontrivial is observing the bounds of a given element. There are tricks to do something that works part of the time and has acceptable performance, or do something that works in general, but has a crappy performance (like polling getBoundingClientRect in every animation frame).

I propose to add a new utility called BoundingClientRectObserver, which does exactly what's in its name, i.e. allows observing element's bounds (the thing that getBoundingClientRect returns).

Usage:

new BoundingClientRectObserver((entries) => {
    entries.forEach((entry) => {
        const {target, newBounds} = entry;

        // React to changed bounds...
    });
}).observe(element);

Polyfill implementation: bounding-client-rect-observer.


I tried to search for existing efforts to define such utility, but I found none. Please let me know if there already is a proposal for such an observer!

jakub-trzebiatowski avatar Apr 01 '23 09:04 jakub-trzebiatowski

This would be really great. It has been a pain to write code that depends on the realtime value of some element's bounding client rect, and the only simple way to do it today is in a poll loop, f.e. reading it every animation frame even if it hasn't changed, which currently leads to a lot of performance cost.

It could be possible that browsers could optimize the return value, memoizing/caching it, to reduce the performance cost, but this would still not be aligned with the other *Observer patterns (MutationObserver, ResizeObserver, etc).

But there's a problem with adding new *Observer patterns in general:

We have no way to derive state from multiple combinations of them for use in the browser paint cycle.

The more *Observer APIs we add, the more need we have for some way to run logic after all paint-cycle observers have ran (by paint-cycle observers, I mean those that run in sync with animation frames and browser paint).

Here's the problem, which add yet another frame-based *Observer will exacerbate:

At present, if we rely on an animation frame, a ResizeObserver, a MutationObserver, and an IntersectionObserver, in order to react to changes of all of those, and to derive all the state we need for rendering something to a WebGL canvas, this means we will have to render to the canvas a total of 4 times in one frame, reducing our framerate to 25% (ouch!!!!!). In each callback, starting with the animation frame callback, we have to update state and then render.

We must render in every type of callback, because we don't know, for any given render frame, which callbacks will run.

If we render to canvas only in an animation frame callback (which is essentially the pattern that everyone (and I mean practically everyone) is writing today), we'll run into unintuitive issues like this one where on every resize the canvas will flicker (typically a white color on websites with default CSS background).

Here's a demo. Set SHOW_FLICKER_PROBLEM to true to see the issue here:

https://codepen.io/trusktr/pen/EzBKYM

Here's what the problem looks like:

https://github.com/whatwg/html/assets/297678/e549c44a-d893-409f-80d0-ca019d6323dc

Then set SOLVE_FLICKER_PROBLEM to true to enable the solution, and the problem will be gone:

https://github.com/whatwg/html/assets/297678/76226ae4-9898-4d92-820d-4c0c90698204

Note the comments, which will point out where the canvas is double rendered during resize, cutting the framerate of a resize in half (ouch!!). Resizing browser windows is typically sluggish already, and this will make things worse.

This problem happens because if we resize a canvas in a ResizeObserver callback after we have already rendered in an animation frame callback (animation frame callbacks always fire before ResizeObserver callbacks) and do not render again in the resize observer callback, then the canvas pixels will be cleared. The pixel clearing is a natural consequence of resizing a canvas and has nothing to do with animation frames or resize observers, however the way these APIs work, we do not have a reliable way to handle certain state changes after multiple callback groups (anim frames, observer callbacks).

The only way to get around all issues with aimation frames and *Observer APIs with simple code is to resort to polling for all state in a single animation frame callback, but with the added performance cost of unnecessarily reading state every frame (although in some cases this can be faster than double rendering).

To avoid polling, the solution would be very complicated, requiring use of MutationObserver to detect every possible change in the DOM that could possibly a client rect to change (there are many many possibly ways to change a client rect including <style> content changes, style attribute changes including of parents and children, inside and outside of shadow roots, etc, it gets really really complicated to do it robustly without missing any cases).

TLDR

Yes, new APIs like ClientRectObserver (I think this name is fine without "Bounding" in it) would be nice, but we also need a way to run final logic after all frame-based *Observer callbacks so that adding yet more *Observer APIs doesn't make the above issue worse.

For example, maybe something like this:

requestAnimationFrame(function loop() {
  // ... update things like position/rotation of objects, etc ...
  requestAnimationFrame(loop)
})
new ResizeObserver(() => {...}).observer(el)
new ClientRectObserver(() => {...}).observer(el)
new MutationObserver(() => {...}).observer(el) // microtask, irrelevant to render loop

requestFinalFrame(function finalLoop() {
  // This runs after everything, even frame-based *Observer APIs added in the future.
  // ... calculate final state, finally render to canvas a SINGLE time ...
  requestFinalFrame(finalLoop)
})

Every render cycle of the browser, callbacks would be called in roughly this order:

  • animation frame callbacks (do not render to canvas here)
  • *Observer callbacks in some particular order, but we should not rely on the order, just collect needed state.
  • final callbacks (derive final state, render to canvas, etc)

trusktr avatar Sep 11 '23 01:09 trusktr

@trusktr Thanks for this review! I wasn't aware of most of the mentioned issues. From my perspective, it nearly sounds like browser-side implementation challenges...

I'm not sure if I get why observing the size of something could cause more WebGL renders. Do we assume that the observing callback could trigger the draw? Or is it something more subtle?

I'm also not sure how much of what you're saying depends on your experience with the implementation details of real browsers; from my perspective, nothing would need to be drawn four/multiple times because of the observers, but in the worst case the layout would need to be calculated many times if one of the callbacks affected the layout. The JavaScript code hardly ever wonders about the rendered pixels.

But that's just the perspective of a web developer who doesn't need to think too much about browser's paint cycle most of the time.

jakub-trzebiatowski avatar Sep 11 '23 08:09 jakub-trzebiatowski

Isn't this solved by https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API? Also, this would benefit from reading through https://whatwg.org/faq#adding-new-features and applying it. Thanks!

annevk avatar Sep 11 '23 10:09 annevk

@annevk I can't see how Intersection Observer solves this problem, as it doesn't call the observer when the relevant element moves when it's fully visible.

Which points of the checklist do you have in mind? I would agree that the proposal would benefit from example use cases.

jakub-trzebiatowski avatar Sep 11 '23 12:09 jakub-trzebiatowski

IntersectionObserver does not solve this problem (I've tried and tried, and if it is solvable, it is far too complicated for anyone's good).

trusktr avatar Dec 17 '23 21:12 trusktr

Here's a related issue describing a problem that would need to be solved for BoundingClientRectObserver (and any other observers) if it (they) ever become reality (f.e. ComputedStyleObserver):

  • https://github.com/w3c/csswg-drafts/issues/9717

The more of these we add (which I think would be super useful) the more this problem will become pronounced.

Adding takeRecords and hasRecords to all *Observer APIs would help and would be useful.

Alternatively requestFinalFrame (or requestPaintFrame or some similar named API) would help solve the problem.

trusktr avatar Dec 17 '23 22:12 trusktr

@trusktr you can use a combination of IntersectionObserver (using negative root margins) and ResizeObserver to roughly get this behavior using @samthor’s library viz-observer. v2 was rewritten to follow the API of the existing observers.

For more on how it works: https://samthor.au/2021/observing-dom/

Floating UI also uses this approach for their tooltips/popovers/dialogs. https://github.com/floating-ui/floating-ui/blob/0918dddda0be8ca40e30402de5e3cb207df7f465/packages/dom/src/autoUpdate.ts#L40

ChrisShank avatar Aug 29 '24 19:08 ChrisShank

+1

Also, now that something like this is coming out for CSS anchor positioning, it might be easier for browsers to support?

AlbertMarashi avatar Oct 31 '24 16:10 AlbertMarashi