Velo by Wix: Promise Queue

In this post, we look at concurrent hide/show animation behavior in Velo APIs. When one of the animation effects can't start because a previous one isn't finished yet.

find hand promise

What is the issue?

Let's suppose that we have to animate an image by mouse event. For example, we want to create a hover effect that combines mouse in/out events.

Velo: hide/show animation by mouse event

Our realization will be very trivial. We have an image that has two event listeners on onMouse{In/Out} and a vector image that will be shown or hidden.

It's the next code snippet:

Velo: Add simple fade animation with a duration of 300 milliseconds
$w.onReady(() => {
  const fadeOptions = {
    duration: 300,
  };

  $w('#imageParrot')
    .onMouseIn(() => {
      $w('#vectorHat').show('fade', fadeOptions);
    })
    .onMouseOut(() => {
      $w('#vectorHat').hide('fade', fadeOptions);
    });
});

As you can see above, the animation has a duration of 300 ms. What happens if we move the cursor through in/out the image faster than 300 ms?

Velo: Glitch with show/hide animation

Yes, there is an issue. The next in the queue animation doesn't run if the previous one is going at the moment.

Why does it happen?

Let's visualize a timeline of the animation's execution. For example, we have three events, in -> out -> in. The first animation starts at 0 it will be finishing at 300 ms. The second animation starts at 200 ms, but it will be skip because this element is animating at this moment. The third one starts at 300 ms that will successfully run because the first one has finished, the second one skipped so the element can animate again.

Visualization of mouse events in a timeline
0 ms ── 100 ms ── 200 ms ── 300 ms ── 400 ms ── 500 ms ── 600 ms ── 700 ms
────────────────────────────────────────────────────────────────────────────

 .show() ───────────────────│ ✅ Done
                            │
              .̶h̶i̶d̶e̶(̶)̶ ......│ ❌ 🪲 Skipped
                            │
                            │ .show() ───────────────────│ ✅ Done

The Wix elements can't be animating with two concurrent (hide/show) animations on the same element at one time.

How can we fix it?

We have to wait for the animation's end before running a new one. For this, we create a queue. We push each animation request to this queue, where animations will be calling one by one.

Visualization of the Promise queue in a timeline
0 ms ── 100 ms ── 200 ms ── 300 ms ── 400 ms ── 500 ms ── 600 ms ── 700 ms
────────────────────────────────────────────────────────────────────────────

 .show() ───────────────────│ ✅ Done
                            │
              .hide() ......└────────────────────│ ✅ Done
                                                 │
                             .show() ............└────────────────────│ ✅ Done

Create a queue

Create a queue.js file in the public folder. In this file, we implement the queue logic.

First, we implement a mechanism for adding actions to the queue.

public/queue.js

export const createQueue = () => {
  // Action list
  const actions = [];

  return (action) => {
    // Adds action to the end of the list
    actions.push(action);
  };
};

Тurn your attention we don't run animation when mouse event fired. Instead, we wrap it to function and push it to the array.

Let's upgrade the code on the page to see how it works.

HOME Page (code)

import { createQueue } from 'public/queue.js';

$w.onReady(() => {
  // Initializing queue
  const queue = createQueue();

  const fadeOptions = {
    duration: 300,
  };

  $w('#imageParrot')
    .onMouseIn(() => {
      // Add actions to queue
      queue(() => $w('#vectorHat').show('fade', fadeOptions));
    })
    .onMouseOut(() => {
      // Add actions to queue
      queue(() => $w('#vectorHat').hide('fade', fadeOptions));
    });
});

Great, we have a list of actions. The next step, run the queue.

It will be an auxiliary function for the queue start.

public/queue.js

export const createQueue = () => {
  const actions = [];

  const runQueue = () => {
    // Check: are we have any actions in queue
    if (actions.length > 0) {
      // Removes the first action from the queue
      // and returns that removed action
      const action = actions.shift();
      // Waits the promise
      action().then(() => {
        // When the Promise resolves
        // then it runs the queue to the next action
        runQueue();
      });
    }
  };

  return (action) => {
    actions.push(action);
    // Runs the queue when adding a new action
    runQueue();
  };
};

The runQueue() is the recursive function it runs itself after the promise has been resolved. Also, we trigger runQueue() by adding a new action. We have to limit the trigger it should run only once at the queue start.

Further, we add the flag for closing the runQueue() if the queue is active.

public/queue.js

export const createQueue = () => {
  // Flag
  let isActive = false;

  const actions = [];

  const runQueue = () => {
    // Check: if the queue is running
    if (isActive) {
      // Stop this call
      return;
    }

    if (actions.length > 0) {
      const action = actions.shift();
      // Before: closes the queue
      isActive = true;

      action().then(() => {
        // After: opens the queue
        isActive = false;
        runQueue();
      });
    }
  };

  return (action) => {
    actions.push(action);
    runQueue();
  };
};

When a new action is adding to the list, we check the queue is active. If the queue is not active, we run it. If the queue is active, we do nothing.

Queue length

The last thing we need is control of the queue length. We can create a lot of animation actions that could lead to a blink effect.

Velo: blink effect by a long Promise queue

The algorithm is simple. If the queue has a max length then we remove the last action before adding a new one.

Visualization of removing queue actions in a timeline
0 ms ── 100 ms ── 200 ms ── 300 ms ── 400 ms ── 500 ms ── 600 ms ── 700 ms
────────────────────────────────────────────────────────────────────────────

 .show() ───────────────────│ ✅ Done
                            │
      .̶h̶i̶d̶e̶(̶)̶ ..............│ 🗑️ Removed
                            │
          .̶s̶h̶o̶w̶(̶)̶ ..........│ 🗑️ Removed
                            │
                .hide() ....└────────────────────│ ✅ Done

Let's set a max length by default as one. I think it covered 99% of use cases.

public/queue.js

// By default, the queue has one action
export const createQueue = (maxLength = 1) => {
  let isActive = false;

  const actions = [];

  const runQueue = () => {};

  return (action) => {
    // Check: if the queue has max length
    if (actions.length >= maxLength) {
      // Removes the last action from the queue
      // before adds a new one
      actions.pop();
    }

    actions.push(action);
    runQueue();
  };
};

Check how it works on Live Demo

That's it!. I hope it could be helpful to your projects. Thanks for reading.

Code Snippets

Here are the whole code snippet plus JSDoc types.

public/queue.js
/**
 * Create a promise queue
 *
 * @typedef {() => Promise<unknown>} Action
 *
 * @param {number} [maxLength] - max count actions in the queue
 * @returns {(action: Action) => void}
 */
export const createQueue = (maxLength = 1) => {
  /** @type {boolean} */
  let isActive = false;

  /** @type {Action[]} */
  const actions = [];

  const runQueue = () => {
    if (isActive) {
      return;
    }

    if (actions.length > 0) {
      const action = actions.shift();

      isActive = true;

      action().then(() => {
        isActive = false;
        runQueue();
      });
    }
  };

  return (action) => {
    if (actions.length >= maxLength) {
      actions.pop();
    }

    actions.push(action);
    runQueue();
  };
};

Example of using:

HOME Page (code)
import { createQueue } from 'public/queue.js';

$w.onReady(() => {
  const queue = createQueue();

  const fadeOptions = {
    duration: 300,
  };

  $w('#imageParrot')
    .onMouseIn(() => {
      queue(() => $w('#vectorHat').show('fade', fadeOptions));
    })
    .onMouseOut(() => {
      queue(() => $w('#vectorHat').hide('fade', fadeOptions));
    });
});

Resources

Posts