1

After I move an element within the DOM using insertBefore, appendChild(), or insertAdjacentElement() CSS transitions don't occur unless some time has passed. It's acting as if there's something asynchronous about these functions. Is there any documented explanation for this?

If there is some aspect of this that is asynchronous, what's the "right" way to do something after it finishes? Is there another event triggered? setTimeout()? How long? Is it affected by system load on the client side?

The following example can also be found in this pen, but I'll update the pen after I find the solution.

In the example code, there are three promises that should occur in succession. The first one hides an element. The second one moves the element to an appropriate place in the DOM. Then the last one shows the element again. Showing and hiding the element is done by toggling a CSS hidden class with a max-height: 0 set. There's a transition: max-height 0.5s set for the element, but this transition doesn't happen immediately after the element is moved. If I either skip the function that moves the element or insert a delay the problem doesn't occur.

console.clear()
window.addEventListener('load', () => {
  const infoBlock = document.getElementById('info')
  const links = document.getElementsByTagName('a')

  for (let link of links) {
    link.addEventListener('click', e => {
      e.preventDefault()
      infoBlock.target = e.target
      toggleInfo(infoBlock, 'hide')
        .then(infoBlock => positionInfo(infoBlock))
        .then(infoBlock => toggleInfo(infoBlock, 'show'))
    })
  }
})

function toggleInfo(infoBlock, action) {
  return new Promise((resolve, reject) => {
    const isVisible = () => !infoBlock.classList.contains('hidden')

    const transitionHandler = e => {
      if (e.propertyName !== 'max-height') return
      infoBlock.removeEventListener('transitionend', transitionHandler)
      resolve(infoBlock)
    }

    const correctState = (action === 'hide') ? !isVisible() : isVisible()
    if (!correctState) {
      infoBlock.classList.toggle('hidden')
      infoBlock.addEventListener('transitionend', transitionHandler)
    } else {
      resolve(infoBlock)
    }
  })
}

function positionInfo(infoBlock) {
  return new Promise((resolve, reject) => {
    let target = infoBlock.target

    let row = target.parentNode
    while (!row.classList.contains('row')) row = row.parentNode

    let nextRow = row.nextSibling

    if (nextRow !== null) {
      nextRow.parentNode.insertBefore(infoBlock, nextRow)
    } else {
      row.parentNode.appendChild(infoBlock)
    }

    // row.insertAdjacentElement('afterend', infoBlock)

    // Doesn't work in Chrome or Firefox
    resolve(infoBlock)

    // Doesn't work in Chrome, works in Firefox
    // window.requestAnimationFrame(resolve.bind(null, infoBlock))

    // Works in Chrome, doesn't work in Firefox
    // setTimeout(resolve.bind(null, infoBlock), 10)

    // Works in Chrome or Firefox... but why?
    // setTimeout(resolve.bind(null, infoBlock), 100)
  })
}
.row a {
  margin: 10px 0;
}

.info {
  background: #111;
  color: white;
  text-align: center;
  height: 240px;
  max-height: 240px;
  transition: max-height 1s;
}

