Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add BoundingClientRectObserver #9104

Open
cubuspl42 opened this issue Apr 1, 2023 · 6 comments
Open

Add BoundingClientRectObserver #9104

cubuspl42 opened this issue Apr 1, 2023 · 6 comments
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest

Comments

@cubuspl42
Copy link

cubuspl42 commented Apr 1, 2023

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);

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!

@trusktr
Copy link

trusktr commented Sep 11, 2023

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:

flicker.mov

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

no-flicker.mov

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)

@cubuspl42
Copy link
Author

@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.

@annevk annevk added addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest labels Sep 11, 2023
@annevk
Copy link
Member

annevk commented Sep 11, 2023

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!

@cubuspl42
Copy link
Author

@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.

@trusktr
Copy link

trusktr commented Dec 17, 2023

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
Copy link

trusktr commented Dec 17, 2023

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):

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest
Development

No branches or pull requests

3 participants