4

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...

tsh
  • 4,263
  • 5
  • 28
  • 47
  • Interesting... I'd like to note I got mislead by the fact that moving the mouse will actually update the `:hover`, and when clicking with my mouse, it did move it. So a better test case for me was to wrap all in a timeout so I can stop touching my mouse: https://jsfiddle.net/1ky05m46/ And if we change the .remove() to set a display:none rule, then we can see that the button is still the one having the :hover on it... https://jsfiddle.net/1ky05m46/1/ Which I guess should not be possible... And note that Chrome doesn't even update it until the mouse moves in this case... – Kaiido Nov 03 '20 at 04:50

2 Answers2

2

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>
Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • 1
    I'm trying this approach. Since my dom update logic is not in a mouseevent, I have to storage the position on every mousemove event. Anyway, it at least works. And... async operation may cause more buggy behaviors and I would like to avoid them. – tsh Nov 03 '20 at 10:28
  • I'm seeing this behavior in a more generic case - I am trying to display a busy indicator before doing some lengthy work, but unless I request the animation frame *twice* **after** updating the indicator, it isn't displayed until *after* the work is done (which of course makes it useless) – Michael Jun 14 '23 at 18:18
  • @Michael this is another problem, only loosely related. See this answer of mine about that *issue* https://stackoverflow.com/a/63929232/3702797 – Kaiido Jun 15 '23 at 01:27
  • Thanks! In my case, I figured out the reason is that i'm using await in an async function. This effectively makes the rest of the function (or until another await) execute before the "callback" returns. Putting a second await causes the equivalent an immediate "return" if I had been using a callback and allows the window to be repainted. – Michael Jun 15 '23 at 01:51
0

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>
bill.gates
  • 14,145
  • 3
  • 19
  • 47
  • 2
    And how can you ensure that in two browser versions this behavior won't change? Using a double rAF is ugly because there is no defined logic for why it works, not because having nested callbacks is ugly in itself. For instance, as I pointed out in my comment, if OP were to hide the button using `display:none`, then in Chrome 2 rAFs are not enough a new mouse move is required. – Kaiido Nov 03 '20 at 08:43