ZeeCoder / use-resize-observer

A React hook that allows you to use a ResizeObserver to measure an element's size.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Observer is constantly destroyed and replaced when using onResize callback

mattlucock opened this issue · comments

Hello @ZeeCoder! I've recently started on adding some ResizeObservers into my React app. I found this hook, and it's great, and I love it. Most of the instances of this hook I have added work great, but the last instance of this hook I needed to add before I was done has broken my entire app. What exactly is causing this has been very elusive, and I've spent a few hours trying to track it down. I was able to determine that the bug was not in my code, nor does it have anything to do with the polyfill, and that it is indeed here. After going line-by-line through your source code and adding lots of console.log calls to it, I found it.

The bug is that when using the onResize callback, the hook destroys its existing observer and creates a new one every single time the hook is called, that is, every single time the component is rendered. In the particular case I refer to, this bug breaks my app, and I'll explain how that happens. However, this bug occurs in all cases; it's just that you normally wouldn't notice it.

The reason why this happens is because you have an effect hook that creates and destroys the observer, and that hook has the onResize callback as a dependency. Now, obviously, if the hook's dependencies aren't equal, the hook runs again. Consider the example you wrote in the readme:

import React from "react";
import useResizeObserver from "use-resize-observer";

const App = () => {
  // width / height will not be returned here when the onResize callback is present
  const { ref } = useResizeObserver({
    onResize: ({ width, height }) => {
      // do something here.
    }
  });

  return <div ref={ref} />;
};

Conceptually, the onResize callback is completely static between renders of the component. But here's the kicker: two functions are equal if and only if they are the same object i.e. they have the same memory reference. It actually uses Object.is() to compare dependencies, but this is the case for strict equality too. The function may seem static, but you'll notice that it's defined within the component. As such, a new function is created every single time App renders. This means that in this example and all examples like it, the onResize callback 'changes' on literally every render. It is impossible to pass the same function twice. This causes the observer to be destroyed and re-created on every render.

Now, what you could do is something like this.

import React from "react";
import useResizeObserver from "use-resize-observer";

const onResize = ({ width, height }) => {
  // do something here.
}

const App = () => {
  // width / height will not be returned here when the onResize callback is present
  const { ref } = useResizeObserver({ onResize });
  return <div ref={ref} />;
};

And guess what! The callback is is declared statically, and not just conceptually but literally testing it in my app, doing this completely eliminates the problem.

The case of this bug that is causing so much strife for me is a case where I have infinite render loop, causing everything to hang and crash. Inside the callback, I'm setting the dimensions as state, which causes the component to re-render with that new state. The fact that it re-renders even if the values are the same is an implementation detail of what I'm doing, but because of that, and because ResizeObservers fire once immediately when set to observe an element, this creates a constant loop of re-rendering and creating and destroying the observer.

I note from this test that the ability to change the callback and for it to be called immediately is a feature you deliberately sought to support. However, I'm not sure this actually makes sense. Partially, there is just no good way to do this, because there is no good way to detect an actual change in function in this context, as discussed. But I'm also not sure it makes conceptual sense. I think if I changed the callback of an instance of the hook that already exists, for... some reason (I'm not sure what the use case for this actually is), I would just expect it to get called next time the observer fires; I don't see any intuitive reason for it to be called immediately. The fact that you're destroying and re-creating the whole ResizeObserver when the callback changes I don't think is at all intuitive. So if I were you, I would just remove the callback as a dependency of the effect hook. I think you could use a ref to store the callback as it changes to make it available to the ResizeObserver already in existence.

An additional minor thing I caught which you could perhaps call a bug is that it seems that you intend for the hook to not update anything if the values doesn't actually change. This just doesn't work with the onResize callback. It checks the current values with the previous values, but the previous values are never set in the onResize branch, so that condition is trivially true when using a onResize callback. In theory, if this worked, I would never have had this problem. However, I feel like this check should actually just be done away with. Perhaps you can think of same cases that I can't, but in theory, there should never be any case where a ResizeObserver fires for an element, but that element has not changed size from the last time it fired. I feel like if that ever happens, it's inherently a bug. All this check does is make said bug undetectable.

Sorry for not reacting to this yet, but as there's a lot going on in my life in light of recent events I can't dedicate the time to this it would deserve.
I really appreciate the effort that must've gone into this issue, and will check back as soon as I'm able.

I haven't read everything you wrote - sorry! (It's a bit long.)

However, from what I managed to understand while briefly skimming your response, yes, you're right, observer will get .observe() and .unobserve() called whenever onResize changes ( https://github.com/ZeeCoder/use-resize-observer/blob/master/src/index.js#L64 ).

Thus, you should use useMemo or useCallback to memoize the onResize function, before passing it to the hook.

One thing that could be improved in this repo: current example in README. It generates a new onResize function on each render, which is bad practice, at least with the current implementation of use-resize-observer.

@jtomaszewski that's a very good point.
I'll start tackling these issues on the repo soon I think as I've been laid off (along with the 2/3 of the company) due to the effects the coronavirus pandemic had on the company.

Oh, also it seems like resizeObserver is being recreated then ( https://github.com/ZeeCoder/use-resize-observer/blob/master/src/index.js#L30 ).

Instead, it could be using the same instance of ResizeObserver between the renders. You can do that with useRef for keeping the observer instance.

P.S. Sorry for you @ZeeCoder ! Luckily I haven't heard about IT layoffs here in Poland yet.

Only the first part of what I wrote was me outlining the bug. The rest was just me proposing a solution and describing a separate-but-related issue that I had found.

Using useCallback or another hook is the same idea as the example I gave where the callback is just defined statically outside the component, but of course, using a hook would be a better way to do it. So yeah, that's a way to get around the bug, but it's still a bug—a very straightforward one at that. And yes, the observer does get recreated then. That's the entire bug.

Having a single observer instance might make sense for other, unrelated reasons, but I don't think it makes sense as a solution to this bug. My interpretation from reading the source, as I explained, was that onResize being a dependency of the effect hook was a simple oversight, because it obviously has undesirable effects. Simply removing that fixes this problem entirely, as I said.

This would also be solved if we used a single ResizeObserver instance across all hook instances: #26

@matthewlucock if I omit onResize from the effect deps array, then if you change the callback of the hook between renders in the host component, the hook won't adapt and the new callback won't be called.

That would not be what you expect, and I'd call that a bug.
I do agree though that the documentation needs to be updated in this regard.

Actually, I may have just thought of a way to do this in a way that the onResize callback would not need memoising.

I'd just need to store the onResize callback in a ref, and update .current whenever the hook function is called, and have the RO callback just check for the ref value.

That way the RO never need to be recreated, and changes to the onResize callback are still respected as it's stored and updated in a mutable ref.

That way the RO never need to be recreated, and changes to the onResize callback are still respected as it's stored and updated in a mutable ref.

@ZeeCoder If I'm interpreting the above and the associated PR correctly, this means #24/#26 has been fixed here as well?