How React blocks UI updating in useLayoutEffect

Viewed 103

If you open this code in a browser and look in the developer tools under the "elements" tab, you will see that after the button is clicked, the element will be added to the DOM but will not be rendered.

How does React do it? What browser mechanism or API is being used and how?

Also, if you remove "console.log(element)" from useLaoutEffect, then the component will not immediately appear in the "elements" tab in the DOM tree. Why is this happening?

const sleep = (duration) => {
  const start = new Date().getTime();
  let end = start;

  while(end < start + duration) {
    end = new Date().getTime();
  }
}

const Message = ({boxRef, children}) => {

  const msgRef = React.useRef(null);

  React.useLayoutEffect(() => {
    const element = document.querySelector('.msg');

    console.log(element);

    const rect = boxRef.current.getBoundingClientRect();

    sleep(1000);

    msgRef.current.style.top = `${rect.height + rect.top}px`;
  }, []);

  return <span ref={msgRef} className="msg">{children}</span>;
};

const Index = () => {
  const [show, setShow] = React.useState(false);
  const boxRef = React.useRef(null);

  return (
    <div>
      <div ref={boxRef} className="box" onClick={() => setShow(prev => !prev)}>Click me</div>
      {show && <Message boxRef={boxRef}>Foo bar baz</Message>}
    </div>
  );
};

ReactDOM.render(<Index />, document.getElementById('root'));
.box {
  position: absolute;
  width: 100px;
  height: 100px;
  border: 1px solid green;
  user-select: none;
}

.msg {
  user-select: none;
  position: relative;
  border: 1px solid red;
  background-color: red;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

1 Answers

This was super interesting so I started to look into it. React uses requestAnimationFrame and tries to schedule renders/work between frames. I think it's less React and more that you have overloaded the browser and prevented it from being able to paint.

https://github.com/facebook/react/blob/4189f712c16cfd6365c163525527a30ef2518b99/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L236

You are effectively blocking the browser from painting, despite the DOM update, for 2 seconds.

Paste this into your console to see it happen:

const sleep = (duration) => {
    const start = new Date().getTime();
    let end = start;

    while(end < start + duration) {
        end = new Date().getTime();
    }
}

requestAnimationFrame(() => sleep(2000)) && document.body.prepend("I appear after browser next paint, which I block")

This is kind of familiar today because Lighthouse/google speed metrics have been targeting time to interactive (TTI) and Long Running Tasks are a big part of the perf game.

You can see if you run the chrome perf profiler during your code, there is a 2 second long running task in red which is preventing paints (well, everything..). Then a quick tiny paint to catch up to the updated DOM.

The DOM Update

Also, if you remove "console.log(element)" from useLaoutEffect, then the component will not immediately appear in the "elements" tab in the DOM tree. Why is this happening?

As for this, this is also interesting!

I don't have a definitive answer but I can definitely reproduce it. I would think that just like what React is doing in their render pipeline to prioritize important tasks, the browser is also trying to determine what tasks to execute in what order.

In the case of your querySelector that never gets used (until you log it), this DOM injection gets deprioritized enough to happen after the long running task. We're talking absolutely tiny amounts of time here.

If you interact with it before the blocking code, it gets scheduled earlier.

Related