55

Is there a way to detect when an element's getBoundingClientRect() rectangle has changed without actually calculating getBoundingClientRect()? Something like a "dirty flag"? Naively, I assume that there must be such a mechanism somewhere in the internal workings of browsers, but I haven't been able to find this thing exposed in the DOM API. Maybe there is a way to do this with MutationObservers?

My application is a web component that turns DOM elements into nodes of a graph, and draws the edges onto a full-screen canvas. See here.

Right now, I'm calling getBoundingClientRect() for every element, one time per animation frame, even when nothing is changing. It's feeling expensive. I'm usually getting %15-%50 CPU usage on a decently powerful computer at 60 fps.

Does anyone know of such a thing? Do you think it's reasonable to expect something like this? Is this kind of thing feasible? Has it ever been proposed before?

Arad Alvand
  • 8,607
  • 10
  • 51
  • 71
micahscopes
  • 1,034
  • 1
  • 9
  • 12
  • 5
    You're looking for [ResizeObserver](https://developers.google.com/web/updates/2016/10/resizeobserver). See also [IntersectionObserver](https://developers.google.com/web/updates/2016/04/intersectionobserver). – wOxxOm Oct 25 '16 at 23:11
  • @wOxxOm wow... apparently this is cutting edge stuff! – micahscopes Oct 25 '16 at 23:16
  • @wOxxOm, hm, okay so I'm into these new tools... but there's one thing that seems to be missing. Check out this [lovely graph of bouncing basketballs](https://micahscopes.github.io/tangled-web-components/examples/balls.html). The basketballs are animated using CSS animation. I don't see how the IntersectionObserver or the ResizeObserver could possibly help me distinguish a non-moving div from one of these bouncing basketballs. Although I gotta admit that it's useful to know when something moves into the viewport, I'm looking for something like ResizeObserver + "*PositionObserver*"... – micahscopes Oct 25 '16 at 23:23
  • 3
    It doesn't detect repositioning. In your case of dragging an element, if you can ensure the parent element isn't moved/resized then simply use offsetLeft and offsetTop. – wOxxOm Oct 25 '16 at 23:28
  • 1
    @wOxxOm, the graph's edges are drawn on a canvas with fixed position using elements client rectangle, so that won't work for elements that are nested, since offsetLeft and offsetTop are based on the parent position. I'm hoping that in the future, there will be something like "BoundingClientRectObserver" or "PositionObserver" in the browser. – micahscopes Oct 25 '16 at 23:35
  • 1
    What I meant is that you can calculate the parent's position just once and then use child's offsetLeft and offsetTop. And take in account window.scrollX & Y. – wOxxOm Oct 25 '16 at 23:43
  • 2
    @wOxxOm, are you suggesting to do getBoundingClientRect() on the parent, then using offsetLeft and offsetTop on the parent's children? I think that's a good idea for relatively positioned children, but in this case I have positioned the children absolutely. Your method does sound more efficient, but it's too complicated to be a general solution. – micahscopes Oct 26 '16 at 18:25
  • Please vote for and comment on the `ClientRectObserver` proposal here: https://discourse.wicg.io/t/idea-clientrectobserver/6095 – trusktr Apr 18 '23 at 19:51

2 Answers2

22

As mentioned in the comments above. The APIs you're looking for are: ResizeObserver and IntersectionObserver. However, there are a few things to note:

  • ResizeObserver will only fire when the observed element changes size. And it will essentially only give you correct values for width and height.
  • Both ResizeObserver and IntersectionObserver are supposed to not block paint
  • ResizeObserver will trigger after layout but before paint, which essentially makes it feel synchronous.
  • IntersectionObserver fires asynchronously.

What if you need position change tracking

This is what IntersectionObserver is made for. It can often be used for visibility detection. The problem here is that IntersectionObserver only fires when the ratio of intersection changes. This means that if a small child moves around within a larger container div, and you track intersection between the parent and the child, you won't get any events except when the child is entering or exiting the parent.

You can still track when an element moves at all. This is how:

  • Start by measuring the position of the element you want to track using getBoundingClientRect.
  • Insert a div as an absolutely positioned direct child of body which is positioned exactly where the tracked element is.
  • Start tracking the intersection between this div and the original element.
  • The intersection should start at 1. Whenever it changes to something else:
    • Remeasure the element using getBoundingClientRect.
    • Fire the position/size changed event
    • update the styles of the custom div to the new position of the element.
    • the observer should fire again with the intersection ratio at 1 again, this value can be ignored.

NOTE: this technique can also be used for more efficient polypill for ResizeObserver which is a newer feature than IntersectionObserver. The commonly available polyfills rely on MutationObserver which is considerably less efficient.

Community
  • 1
  • 1
Naman Goel
  • 1,525
  • 11
  • 16
  • 20
    have you got an example of your method? I can't see how this would work given that the absolutely positioned element is a direct child of the body but IntersectionObserver would require it to be a descendent of the element that we want to track – MarcS Feb 14 '20 at 08:31
  • @MarcS If the tracked-element does not have `position: relative` then the absolutely-positioned child will be positioned relative to some ancestor that *does* have `position: relative;`. I think the tracked element will need two absolutely-positioned child elements (one in the top-left corner, the other in the bottom-right corner) for it to work (if it's only in the top-left corner then if the tracked element is moved towards the left by 1px then the intersection observer wouldn't fire. – Dai Mar 28 '20 at 23:54
  • 5
    How would you do the "Start tracking the intersection between this div and the original element." step? That doesn't seem possible with a traditional `IntersectionObserver` as neither element is an ancestor of the other. – DBS May 07 '21 at 10:48
  • I don't know why this has so many upvotes. As far as I can tell, it cannot work, since an `IntersectionObserver` can only observe the intersection between an element and its ancestor, not between two arbitrary elements. Some kind of solution could be to create a child node, position it using `position: fixed` and observe its intersection to the observed element, but even that will cause problems because both elements [need to have the same containing block](https://stackoverflow.com/a/65738211/242365). – cdauth Nov 17 '22 at 21:07
  • 1
    Downvoted because `IntersectionObserver` can only observe intersection between a child and its ancestor, so this answer is incorrect with respect to the non-ancestor div, as if it was a guess. – trusktr Dec 21 '22 at 21:17
  • Why no example? – Arad Alvand Aug 11 '23 at 01:17
4

I have had mediocre success with a combination of 3 observers:

  • An event listener listens for scroll events on any of the scrollable ancestors of the observed element. For this, a capture scroll event listener can be registered on the document, which checks if the event target is an ancestor of the observed element.
  • A ResizeObserver detects resizes of the observed element.
  • An IntersectionObserver detects position changes of the observed element. To achieve this, an invisible child element is added to the observed element, 2×2 pixels in size, positioned using position: fixed. The invisible element does not have any top or left coordinates, causing it to be rendered inside the observed element, but it is moved using a negative margin-left and margin-top to an absolute position of -1,-1 in the top left corner of the viewport. With this positioning, 1 of the 4 pixels is visible in the viewport (at position 0,0 of the viewport), while the other 3 pixels are invisible (at position -1,-1, -1,0 and 0,-1 of the viewport). As soon as the observed element moves, its invisible child moves with it, causing 0 or more than 1 pixel to be visible and the IntersectionObserver to fire, leading us to emit a change event and repositioning the invisible element.
function observeScroll(element: HTMLElement, callback: () => void): () => void {
    const listener = (e: Event) => {
        if ((e.target as HTMLElement).contains(element)) {
            callback();
        }
    };

    document.addEventListener('scroll', listener, { capture: true });
    return () => {
        document.removeEventListener('scroll', listener, { capture: true });
    };
}

function observeSize(element: HTMLElement, callback: () => void): () => void {
    const resizeObserver = new ResizeObserver(() => {
        callback();
    });
    resizeObserver.observe(element);
    return () => {
        resizeObserver.disconnect();
    };
}

function observePosition(element: HTMLElement, callback: () => void): () => void {
    const positionObserver = document.createElement('div');
    Object.assign(positionObserver.style, {
        position: 'fixed',
        pointerEvents: 'none',
        width: '2px',
        height: '2px'
    });
    element.appendChild(positionObserver);

    const reposition = () => {
        const rect = positionObserver.getBoundingClientRect();
        Object.assign(positionObserver.style, {
            marginLeft: `${parseFloat(positionObserver.style.marginLeft || '0') - rect.left - 1}px`,
            marginTop: `${parseFloat(positionObserver.style.marginTop || '0') - rect.top - 1}px`
        });
    };
    reposition();

    const intersectionObserver = new IntersectionObserver((entries) => {
        const visiblePixels = Math.round(entries[0].intersectionRatio * 4);
        if (visiblePixels !== 1) {
            reposition();
            callback();
        }
    }, {
        threshold: [0.125, 0.375, 0.625, 0.875]
    });
    intersectionObserver.observe(positionObserver);

    return () => {
        intersectionObserver.disconnect();
        positionObserver.remove();
    };
}

export function observeBounds(element: HTMLElement, callback: () => void): () => void {
    const destroyScroll = observeScroll(element, callback);
    const destroySize = observeSize(element, callback);
    const destroyPosition = observePosition(element, callback);
    return () => {
        destroyScroll();
        destroySize();
        destroyPosition();
    };
}

When using this code, keep in mind that the callback is called synchronously and will block whatever event is calling it. The callback should call whatever actions asynchronously (for example using setTimeout()).

Note: There are some situations where this will not work:

  • When the observed element has an ancestor that acts as a containing block for fixed elements (transform, perspective, filter or will-change: transform is set, see MDN under fixed) AND has overflow set to something else than visible, fixed descendants will not be able to escape the containing block. This will cause permanent invisibility of all 4 pixels of the position detector element, so the IntersectionObserver will not fire on position changes. I am looking for a solution here.
  • When the observer is used inside an iframe and the top left corner of the iframe is out of view, the IntersectionObserver will also report 0 pixels of visibility.
cdauth
  • 6,171
  • 3
  • 41
  • 49
  • This code is incredibly brittle; so many ways it can break (f.e. scroll container is not always document, containing block is different, etc); but this shows just how difficult this problem is. If only we had an official web API for this. – trusktr Dec 24 '22 at 00:33