Back

Introducing Fela 12

7min • 23 February 2022


Since React introduced hooks(new tab) in 2018, Fela hasn't changed that much in the last 3 years. We've been on version 11 since November 2019(new tab) and most APIs have been solid and stable for several years.

Instead of adding any new features, recent updates rather focused on rendering performance and optimisations. Today, I'm happy to announce Fela 12. It ships many of these improvements by default now!

In this post, I want to highlight some of the improvements and explain how they work under the hood.

Performance Optimisations

With version 11.5.0, we first shipped a new optimizeCaching feature flag(new tab) which allowed users to opt into performance improvements without being forced to use it in production yet. That way I was able to test it in many different applications and get a lot of feedback.

Some results from the latest performance benchmarks comparing Fela to other popular libraries.
Running on Chrome 98 with a new M1 MacBook Pro from 2021.
These tests include all plugins from fela-preset-web(new tab).

Disclaimer: While I do care a lot about performance, these numbers should support that modern libraries are capable of extremely fast renders. Out of these 5.06ms in the first test, roughly 3.80ms are React alone, so we're really only speaking about ~1ms for a deep tree render.
So, no matter what the exact numbers are, most of the CSS in JS libraries today are just fine to use. This is especially true when SSR is used where the styles are generated on the server and injected into the markup that is send to the client.

The way the optimisation works has a lot to do with plugins and how they run:
Generally speaking, every plugin is a function of style that returns a new style(new tab).
It also provides some more arguments(new tab), but we can ignore them for now.
For example, the prefixer(new tab) plugin takes the passed style object and uses a third-party prefixer(new tab) package to add vendor prefixes to every style declaration when needed. This happens on every render since plugins, by default, are not context-free. They can depend on props or renderer configuration which can change throughout your application lifetime.
It's only after the style object has been processed by plugins, that we actually compare the output with our internal cache to skip DOM rendering.

This architecture is pretty straightforward, but comes with a major flaw:
Iterating big objects over and over again, basically on every render, comes with a performance cost. Especially if we chain multiple plugins and render a huge tree, this becomes more impactful.

Note: We're talking about thousands of operations within milliseconds, so I doubt that this will actually affect real-world performance in a significant way, but faster is faster no matter what.

Context-Free Plugins

While we can't apply these optimisations to all plugins, we can do it to some of them. Specifically prefixer(new tab), fallback-value(new tab) and unit(new tab). Thus, 3 of the most used plugins within the Fela ecosystem.
I call them context-free plugins as they do not depend on any outer properties and operate on single style declarations only.

To give an example, let's consider the rtl(new tab) plugin for a second. It depends on the theme.direction that is passed via React context. So paddingLeft might become paddingRight depending on the context the style is rendered in. That means we can't optimise this plugin since it is not context-free.
The unit(new tab) plugin on the other hand will always add a unit e.g. px to number values unless it is a unitless property. It is a pure plugin as we can predict it's outcome, no matter in which context it is rendered in.

Note: It is important not to confuse context-free plugins with plugins that accept configuration. We can configure the unit(new tab) plugin to add e.g. em instead of px, so the outcome is different. But, this happens on a global level and we can predict it given the passed configuration.

So, how does this help us improving performance?
Given the predictable outcome of these 3 plugins, we don't have to run them every time we render a style, but only once before we actually cache a specific style declaration.

Plugin API

Context-free plugins also have a different API signature. Instead of passing the whole style object, type, renderer and props, we only get a single argument declaration which includes property and value.
We can mutate those values in order to process the style. For example, a simplified version of the unit(new tab) plugin might look like this:

// this is only an example! the actual plugin is more complex and advanced
// we have to cover unitless properties and also handle pseudo numbers and arrays
function unit(declaration) {
if (typeof declaration.value === 'number') {
declaration.value += 'px'
}
return declaration
}

Results

With these improvements enabled, a typical React application with Fela achieved 30-50% faster style rendering in my own experiments. This can, of course, differ from scenario to scenario, but in general I think this is quite an impressive improvement given that it only really applies to 3 plugins right now.

In order to be backwards-compatible, we right now only ship these plugins as an addition to the actual plugin. The Fela renderer(new tab) searches for a plugin.optimized(new tab) function in order to apply these optimisations.
In the future, we are planning to add even more advanced options to the plugin system so that we can further optimise the rendering performance.

Overall Performance

Next to plugins, we also improved performance in several other places. Most of those are critical snippets that are running a hundred if not a thousand times per application.
Combined with the plugin improvements, we achieved up to 100% faster rendering in real-world React applications and our own benchmarks.

Rehydration Improvements

Apart from the performance optimisations, Fela 12 also ships improvements to style rehydration(new tab).
It now covers more edge cases and also handles propertyPriority(new tab) correctly to make sure that shorthand and longhand properties(new tab) are rendered correctly on both server and client.
We also detect arbitrary third-party class names and correctly rehydrate them.

Bundle Size

Additionally, we were able to shave off some bytes here and there.
Most noticable, the prefixer(new tab) plugin went from 4.4kb to only 1.9kb (both minified & gzipped) by moving from my very own inline-style-prefixer to stylis(new tab).

UMD Bundles

Info: UMD is an abbreviation for Universal Module Definition(new tab).

Last but not least, I also improved the overall build structure to include UMD bundles of every package. The bundles follow the common naming convention of using pascal case(new tab).

So fela becomes Fela, fela-plugin-prefixer becomes FelaPluginPrefixer and so on. The same is true for externals that are required when using e.g. react-fela. One would have to have Fela and React available globally.

Monorepo Setup

Apart from improvements to Fela itself, we also completely overhauled the monorepo setup that powered Fela over the last 5 years.
It includes Turbo(new tab) and Pnpm(new tab), but I'm going to talk about that in more depth in a separate blog post that is coming soon.

What's Next?

There are two interesting challenges coming soon for Fela:

  1. One is all about further improving performance of plugins which more advanced plugin APIs that will allow processing styles in only one object loop.
  2. The other, way more complex challenge is support for React 18 including Server Components and Streaming Rendering. We've had approaches and ideas way back in 2017 already, but now that progressive rendering is becoming more and more popular and some frameworks already implement these features in experimental builds(new tab), we definitely don't want to miss out on support for these!

Credits

Thanks to all the wonderful contributors(new tab) that have helped make Fela as great as it is today.
Thanks to my friends and colleagues Bea(new tab) and Timon(new tab) for reviewing this article!

Changes

23/02/2022: Added a disclaimer and some more context to the benchmark results shown.

Thanks for reading!

I’m Robin, Freelancer & Frontend Architect from Germany.
Any question regarding this article? Reach out to me on Twitter(new tab)!
You can also find me on GitHub(new tab).