12

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

TylerH
  • 20,799
  • 66
  • 75
  • 101
Richard
  • 7,037
  • 2
  • 23
  • 76
  • In what browser are you seeing this? On Chrome, I am seeing what it is intended. – Terry Feb 12 '20 at 12:11
  • @Terry Firefox 73.0 (64-bit) for Windows. – Richard Feb 12 '20 at 12:11
  • Can you attach a gif to your question that illustrates the issue? As far as I could tell it's also rendering/behaving as expected on Firefox. – Terry Feb 12 '20 at 12:13
  • When the Promise resolves, the original transition is restored, but at this point the box is still transformed. Therefore it transitions back. You need to wait at least 1 more frame before resetting the transition to the original value: https://jsfiddle.net/khrismuc/3mjwtack/ –  Feb 12 '20 at 12:16
  • When run multiple times, I managed to reproduce the problem once. Transition execution depends on what the computer is doing at background. Adding some more time to the delay before resolving the promise might help. – Teemu Feb 12 '20 at 12:21
  • @Terry Added a gif displaying the problem. – Richard Feb 12 '20 at 12:22
  • @Teemu Thank you for the suggestion. However, I'm more interested in why this is happening. – Richard Feb 12 '20 at 12:22
  • Did you not see my comment from 5 minutes ago containing the explanation and solution...? –  Feb 12 '20 at 12:22
  • But I just explained it, transitions are not accurate, the execution time is depended on what the computer does on the background. That's why you're most often adviced to use animations instead. – Teemu Feb 12 '20 at 12:23
  • @ChrisG But shouldn't it happen sequentially? In my code, setting `transform` value to its original value happened before tampering with the `transition` value. – Richard Feb 12 '20 at 12:23
  • In theory yes, but JS has a pretty complex internal event loop and some commands are not executed right away. They end up queued until the next frame. I've seen issues resolved by putting the code in question in `setTimeout` with a delay of zero. –  Feb 12 '20 at 12:25
  • @ChrisG I see. I suppose that an answer that explains the internal event loop briefly and a simple scenario of my commands being queued instead of being executed sequentially should suffice. – Richard Feb 12 '20 at 12:28

4 Answers4

4

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>
CertainPerformance
  • 356,069
  • 52
  • 309
  • 320
  • This is the answer I was looking for. Thanks. Could you, perhaps, provide a link that describes these *microtask* and *repaint* mechanics? – Richard Feb 12 '20 at 12:30
  • This looks like a good summary: https://javascript.info/event-loop – CertainPerformance Feb 12 '20 at 12:35
  • 2
    That's not exactly true no. The event loop doesn't say anything about style changes. Most browsers will try to wait for the next painting frame when they can, but that's about it. [more info](https://stackoverflow.com/a/60199904/3702797) ;-) – Kaiido Feb 13 '20 at 03:12
3

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 );
Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Yes exactly, but responding to the `transitionend` event would avoid having to hard-code a timeout to match the end of the transition. [transitionToPromise.js](https://gist.github.com/davej/44e3bbec414ed4665220) will promisify the transition allowing you to write `transitionToPromise(box, 'transform', 'translateX(100px)').then(() => /* four lines as per answer above */)`. – Roamer-1888 Feb 13 '20 at 06:19
  • Two advantages: (1) the transition's duration can then be modified in CSS without needing to modify the javascript; (2) transitionToPromise.js is reusable. BTW, I tried it and it works well. – Roamer-1888 Feb 13 '20 at 06:19
  • @Roamer-1888 yes you could, though I would personally add a check `(evt)=>if(evt.propertyName === "transform"){ ...` to avoid false positives and I don't really like promisifying such events, because you never know if it will ever fire (think of a case like `someAncestor.hide() ` when the transition runs, your Promise will never fire and your transition will get stuck disabled. So that's really up to OP to determine what's best for them, but personally and by experience, I now prefer timeouts than transitionend events. – Kaiido Feb 13 '20 at 06:41
  • 1
    Pros & cons, I guess. In any case, with or without promisification, this answer is far cleaner than one involving two setTimeouts and a requestAnimationFrame. – Roamer-1888 Feb 13 '20 at 06:56
  • I have one question though. You said that `requestAnimationFrame()` will be triggered **just before** the next browser repaint. You also mentioned that *browsers will generally wait until the next painting frame to recalculate all the new box positions*. Yet, you still needed to manually trigger a forced reflow (your answer on the first link). I, hence, draw the conclusion that even when `requestAnimationFrame()` happens just before repaint, the browser still has **not** calculated the newest computed style; thus, the need to **manually** force a recalculation of styles. Correct? – Richard Feb 13 '20 at 14:32
  • @Richard I did link to the event loop processing model at multiple occasions in my answer. From there you can see that only in some of these loops (or "frames") the [*update the rendering*](https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering) algorithm is called (which makes this particular loop a "painting frame"). This frame is responsible for triggering a bunch of UI events, then requestAnimationFrame callbacks and finally update the rendering. If nothing required a synchronous reflow, then browsers will wait this very last step to recalc the box positions. – Kaiido Feb 14 '20 at 01:45
  • In other words, the *update the rendering* (recalculating box positions) step from "painting frame" happens after said `requestAnimationFrame()` if synchronous reflow is not needed before this step? – Richard Feb 14 '20 at 03:19
  • @Richard requestAnimationFrame callbacks are called as part of the *update the rendering* algorithm (10.11). So is the actual *rendering* (10.14). It is not specced here when the recalculating of box positions happens. But since it's a costly operation, browsers will try to wait the last moment to do it. This last moment is **inside** the final "rendering" step (10.14). At this time no user-script (js) is to be ran anymore on this thread. – Kaiido Feb 14 '20 at 03:29
0

I believe your issue is just that in your .then you are setting the transition to '', when you should be setting it to none as you did in the timer callback.

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 = 'none'; // <<----
    })
  }
})
.box {
  width: 100px;
  height: 100px;
  border-radius: 5px;
  background-color: #121212;
  transition: all 2s ease;
}
<div class = "box"></div>
Scott Marcus
  • 64,069
  • 6
  • 49
  • 71
  • 1
    No, OP is setting it to `''` to apply the class rule again (which was overruled by the element rule) your code simply sets it to `'none'` twice, which prevents the box from transitioning back but doesn't restore its original transition (the class's) –  Feb 12 '20 at 12:20
0

I appreciate this isn't quite what you're looking for, but - out of curiosity and for the sake of completeness - I wanted to see if I could write a CSS-only approach to this effect.

Almost... but it turns out I still had to include a single line of javascript.

Working Example:

document.querySelector('.box').addEventListener('animationend', (e) => e.target.blur());
.box {
  width: 100px;
  height: 100px;
  border-radius: 5px;
  background-color: #121212;
  cursor: pointer;
}

.box:focus {
 animation: boxAnimation 2s ease;
}

@keyframes boxAnimation {
  100% {transform: translateX(100px);}
}
<div class="box" tabindex="0"></div>
Rounin
  • 27,134
  • 9
  • 83
  • 108