Support Ukraine

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.

What is the issue?

Let's suppose that we have to animate an image by a 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 solution is quite simple. We have an image with two event listeners for onMouse{In/Out} events and a vector image that can 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 in/out of the image faster than 300 ms?

Velo: Glitch with show/hide animation

Yes, there is an issue. The next animation in the queue doesn't run if the previous one is ongoing 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 and will finish at 300 ms. The second animation starts at 200 ms but will be skipped because this element is animating at this moment. The third one starts at 300 ms, which 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

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 called 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 will implement the queue logic.

First, we need to 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);
  };
};

Turn your attention, we don't run animation when the mouse event is fired. Instead, we wrap it in a 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 is to run the queue.

It will be an auxiliary function to help start the queue.

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() function is recursive, running itself after the promise has been resolved. Additionally, we trigger runQueue() by adding a new action. To ensure it runs only once at the start of the queue, we must limit the trigger.

Furthermore, we have added a flag to close 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 added to the list, we check if 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 a control of the queue length. We can create a lot of animation actions that could lead to a blinking effect.

Velo: blink effect using a long Promise queue

The algorithm is simple. If the queue has a maximum 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 maximum length by default as one. I think it covers 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 adding 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 is the complete code snippet, including 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