jaredLunde/react-hook

resize-observer only reports latest resized elements

flekschas-ozette opened this issue · 0 comments

Describe the bug
I noticed that in some cases useResizeObserver does not report the resizing of certain elements when observing several elements. This was confusing so I created a ResizeObserver manually and it indeed recognized the resizing of all elements.
I dug into the code I found the cause for the problem: raf-schd. Using RAF-based throttling unfortunately can cause resized elements to not be reported. The reason why is documented on the raf-schd page:

import rafSchd from 'raf-schd';

const expensiveFn = arg => { ... };

const schedule = rafSchd(expensiveFn);

schedule('foo');
schedule('bar');
schedule('baz');

// animation frame fires

// => 'baz'

Think of expensiveFn being the ResizeObserver's callback function and foo, bar, and baz as different elements. All three might resize but the ResizeObserver's callback function is only called on the last element.

To Reproduce
I unfortunately don't have a dedicated repository, but you can create a React app with react-split and listen to several elements within the split. When dragging the resize handle, only one element will be reported as being resized.

Expected behavior
I'd expect useResizeObserver to be throttle but to invoke the callback function on all resized elements on every animation frame.

Possible Solution
The issue with rafSchd is that the arguments of latest call wins. However, we want to accumulate the entries that resized during an animation frame. In my local testing the following allows us to do that:

function createResizeObserver() {
  let ticking = false; // Determines whether we're waiting for the next animation frame
  let allEntries = []; // Will store all entries that were observed to have resized

  const callbacks: Map<any, Array<UseResizeObserverCallback>> = new Map()

  const observer = new ResizeObserver(
    (entries, obs) => {
      // In a unthrottled fashion we're going to accumulate all resized enries
      allEntries = allEntries.concat(entries);
      if (!ticking) {
        window.requestAnimationFrame(() => {
          // Once we get a new animation frame we're going to go over all accumulated entries.
          // Since it might be possible that the same element was reported to have resized
          // multiple times, we're going to keep track of which element we already reported.
          const triggered = new Set();
          for (let i = 0; i < allEntries.length; i++) {
            if (triggered.has(allEntries[i].target)) continue;
            triggered.add(allEntries[i].target);
            const cbs = callbacks.get(allEntries[i].target);
            cbs?.forEach((cb) => cb(allEntries[i], obs));
          }
          allEntries = [];
          ticking = false;
        });
      }
      ticking = true;
    }
  )

  return {
    observer,
    subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
      observer.observe(target)
      const cbs = callbacks.get(target) ?? []
      cbs.push(callback)
      callbacks.set(target, cbs)
    },
    unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
      const cbs = callbacks.get(target) ?? []
      if (cbs.length === 1) {
        observer.unobserve(target)
        callbacks.delete(target)
        return
      }
      const cbIndex = cbs.indexOf(callback)
      if (cbIndex !== -1) cbs.splice(cbIndex, 1)
      callbacks.set(target, cbs)
    },
  }
}

Version: 1.2.5