Wing
Wing

Reputation: 9701

What is causing this broken animation/transition in a Vue.js component in Chrome?

const NotificationBar = {
  name: 'notification-bar',

  template: `
    <div
      :class="{
        'notification-bar': true,
        'notification-bar--error': isError,
        'notification-bar--warning': isWarning,
        'notification-bar--info': isInfo,
        'notification-bar--visible': isVisible,
      }"

      @click="dismiss"
      @transitionend="transitionEnd($event)">
      {{ message }}
    </div>
  `,

  props: {
    message: {
      type: String,
      required: true,
    },

    type: {
      type: String,
      required: true,

      validator(value) {
        const valid = ['error', 'warning', 'info'];
        return valid.includes(value);
      },
    },

    dismissable: {
      type: Boolean,
      default: false,
    },

    timeout: {
      type: Number,
      default: 0,
    },
  },

  data() {
    return {
      isVisible: false,
    };
  },

  computed: {
    isError() {
      return this.type === 'error';
    },

    isWarning() {
      return this.type === 'warning';
    },

    isInfo() {
      return this.type === 'info';
    },
  },

  methods: {
    clear() {
      const event = 'cleared';
      let done;

      if (this.isVisible) {
        this.$once('transitionend', () => {
          done = true;
          this.$emit(event, done);
        });
        this.isVisible = false;
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    dismiss() {
      const event = 'dismissed';
      let done;

      if (this.dismissable) {
        done = true;
        this.$emit(event, done);
        this.clear();
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    show() {
      if (!this.isVisible) {
        this.isVisible = true;
        this.$emit('show', this.clear);

        if (this.timeout) {
          setTimeout(() => {
            this.$emit('timeout');
            this.clear();
          }, this.timeout);
        }
      }
    },

    transitionEnd(event) {
      this.$emit('transitionend', event);
    },
  },

  mounted() {
    window.requestAnimationFrame(this.show);
  },
};

const NotificationCenter = {
  name: 'notification-center',

  components: {
    NotificationBar,
  },

  template: `
    <div>
      <notification-bar
        v-for="notification in active"

        :message="notification.message"
        :type="notification.type"
        :dismissable="notification.dismissable"
        :timeout="notification.timeout"

        @cleared="clear">
      </notification-bar>
    </div>
  `,

  props: {
    queue: {
      type: Array,
      required: true,
    },
  },

  data() {
    return {
      active: [],
    };
  },

  computed: {
    hasActiveNotification() {
      return this.active.length > 0;
    },

    hasQueuedNotification() {
      return this.queue.length > 0;
    },
  },

  watch: {
    queue() {
      if (this.hasQueuedNotification && !this.hasActiveNotification) {
        this.setNextActive();
      }
    },
  },

  methods: {
    setNextActive() {
      this.setActive(this.queue.shift());
    },

    setActive(notification) {
      this.active.push(notification);
    },

    removeActive() {
      this.active.pop();
    },

    clear() {
      this.active.pop();

      if (this.hasQueuedNotification) {
        this.$nextTick(this.setNextActive);
      }
    },
  },
};

window.vm = new Vue({
  components: {
    NotificationCenter,
  },

  el: '#app',

  template: `
    <div>
      <notification-center
        :queue="notifications">
      </notification-center>

      <label>
        <strong>Type</strong> <br>
        Error <input v-model="type" type="radio" name="type" value="error"> <br>
        Warning <input v-model="type" type="radio" name="type" value="warning"> <br>
        Info <input v-model="type" type="radio" name="type" value="info"> <br>
      </label>

      <label>
        <strong>Message</strong>
        <input v-model="message" type="text">
      </label>

      <label>
        <strong>Dismissable</strong>
        <input v-model="dismissable" type="checkbox">
      </label>

      <label>
        <strong>Timeout</strong>
        <input v-model="timeout" type="number" step="100" min="0">
      </label>

      <button @click="generateNotification">Generate notification</button>
    </div>
  `,

  data: {
    notifications: [],
    
    type: null,
    message: null,
    dismissable: null,
    timeout: null,
  },
  
  methods: {
    generateNotification() {
      const {
        type,
        message,
        dismissable,
        timeout,
      } = this;
      
      this.notifications.push({
        type,
        message,
        dismissable,
        timeout,
      });
      
      this.type = this.message = this.dismissable = this.timeout = null;
    },
  },
});
.notification-bar {
  box-sizing: border-box;
  position: absolute;
  top: -3.2rem;
  right: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3.2rem;
  color: #fff;
  font-family: 'Avenir Next', sans-serif;
  font-size: 1.2em;
  line-height: 3.2rem;
  text-align: center;
  transition: top 266ms ease;
}
.notification-bar--error {
  background-color: #f02a4d;
}
.notification-bar--warning {
  background-color: #ffc107;
}
.notification-bar--info {
  background-color: #2196f3;
}
.notification-bar--visible {
  top: 0;
}

label {
  display: block;
  margin: 2rem 0;
  font-size: 1.4rem;
}
label:first-of-type {
  margin-top: 5rem;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
    <style>
      html {
        font-size: 62.5%;
      }
      
      body {
        font-family: sans-serif;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.3/vue.js"></script>
  </body>
</html>

Steps to reproduce problem

  1. Run above snippet in Chrome.
  2. Generate a notification. (Set type to anything, set message to anything, set dismissable, click Generate notification)
  3. Observe notification bar

Expected behaviour

Notification bar smoothly animates into the visible viewport. You can observe this by doing the steps above in Firefox.

Here is a GIF demonstrating the correct behaviour in Chrome. Upon clicking Generate notification you can see the bar smoothly transitioning in.

GIF of expected behaviour

Here is the screenshot of the timeline when Chrome is behaving correctly:

Timeline of expected behaviour

Here is the call tree when Chrome is behaving correctly:

Call tree of expected behaviour

Actual behaviour

Notification bar does not smoothly animate into the visible viewport the majority of the time. Capturing a timeline in Chrome Devtools shows no animation running when notification bar is being shown. Animations always run correctly when bar is animating off screen. Animation always runs correctly in Firefox.

Here is a GIF demonstrating the incorrect behaviour in Chrome. Upon clicking Generate notification you can see the bar suddenly appearing.

GIF of incorrect behaviour

Here is the screenshot of the timeline when Chrome is not behaving correctly:

Timeline of incorrect behaviour

Here is the call tree when Chrome is not behaving correct:

Call tree of incorrect behaviour

Additional information

Outline of what code is doing:

  1. NotificationCenter takes in a queue prop. This is an array of objects with the array representing a queue of notifications and an object representing a single notification.

  2. Once the queue changes, a watcher runs checking if there are notifications in the queue and if there isn't an active notification. If this is the case, the next notification is set as the active notification.

  3. NotificationCenter's template has a directive looping over items in active and renders a NotificationBar. In the previous step, a new active notification was set thus a new notification bar will be created and mounted to the DOM.

  4. Once the NotificationBar is mounted on the DOM, its show method is run inside of window.requestAnimationFrame.

Upvotes: 1

Views: 4506

Answers (2)

Wing
Wing

Reputation: 9701

After some discussion with LinusBorg, a contributor to Vue, on the Vue forum we had a possible cause for this problem:

[...] the issue is likely that Vue patches the DOM asynchronously, so when mounted() is called, the elements of the components exist, but they are not guranteed to be in the DOM.

And so now, depending on how different browsers handle the priorities of normal tasks, microtasks and animationFrames, it may simply be the case that in Chrome, the element is not in the DOM yet, when you change the class through show()

In that case, the animation effect would not appear, naturally.

I suggest to try this.$nextTick() instead (which guarantees that the element is already in the DOM), or simply use the tools Vue gives you for this, namely the <transition> component.

– LinusBorg, https://forum.vuejs.org/t/what-is-causing-this-broken-animation-transition-in-a-vue-js-component-in-chrome/7742/7

Attempts to use this.$nextTick were made initially but failed in both Firefox and Chrome.

Eventually I was able to implement this whole thing using the <transition> component.

const NotificationBar = {
  name: 'notification-bar',
  
  template: `
    <transition
      name="visible"
      mode="out-in"

      @after-enter="show">
      <div
        :class="{
          'notification-bar': true,
          'notification-bar--error': isError,
          'notification-bar--warning': isWarning,
          'notification-bar--info': isInfo,
          'notification-bar--visible': isVisible,
        }"

        :key="id"

        @click="dismiss">
        {{ message }}
      </div>
    </transition>
  `,

  props: {
    message: {
      type: String,
      required: true,
    },

    type: {
      type: String,
      required: true,

      validator(value) {
        const valid = ['error', 'warning', 'info'];
        return valid.includes(value);
      },
    },

    id: {
      type: [Number, String],
      required: true,
    },

    dismissable: {
      type: Boolean,
      default: false,
    },

    timeout: {
      type: Number,
      default: 0,
    },
  },

  data() {
    return {
      isVisible: false,
    };
  },

  computed: {
    isError() {
      return this.type === 'error';
    },

    isWarning() {
      return this.type === 'warning';
    },

    isInfo() {
      return this.type === 'info';
    },
  },

  methods: {
    clear() {
      const event = 'clear';
      let done;

      if (this.isVisible) {
        done = true;
        this.$emit(event, done);
        this.isVisible = false;
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    dismiss() {
      const event = 'dismissed';
      let done;

      if (this.dismissable) {
        done = true;
        this.$emit(event, done);
        this.clear();
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    show() {
      if (!this.isVisible) {
        this.isVisible = true;
        this.$emit('show', this.clear);

        if (this.timeout) {
          setTimeout(() => {
            this.$emit('timeout');
            this.clear();
          }, this.timeout);
        }
      }
    },
  },
};

const NotificationCenter = {
  name: 'notification-center',
  
  template: `
    <div>
      <notification-bar
        v-if="hasQueuedNotification"

        :message="activeNotification.message"
        :type="activeNotification.type"
        :dismissable="activeNotification.dismissable"
        :timeout="activeNotification.timeout"
        :id="activeNotification.id"

        @clear="clear">
      </notification-bar>
    </div>
  `,

  components: {
    NotificationBar,
  },

  props: {
    queue: {
      type: Array,
      required: true,
    },
  },

  computed: {
    hasQueuedNotification() {
      return this.queue.length > 0;
    },

    activeNotification() {
      return this.queue[0];
    },
  },

  methods: {
    clear() {
      this.queue.shift();
    },
  },
};

window.vm = new Vue({
  components: {
    NotificationCenter,
  },

  el: '#app',

  template: `
    <div>
      <notification-center
        :queue="notifications">
      </notification-center>

      <label>
        <strong>Type</strong> <br>
        Error <input v-model="type" type="radio" name="type" value="error"> <br>
        Warning <input v-model="type" type="radio" name="type" value="warning"> <br>
        Info <input v-model="type" type="radio" name="type" value="info"> <br>
      </label>

      <label>
        <strong>Message</strong>
        <input v-model="message" type="text">
      </label>

      <label>
        <strong>Dismissable</strong>
        <input v-model="dismissable" type="checkbox">
      </label>

      <label>
        <strong>Timeout</strong>
        <input v-model="timeout" type="number" step="100" min="0">
      </label>

      <button @click="generateNotification">Generate notification</button>
    </div>
  `,

  data: {
    notifications: [],

    type: null,
    message: null,
    dismissable: null,
    timeout: null,

    dismissIndex: null,
    dismissMessage: null,
  },

  methods: {
    generateNotification() {
      const {
        type,
        message,
        dismissable,
        timeout,
      } = this;

      const id = Date.now();

      this.notifications.push({
        type,
        message,
        dismissable,
        timeout,
        id,
      });

      this.type = this.message = this.dismissable = this.timeout = null;
    },
  },
});
.notification-bar {
  box-sizing: border-box;
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3.2rem;
  color: #fff;
  font-family: 'Avenir Next', sans-serif;
  font-size: 1.2em;
  line-height: 3.2rem;
  text-align: center;
}
.notification-bar--error {
  background-color: #f02a4d;
}
.notification-bar--warning {
  background-color: #ffc107;
}
.notification-bar--info {
  background-color: #2196f3;
}
.notification-bar.visible-enter, .notification-bar.visible-leave-to {
  top: -3.2rem;
}
.notification-bar.visible-enter-to, .notification-bar.visible-leave {
  top: 0;
}
.notification-bar.visible-enter-active, .notification-bar.visible-leave-active {
  transition: top 266ms ease;
}

/* ================================================================== */
/*                                                                    */
/* ================================================================== */
html {
  font-size: 62.5%;
}

body {
  margin: 0;
  border: 1px solid black;
  font-family: sans-serif;
}

label {
  display: block;
  margin: 2rem 0;
  font-size: 1.4rem;
}

label:first-of-type {
  margin-top: 5rem;
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title></title>
</head>
<body>
  <div id="app"></div>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.1.10/vue.js"></script>
</body>
</html>

Upvotes: 1

Saurabh
Saurabh

Reputation: 73609

The issue is with this line:

this.type = this.message = this.dismissable = this.timeout = null;

If you remove this it works fine. as when this gets executed props becomes NULL and you have validation that props should not be null.

const NotificationBar = {
  name: 'notification-bar',

  template: `
    <div
      :class="{
        'notification-bar': true,
        'notification-bar--error': isError,
        'notification-bar--warning': isWarning,
        'notification-bar--info': isInfo,
        'notification-bar--visible': isVisible,
      }"

      @click="dismiss"
      @transitionend="transitionEnd($event)">
      {{ message }}
    </div>
  `,

  props: {
    message: {
      type: String,
      required: true,
    },

    type: {
      type: String,
      required: true,

      validator(value) {
        const valid = ['error', 'warning', 'info'];
        return valid.includes(value);
      },
    },

    dismissable: {
      type: Boolean,
      default: false,
    },

    timeout: {
      type: Number,
      default: 0,
    },
  },

  data() {
    return {
      isVisible: false,
    };
  },

  computed: {
    isError() {
      return this.type === 'error';
    },

    isWarning() {
      return this.type === 'warning';
    },

    isInfo() {
      return this.type === 'info';
    },
  },

  methods: {
    clear() {
      const event = 'cleared';
      let done;

      if (this.isVisible) {
        this.$once('transitionend', () => {
          done = true;
          this.$emit(event, done);
        });
        this.isVisible = false;
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    dismiss() {
      const event = 'dismissed';
      let done;

      if (this.dismissable) {
        done = true;
        this.$emit(event, done);
        this.clear();
      } else {
        done = false;
        this.$emit(event, done);
      }
    },

    show() {
      if (!this.isVisible) {
        this.isVisible = true;
        this.$emit('show', this.clear);

        if (this.timeout) {
          setTimeout(() => {
            this.$emit('timeout');
            this.clear();
          }, this.timeout);
        }
      }
    },

    transitionEnd(event) {
      this.$emit('transitionend', event);
    },
  },

  mounted() {
    window.requestAnimationFrame(this.show);
  },
};

const NotificationCenter = {
  name: 'notification-center',

  components: {
    NotificationBar,
  },

  template: `
    <div>
      <notification-bar
        v-for="notification in active"

        :message="notification.message"
        :type="notification.type"
        :dismissable="notification.dismissable"
        :timeout="notification.timeout"

        @cleared="clear">
      </notification-bar>
    </div>
  `,

  props: {
    queue: {
      type: Array,
      required: true,
    },
  },

  data() {
    return {
      active: [],
    };
  },

  computed: {
    hasActiveNotification() {
      return this.active.length > 0;
    },

    hasQueuedNotification() {
      return this.queue.length > 0;
    },
  },

  watch: {
    queue() {
      if (this.hasQueuedNotification && !this.hasActiveNotification) {
        this.setNextActive();
      }
    },
  },

  methods: {
    setNextActive() {
      this.setActive(this.queue.shift());
    },

    setActive(notification) {
      this.active.push(notification);
    },

    removeActive() {
      this.active.pop();
    },

    clear() {
      this.active.pop();

      if (this.hasQueuedNotification) {
        this.$nextTick(this.setNextActive);
      }
    },
  },
};

window.vm = new Vue({
  components: {
    NotificationCenter,
  },

  el: '#app',

  template: `
    <div>
      <notification-center
        :queue="notifications">
      </notification-center>

      <label>
        <strong>Type</strong> <br>
        Error <input v-model="type" type="radio" name="type" value="error"> <br>
        Warning <input v-model="type" type="radio" name="type" value="warning"> <br>
        Info <input v-model="type" type="radio" name="type" value="info"> <br>
      </label>

      <label>
        <strong>Message</strong>
        <input v-model="message" type="text">
      </label>

      <label>
        <strong>Dismissable</strong>
        <input v-model="dismissable" type="checkbox">
      </label>

      <label>
        <strong>Timeout</strong>
        <input v-model="timeout" type="number" step="100" min="0">
      </label>

      <button @click="generateNotification">Generate notification</button>
    </div>
  `,

  data: {
    notifications: [],
    
    type: null,
    message: null,
    dismissable: null,
    timeout: null,
  },
  
  methods: {
    generateNotification() {
      const {
        type,
        message,
        dismissable,
        timeout,
      } = this;
      
      this.notifications.push({
        type,
        message,
        dismissable,
        timeout,
      });
      
      //this.type = this.message = this.dismissable = this.timeout = null;
    },
  },
});
.notification-bar {
  box-sizing: border-box;
  position: absolute;
  top: -3.2rem;
  right: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3.2rem;
  color: #fff;
  font-family: 'Avenir Next', sans-serif;
  font-size: 1.2em;
  line-height: 3.2rem;
  text-align: center;
  transition: top 266ms ease;
}
.notification-bar--error {
  background-color: #f02a4d;
}
.notification-bar--warning {
  background-color: #ffc107;
}
.notification-bar--info {
  background-color: #2196f3;
}
.notification-bar--visible {
  top: 0;
}

label {
  display: block;
  margin: 2rem 0;
  font-size: 1.4rem;
}
label:first-of-type {
  margin-top: 5rem;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>
    <style>
      html {
        font-size: 62.5%;
      }
      
      body {
        font-family: sans-serif;
      }
    </style>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.0.3/vue.js"></script>
  </body>
</html>

Edit

You have to put some validation in setActive function, as if you push empty item in active, some validations you have are failing.

  setActive(notification) {
    if(notification.message){
    this.active.push(notification);
    }
  },

check this fiddle.

Upvotes: 0

Related Questions