Why I need 2 requestAnimationFrame invokes to wait the dom rendered

Viewed 187

I'm trying to locate which element newly rendered is under mouse pointer. (*)

Here is my code:

btn.addEventListener('click', function () {
  btn.remove();
  for (let i = 0; i < 10; i++) {
    lst.appendChild(document.createElement('li')).textContent = 'Element ' + i;
  }
  requestAnimationFrame(function () { requestAnimationFrame(function () {
    const chosen = document.querySelector('li:hover');
    alert(chosen && 'Your mouse on ' + chosen.textContent); // do something more with chosen
  }); });
});
#btn { width: 200px; height: 200px; }
#lst { width: 200px; line-height: 20px; display: block; padding: 0; }
#lst li { display: block; height: 20px; width: 200px; overflow: hidden; }
#lst li:hover { background: #ccc; }
<button id=btn>Click Me</button>
<ul id=lst><ul>

I'm confused that I need 2 requestAnimationFrame to make my code execute correctly. Removing one raf, the alert will show null instead.

The code also seems ugly to me. How to implement it more elegantly?


In case anyone care about: I'm running my code on Firefox. And the code, as a part of my Firefox extension, only need to target to Firefox 60+.

(*): The story behind may be more complex. But to keep it simple...

2 Answers

That's quite an interesting behavior you found here, browsers seem to not update the :hover before that second frame, even if we force a reflow or what else.

Even worse, in Chrome if you hide the <button> element using display:none, it will stay the :hover element until the mouse moves (while normally display:none elements are not accessible to :hover).

The specs don't go into much details about how :hover should be calculated, so it's a bit hard to tell it's a "bug" per se.

Anyway, for what you want, the best is to find that element through the document.elementsFromPoints method, which will work synchronously.

btn.addEventListener('click', function ( evt ) {
  btn.remove();
  for (let i = 0; i < 10; i++) {
    lst.appendChild(document.createElement('li')).textContent = 'Element ' + i;
  }
  const chosen = document.elementsFromPoint( evt.clientX, evt.clientY )
    .filter( (elem) => elem.matches( "li" ) )[ 0 ];

  alert(chosen && 'Your mouse on ' + chosen.textContent); // do something more with chosen
});
#btn { width: 200px; height: 200px; }
#lst { width: 200px; line-height: 20px; display: block; padding: 0; }
#lst li { display: block; height: 20px; width: 200px; overflow: hidden; }
#lst li:hover { background: #ccc; }
<button id=btn>Click Me</button>
<ul id=lst><ul>

I cannot exactly answer the question why you need 2 rafs.

But i can provide you an more elegant way with async / await. Create a small function called nextTick that returns an promise. So you await for the next frame.

So you can first wait till the button is gone, create your elemens, then await again for the next painting cycle to be sure the elements are accessible

btn.addEventListener('click', async function () {
  btn.remove();
  await nextTick();
  for (let i = 0; i < 10; i++) {
    lst.appendChild(document.createElement('li')).textContent = 'Element ' + i;
  }
  await nextTick()
  const chosen = document.querySelector('li:hover');
  alert(chosen && 'Your mouse on ' + chosen.textContent); // do something more with chosen
});

function nextTick() {
   return new Promise(requestAnimationFrame)
}
#btn { width: 200px; height: 200px; }
#lst { width: 200px; line-height: 20px; display: block; padding: 0; }
#lst li { display: block; height: 20px; width: 200px; overflow: hidden; }
#lst li:hover { background: #ccc; }
<button id=btn>Click Me</button>
<ul id=lst><ul>

Related