Highly inefficient invisible animations (CSS/Firefox/Chrome/React)

mortoray

edA‑qa mort‑ora‑y

Posted on January 16, 2021

Highly inefficient invisible animations (CSS/Firefox/Chrome/React)

The cursor in my text editor was lagging. It’s quite unusual given my 8 cores machine with 32GB of RAM. While tracking down that issue, I discovered that my escape game was consuming 20-30% of the CPU while idling. That’s bad! It turns out it was invisible elements being rotated via CSS.

It’s a bit of a pain. This means we need to remove all those elements which fade-away, otherwise they pile up and create load. Here I’ll show you my solution using React — the top-layers of my game are in React, that’s why I used it. I’m not suggesting you use React to solve this problem. But if you have animated HTML elements, get rid of them if they aren’t visible.

The Problem

While loading scenes, I display an indicator in the top-right corner of the screen.

This fades in when loading starts and fades out when loading is done. I wanted to avoid an abrupt transition. I handled this with CSS classes to hide and show the element. My React code looks like this:

    <SVGElement 
        url={url}
        className={RB.class_name("load-marker", className, is_loading && 'loading')}
    />
Enter fullscreen mode Exit fullscreen mode

SVGElement is my component to load SVG files and display them inline. An img tag will perform the same way for this setup. The key is the is_loading && ‘loading’ part of the className attribute. This adds the loading class name to the element while it’s loading. When finished loading, I remove the class name.

This is the CSS (SCSS):

.load-marker {
    &:not(.loading) {
        animation-name: fade-out;
        animation-fill-mode: forwards;
        animation-duration: 0.5s;
        animation-timing-function: ease-in-out;
    }
    &.loading {
        animation-fill-mode: forwards;
        animation-duration: 0.5s;
        animation-timing-function: ease-in-out;
        animation-name: fade-in;
    }
    @keyframes fade-out {
        from {
            opacity: 1;
            visibility: visible;
        }
        to {
            opacity: 0;
            visibility: collapse;
        }
    }
    @keyframes fade-in {
        from {
            opacity: 0;
            visibility: collapse;
        }
        to {
            opacity: 1;
            visibility: visible;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I have an urge to digress into a rant about CSS's animation system! I've written animation and layout systems before, and argh, this is acid thrown in my eyes. Indeed, that system has a clear adding and removing animation support, making this whole setup trivial. But this is CSS, and, alas…

When an item loses the .loading class it will transition to a transparent state. The problem however came from some other CSS:

.loader {
    svg {
        animation: rotation 6s infinite linear;
        overflow: visible;
        position: absolute;
        top: 20px;
        right: 20px;
        width: 70px;
        height: 70px;
    }
    @keyframes rotation {
        from {
            transform: rotate(0deg);
        }
        to {
            transform: rotate(360deg);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That infinite bit is the problem. It’s irrelevant that we’ve faded the opacity to 0, the animation is still running! Firefox still does a style and layout update, each frame. Why it ends up consuming so much CPU, I have no idea. Chrome also consumed CPU, but only around 10%. Note, 10% is still ridiculous for a static screen.

I could also “solve” the problem by not spinning the item unless something is loading. This creates a rough transition where the icon abruptly stops rotating while fading away. Not good.

The Solution

I have two animated indicators, the loader and a disconnected icon, for when you lose the WebSocket connection to the server. I abstracted a common base component to handle them the same. This is how I use it, for the loader:

export function Loader({ is_loading }) {
    return <HideLoader
        url={theme.marker_loading}
        is_loading={is_loading}
        className="loader"
    />
}
Enter fullscreen mode Exit fullscreen mode

This is the implementation:

function HideLoaderImpl({ is_loading, url, className }) {
    const [ timer_id, set_timer_id ] = React.useState(0)

    React.useEffect(() => {
        if( !is_loading && !timer_id ) {
            const css_duration = 1000
            const new_timer_id = setTimeout( () => set_timer_id(0), css_duration )
            set_timer_id(new_timer_id)
        }
    }, [is_loading]) // only trigger on an is_loading change

    const visible = is_loading || timer_id
    if(!visible) {
        return null
    }

    return (
        <SVGElement 
            url={url}
            className={RB.class_name("load-marker", className, is_loading && 'loading')}
        />
    )
}

const HideLoader = React.memo(HideLoaderImpl)
Enter fullscreen mode Exit fullscreen mode

At first glance, it’s not obvious how this achieves a delayed removal of the element. The HTML generation is clear, when visible is false, then display nothing. When true, display the element as before, with the same logic for setting the loading class name.

If is_loading is true, then visible will be true. This is the simple case. But there is the other true condition when we have a timer_id.

The setTimeout callback does nothing but clear the timer_id when it’s done. At first I suspected I’d have to track another variable, setting at the start and end of the timeout. It turns out that all I need to know is whether there is a timeout at all. So long as I have a timer, I know that I shouldn’t remove the element.

The condition list to React.useEffect is important here. I provide only is_loading — I only wish for the effect to run if the value of is_loading has changed. Some style guides will insist that I include timer_id (and set_timer_id) as well in the list. That approach defines the second argument to useEffect as a dependency list, but this is incorrect. It’s actually a list of values, which if changed, will trigger the effect to run again. The React documents are clear about this. Yet also say it’s a dependency list, and recommend a lint plugin that would complain about my code. That recommendation makes sense for useCallback and useMemo, but not for useEffect.

Adding timer_id to the list would be wrong. When the timer finishes, it sets the timer_id to 0. That change would cause the effect to trigger again. This is a case where we do “depend” on the timer_id value, but we shouldn’t re-execute when it changes, as that would end up creating create a new timer.

In any case, this simple code now does what I want. It defers the DOM removal of the element until after the end of the animation. Well, it defers it one second, which is long enough to cover the 0.5s CSS animation. It’s complicated to keep these times in sync — more fist shaking at the CSS animation system!

If you’ve got an eye for defects, there is one there. The loader icon can be removed too early: when is_loading becomes true, then false, then within one second becomes true and false again. I don’t create a new timer if one already exists, so the deferral time will still be from the first timer. In practice, this will not likely happen, and the impact is minimal. The fix is to cancel an existing timeout and always create a new one.

My lagging cursor

I never got an obvious answer why my cursor was lagging. There were all sorts of applications, idle applications, consuming 5-10% CPU. It’s perhaps a real cost of high-level languages. More on that another day. I still hope that future apps will strive for less energy use.

For now, remove all those invisible animated HTML elements.

💖 💪 🙅 🚩
mortoray
edA‑qa mort‑ora‑y

Posted on January 16, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related