Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why does setting CSS property using Promise.then not actually happen at the then block?

Please try and run the following snippet, then click on the box.

const box = document.querySelector('.box')
box.addEventListener('click', e => {
  if (!box.style.transform) {
    box.style.transform = 'translateX(100px)'
    new Promise(resolve => {
      setTimeout(() => {
        box.style.transition = 'none'
        box.style.transform = ''
        resolve('Transition complete')
      }, 2000)
    }).then(() => {
      box.style.transition = ''
    })
  }
})
.box {
  width: 100px;
  height: 100px;
  border-radius: 5px;
  background-color: #121212;
  transition: all 2s ease;
}
<div class = "box"></div>

What I expect to happen:

  • Click happens
  • Box starts translating horizontally by 100px (this action takes two seconds)
  • On click, a new Promise is also created. Inside said Promise, a setTimeout function is set to 2 seconds
  • After the action is completed (two seconds have elapsed), setTimeout runs its callback function and set transition to none. After doing that, setTimeout also reverts transform to its original value, thus rendering the box to appear at the original location.
  • The box appears at the original location with no transition effect problem here
  • After all of those finish, set the transition value of the box back to its original value

However, as can be seen, the transition value does not seem to be none when running. I know that there are other methods to achieve the above, e.g. using keyframe and transitionend, but why does this happen? I explicitly set the transition back to its original value only after the setTimeout finishes its callback, thus resolving the Promise.

EDIT

As per request, here's a gif of the code displaying the problematic behaviour: Problem

like image 656
Richard Avatar asked Feb 12 '20 12:02

Richard


People also ask

How do you return from promise then?

Return valuereturns a value, the promise returned by then gets resolved with the returned value as its value. doesn't return anything, the promise returned by then gets resolved with an undefined value. throws an error, the promise returned by then gets rejected with the thrown error as its value.

What does the finally () method on promise to provide your explanation?

The finally() method of a Promise schedules a function, the callback function, to be called when the promise is settled. Like then() and catch() , it immediately returns an equivalent Promise object, allowing you to chain calls to another promise method, an operation called composition.

What is promise then?

A promise is a pattern for handling asynchronous operations. The promise allows you to call a method called "then" that lets you specify the function(s) to use as the callbacks.


2 Answers

The event loop batches style changes. If you change the style of an element on one line, the browser doesn't show that change immediately; it'll wait until the next animation frame. This is why, for example

elm.style.width = '10px';
elm.style.width = '100px';

doesn't result in flickering; the browser only cares about the style values set after all Javascript has completed.

Rendering occurs after all Javascript has completed, including microtasks. The .then of a Promise occurs in a microtask (which will effectively run as soon as all other Javascript has finished, but before anything else - such as rendering - has had a chance to run).

What you're doing is you're setting the transition property to '' in the microtask, before the browser has started rendering the change caused by style.transform = ''.

If you reset the transition to the empty string after a requestAnimationFrame (which will run just before the next repaint), and then after a setTimeout (which will run just after the next repaint), it'll work as expected:

const box = document.querySelector('.box')
box.addEventListener('click', e => {
  if (!box.style.transform) {
    box.style.transform = 'translateX(100px)'
    setTimeout(() => {
      box.style.transition = 'none'
      box.style.transform = ''
      // resolve('Transition complete')
      requestAnimationFrame(() => {
        setTimeout(() => {
          box.style.transition = ''
        });
      });
    }, 2000)
  }
})
.box {
  width: 100px;
  height: 100px;
  border-radius: 5px;
  background-color: #121212;
  transition: all 2s ease;
}
<div class="box"></div>
like image 195
CertainPerformance Avatar answered Sep 18 '22 08:09

CertainPerformance


You are facing a variation of the transition doesn't work if element start hidden problem, but directly on the transition property.

You can refer to this answer to understand how the CSSOM and the DOM are linked for the "redraw" process.
Basically, browsers will generally wait until the next painting frame to recalculate all the new box positions and thus to apply CSS rules to the CSSOM.

So in your Promise handler, when you reset the transition to "", the transform: "" has still not been calculated yet. When it will get calculated, the transition will already have been reset to "" and the CSSOM will trigger the transition for the transform update.

However, we can force the browser to trigger a "reflow" and thus we can make it recalculate the position of your element, before we reset the transition to "".

const box = document.querySelector('.box')
box.addEventListener('click', e => {
  if (!box.style.transform) {
    box.style.transform = 'translateX(100px)'
    new Promise(resolve => {
      setTimeout(() => {
        box.style.transition = 'none'
        box.style.transform = ''
        box.offsetWidth; // this triggers a reflow
        resolve('Transition complete')
      }, 2000)
    }).then(() => {
      box.style.transition = ''
    })
  }
})
.box {
  width: 100px;
  height: 100px;
  border-radius: 5px;
  background-color: #121212;
  transition: all 2s ease;
}
<div class = "box"></div>

Which makes the use of the Promise quite unnecessary:

const box = document.querySelector('.box')
box.addEventListener('click', e => {
  if (!box.style.transform) {
    box.style.transform = 'translateX(100px)'
    setTimeout(() => {
      box.style.transition = 'none'
      box.style.transform = ''
      box.offsetWidth; // this triggers a reflow
      // even synchronously
      box.style.transition = ''
    }, 2000)
  }
})
.box {
  width: 100px;
  height: 100px;
  border-radius: 5px;
  background-color: #121212;
  transition: all 2s ease;
}
<div class = "box"></div>

And for an explanation on micro-tasks, like Promise.resolve() or MutationEvents, or queueMicrotask(), you need to understand they'll get ran as soon as the current task is done, 7th step of the Event-loop processing model, before the rendering steps.
So in your case, it's very like if it were ran synchronously.

By the way, beware micro-tasks can be as blocking as a while loop:

// this will freeze your page just like a while(1) loop
const makeProm = ()=> Promise.resolve().then( makeProm );
like image 41
Kaiido Avatar answered Sep 21 '22 08:09

Kaiido