.info.hidden {
  max-height: 0;
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/css/bootstrap.css" rel="stylesheet" />

<div id="info" class="row info hidden">
  <p class="col-12">info</p>
</div>
<div class="container">
  <div class="row" id="row01">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row02">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row03">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row04">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row05">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/js/bootstrap.min.js"></script>
BoltClock
  • 700,868
  • 160
  • 1,392
  • 1,356
Vince
  • 3,962
  • 3
  • 33
  • 58
  • Your resolve probably requires to be bound to the `promise` so why even `bind`? Just use an anonymous function in `setTimeout` and call it a day, as that works. If you would read up on how browser engines work, you would find out why your transition doesn't occur the way you think it does. – somethinghere Apr 24 '18 at 10:48
  • @somethinghere I don't understand your comment. I don't have a clue where to read about how browser engines work and if I did I'm pretty sure I wouldn't understand anyway. There's a lot I don't understand. This project is educational, not professional. I'm calling resolve with an argument because the real code that this example is a subset of modifies the object and that needs to be passed on to the next function in the chain. I can use anonymous functions instead of bind, but that gets me exactly the same result and doesn't seem to be related to my problem. – Vince Apr 24 '18 at 12:14
  • Im not asking you to look into the details of how a browser engine works but a general overview will explain to you how and when classes and changes get applied, explaining why a `setTimeout` should delay any change in drawing for the next cycle, so the renderer has already processed your elements style without the class, and in the next cycle will notice the class has changed and trigger a transition. – somethinghere Apr 24 '18 at 12:22

1 Answers1

1

For a transition to work, the element has to go from one state to an other one.
Browsers will generally wait until they have to actually paint your page before recalculating all the new styles you may have applied through js.
In your complex code, the different states are set synchronously, and thus, the transition won't occur.

That's why waiting some time might work, or might not: when this recalculation should happen is not specified, and you can't be sure when a specific browser will do it.

  • requestAnimationFrame should trigger before the next screen refresh, so if the browser implementation decided to trigger the recalculation just before the repaint, it can be after this rAF callback.
    To wait next frame, you have to nest your rAF calls (requestAnimationFrame(()=>requestAnimationFrame(callback))).
  • setTimeout(fn, 10) is just complete randomness to this regard, because a frame lasts at most 16.6ms.
  • setTimeout(fn, 100) will work because it's more than a frame later.

To overcome this, in all browsers, and synchronously, you can tell the browser to recalculate the current styles applied on your elements by triggering a reflow, before you set the final state, in positionInfo(), before resolving the Promise:

console.clear()
window.addEventListener('load', () => {
  const infoBlock = document.getElementById('info')
  const links = document.getElementsByTagName('a')

  for (let link of links) {
    link.addEventListener('click', e => {
      e.preventDefault()
      infoBlock.target = e.target
      toggleInfo(infoBlock, 'hide')
        .then(infoBlock => positionInfo(infoBlock))
        .then(infoBlock => toggleInfo(infoBlock, 'show'))
    })
  }
})

function toggleInfo(infoBlock, action) {
  return new Promise((resolve, reject) => {
    const isVisible = () => !infoBlock.classList.contains('hidden')

    const transitionHandler = e => {
      if (e.propertyName !== 'max-height') return
      infoBlock.removeEventListener('transitionend', transitionHandler)
      resolve(infoBlock)
    }

    const correctState = (action === 'hide') ? !isVisible() : isVisible()
    if (!correctState) {
      infoBlock.classList.toggle('hidden')
      infoBlock.addEventListener('transitionend', transitionHandler)
    } else {
      resolve(infoBlock)
    }
  })
}

function positionInfo(infoBlock) {
  return new Promise((resolve, reject) => {
    let target = infoBlock.target

    let row = target.parentNode
    while (!row.classList.contains('row')) row = row.parentNode

    let nextRow = row.nextSibling

    if (nextRow !== null) {
      nextRow.parentNode.insertBefore(infoBlock, nextRow)
    } else {
      row.parentNode.appendChild(infoBlock)
    }
    // trigger a reflow
    row.offsetWidth;
    resolve(infoBlock)
  })
}
.row a {
  margin: 10px 0;
}

.info {
  background: #111;
  color: white;
  text-align: center;
  height: 240px;
  max-height: 240px;
  transition: max-height 1s;
}

.info.hidden {
  max-height: 0;
}
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/css/bootstrap.css" rel="stylesheet" />

<div id="info" class="row info hidden">
  <p class="col-12">info</p>
</div>
<div class="container">
  <div class="row" id="row01">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row02">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row03">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row04">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
  <div class="row" id="row05">
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
    <a href="#" class="col-4"><img src="https://via.placeholder.com/150x100" alt="placeholder"></a>
  </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/js/bootstrap.min.js"></script>

Note that you seem to have other problems in your code, but I'll let it for you.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • This solves the problem and I really appreciate it, but I only understand part of it. I think I understand how triggering a reflow after moving the element makes sense, but I don't understand the part about the different states being set synchronously. I'm not setting any styles using JavaScript. I'm just toggling a class which only defines a single property. I thought the synchronous calls would be a good thing... The repositioning of the infoBlock can only happen after the first transition has finished, the second class change can only happen after the repositioning has finished. – Vince Apr 24 '18 at 11:53
  • hehe you called this code *complex*... This is a subset of the actual code which is **much** more complex. – Vince Apr 24 '18 at 11:53
  • I used the word "*style*" because it's basically what matters, even when this style change is set through a class setting, I didn't mean (only) setting `Element.style.prop`. – Kaiido Apr 24 '18 at 11:56
  • 1
    @Vince to give you a more complete view on it https://stackoverflow.com/questions/47342730/javascript-are-dom-redraw-methods-synchronous/47343090#47343090 – Kaiido Apr 24 '18 at 12:18