Promisifying CSS animation with timeout fallback
Tags: css, javascript
Published: 2022-03-10 01:15:46

So I came across this interesting problem dealing with state updates and animations. I wanted to create a utility function that would execute a state update after an animation had finished but with a timeout fallback in case a developer forgot to animate the element.

This is useful for React applications where you want to wait for a slide-out animation for your popup to complete before updating your state, causing your popup to be removed from the DOM.

Let's start with the first two problems
1) We need an event listener to listen for the CSS tranistionend event that is fired from the animated element. When it is triggered, call some function `fn`.
2) We need a timeout function as a fallback. When the timeout triggers, call some function `fn`

```typescript
// 1
const addTransitionEndListener = (element: Element) => (fn: () => void) => {
  element.addEventListener("transitionend", fn);
};

// 2
const addTimeOut = (duration: number) => (fn: () => void) => {
  setTimeout(fn, duration);
};
```

Next, we want to promisify these two functions inside `Promise.race` and apply our state update when either one of these two promises is settled. We will also add some convenience types.

```typescript
// 1
type Effect = () => void;
type CallbackFn = (f: Effect) => void;

// 2
const promisify = (fn: CallbackFn) =>
  new Promise((resolve, reject) => {
    fn(() => {
      resolve(null);
    });
  });

// 3
const addTransitionEndListenerP = promisify(
  addTransitionEndListener(someElement)
);
const addTimeOutP = promisify(addTimeOut(someDuration));

// 4
Promise.race([addTransitionEndListenerP, addTimeOutP]).finally(() =>
  console.log("state update goes here")
);
```
There's a lot to digest here so let's go through it section by section.

1. `Effect` represents a function with no return value. `CallbackFn` expects an `Effect` function as an argument and does not return anything. Both `addTransitionEndListener` and `addTimeOut` are of type `CallbackFn` after the first argument.
2. The `promisify` function expects a `CallbackFn` and returns a Promise. We are passing in `resolve(null)` as the `Effect` fn as we do not care about returning anything from our Promise for now.
3. Using `promisify`, we turn `addTransitionEndListener` and `addTimeOut` into something that returns a Promise.
4. We use `Promise.race` and `finally` to perform our mock state update regardless of whether the `transitionend` event first first or `someDuration` passes first. `someDuration` and `someElement` are defined outside of here.

After wiring all this up, we now have another problem. How do we remove the event listener or clear the timeout once either event happens? Keep in mind that `removeEventListener`requires a reference to the same function that was passed in to `addEventListener` and `clearTimeout`requires the interval id which is only returned when `setTimeout` is called.

To solve this, let's change the type of `CallbackFn` to return a `CleanupFn` which is a type alias for `Effect`.
```typescript
type CleanupFn = Effect;
type CallbackFn = (f: Effect) => CleanupFn;
```

Next we will need to change `addTransitionEndListener` and `addTimeOut` to return a CleanupFn.

```typescript
const addTransitionEndListener = (element: Element) => (
  fn: () => void
): CleanupFn => {
  element.addEventListener("transitionend", fn);
  return () => {
    element.removeEventListener("transitionend", fn);
  };
};

const addTimeOut = (duration: number) => (fn: () => void): CleanupFn => {
  const timeoutID = setTimeout(fn, duration);
  return () => clearTimeout(timeoutID);
};
```

We will also need to change promisify to return a pair of a Promise and a Cleanup function. We will then need to provide another function that wraps `Promise.race` so that it calls all the cleanup functions in the finally block.

```typescript
type PromiseCleanup = [Promise<unknown>, CleanupFn];

const promisify = (fn: CallbackFn) => {
  let cleanupFn: Effect | undefined;
  return [new Promise((resolve, reject) => {
    cleanupFn = fn(() => {
      resolve(null);
    });
  }), cleanupFn as Effect]
};

export const promisifyRace = (
  promiseCleanups: PromiseCleanup[]
): Promise<unknown> => {
  const promises = promiseCleanups.map(([promise, _]) => promise);
  const cleanups = promiseCleanups.map(([, cleanup]) => cleanup);

  return Promise.race(promises).finally(() => {
    cleanups.forEach((cleanup) => cleanup());
  });
};
```

Now we are free to use it like so:
```typescript
 promisifyRace([transitionP(), durationP()])
    .finally(stateUpdate)
```
Github link: https://github.com/wibily/promisifyCleanup

Codesandbox link:
https://codesandbox.io/s/animationend-fiddle-o2scuh?file=/src/index.ts

Note: codesandbox link builds on top of the idea above by passing in both the resolve, reject Promise executor to the promisify function allowing the caller to choose whether they want to resolve or reject the Promise.

### TLDR;
1. Convert your asynchronous side-effect functions to return a pair of a Promise and a cleanup function
2. As we don't have Promise.cancel, create your own Promise.race/any/all implementation that runs all the cleanup functions in the finally block 

Aside:
TIL that there is a difference between animationend and transitionend. [Thank you Jessica](https://stackoverflow.com/a/41530600/6036932)