Why does popper jump to top-left corner when underlying component re-renders?

2.7k Views Asked by At

I am using the Material-UI Popper component (which in turn uses popper.js) to create a hovering toolbar. For the most part it is working well, except for one odd behavior:

  1. Select some text: the hovering toolbar appears above the text - as expected.
  2. Select any button in the toolbar: the appropriate action is performed. However the toolbar jumps to the top-left corner of the window. See below.

enter image description here

You can try out this behavior in my Storybook - just select some text and click on one of the "T" buttons.

The basic issue centers around positioning of the popper:

  1. When user selects some text, a fake virtual element is created and passed to the popper as an anchor element. Popper uses this anchorEl to position the hovering toolbar. So far so good.
  2. When the user clicks on a button in the toolbar, the hovering toolbar jumps to the top-left of the window.

I am guessing this is happening because the anchor element is somehow lost when the underlying component re-renders. I don't know why, but this is just my theory. Can someone help me solve this issue?

The code that computes the anchorEl sits inside a React useEffect(). I have made sure that the dependency list for the useEffect is accurate. I can see that when the toolbar jumps, the useEffect() is NOT being called, which means that anchorEl is not being recomputed. This leads me to believe that the toolbar should stay intact in its current position and not jump to (0,0). But that's not happening :-(.

Here's the useEffect() code inside the toolbar component. You can find the full code in my repo. Any help would be much appreciated.

useEffect(() => {
    if (editMode === 'toolbar') {
        if (isTextSelected) {
            const domSelection = window.getSelection();
            if (domSelection === null || domSelection.rangeCount === 0) {
                return;
            }
            const domRange = domSelection.getRangeAt(0);
            const rect = domRange.getBoundingClientRect();
            setAnchorEl({
                clientWidth: rect.width,
                clientHeight: rect.height,
                getBoundingClientRect: () =>
                    domRange.getBoundingClientRect(),
            });
            setToolbarOpen(true);
        } else {
            setToolbarOpen(false);
        }
    } else {
        setToolbarOpen(false);
    }
}, [editMode, isTextSelected, selection, selectionStr]);
1

There are 1 best solutions below

1
On BEST ANSWER

I believe your domRange is no longer valid after toggleBlock does its work (due to dom nodes getting replaced), so getBoundingClientRect is no longer returning anything meaningful.

You should be able to fix this by redoing the work of getting the range within the anchorEl's getBoundingClientRect. Perhaps something like the following (I didn't try to execute it, so no guarantee that there aren't minor errors):

const getSelectionRange = () => {
  const domSelection = window.getSelection();
  if (domSelection === null || domSelection.rangeCount === 0) {
    return null;
  }
  return domSelection.getRangeAt(0);
};
useEffect(() => {
  if (editMode === "toolbar") {
    if (isTextSelected) {
      const domRange = getSelectionRange();
      if (domRange === null) {
        return;
      }
      const rect = domRange.getBoundingClientRect();
      setAnchorEl({
        clientWidth: rect.width,
        clientHeight: rect.height,
        getBoundingClientRect: () => {
          const innerDomRange = getSelectionRange();
          return innerDomRange === null
            ? null
            : innerDomRange.getBoundingClientRect();
        }
      });
      setToolbarOpen(true);
    } else {
      setToolbarOpen(false);
    }
  } else {
    setToolbarOpen(false);
  }
}, [editMode, isTextSelected, selection, selectionStr]);