CSS Pipeline @ Housing.com
Part 3: Experimenting with Atomic CSS using @linaria/atomic
This article is third in a series of articles related to CSS Pipeline at Housing.com. We recommend reading the previous articles before proceeding further. Part 1, Part 2
Linaria supports atomic css through @linaria/atomic
. We considered moving to atomic css due to the following benefits:
- style deduplication leads to plateauing of css bundles gradually.
- Linaria is able to handle a lot of issues created due to “declaration order precedence” in css [CSS specificity & CSS cascading related issues].
What’s the issue with declaration order precedence in css?
import {cx, css} from '@linaria/atomic'
const red = css`color: red;`
const blue = css`color: blue;`
export default () => <div className={cx(blue, red)}>Hello World</div>
In the above example if we used @linaria/core
instead of @linaria/atomic
, hello world
would appear blue
, and not red
. Users of CSS-in-JS libraries might expect to see red
color [because red
color is applied after blue
color]. But css works according to declaration order precedence. The style which was declared later would override the styles declared before it, no matter the order of application of classnames on the element.
How does @linaria/atomic
handle this
To understand that, lets see how linaria transforms the above example:
import {cx, css} from '@linaria/atomic'
import 'linaria.css' // added by linaria
const red = 'atm_cl_sdfs4g' // modified by linaria
const blue = 'atm_cl_sdfr33' // modified by linaria
export default () => <div className={cx(blue, red)}>Hello World</div>
// linaria.css - created by linaria
.atm_cl_sdfs4g {color: red;}
.atm_cl_sdfr33 {color: blue;}
Notice the following:
- Linaria created a css file, and added an import for it in our source file.
- It modified the
css
tagged template literals and replaced them with strings. These strings represent the generated classnames which can be found in thelinaria.css
file
Notice the value of these classnames: atm_cl_sdfs4g
and atm_cl_sdfr33
. These classnames follow a pattern
- Constant identifier:
atm_
a common prefix to identify that these classnames are atomic classnames propertySlug
identifier:cl_
. Herecl
represents a propertySlug [i.e. its a 2 letter representation for ‘color’]valueSlug
identifier: an alphanumeric string of6–8
characters. This string valueSlug [i.e.sdfs4g
representsred
,sdfr33
representsblue
]
Why is this relevant?
The trick lies in the combination of these specific classnames and the usage of the cx function
When we call cx
function cx(‘atm_cl_sdfr33’, ‘atm_cl_sdfs4g’)
, it returns atm_cl_sdfs4g
, thus only one class would be applied to the element, and hence no matter the declaration order of the css color: red
would be applied.
Why does cx
function remove a classname?
It knows that both these classnames target the same css property [in this case color
]. Which is redundant, hence it only keeps the last item in the list [honouring usage-order-precedence]. It was the propertySlug
[cl
section] in both the classnames due to which cx
could identify that both these classnames target the the css property.
That’s pretty simple, yet awesome!
What itched our mind was the fact that these classnames are very long, and might increase our HTML sizes significantly. Therefore, we compared @linaria/atomic
with Atlassian’s compiled.
Let’s see how Linaria/atomic & Atlassian’s compiled generate classNames
It’s a complex process, therefore for the sake of simplicity let’s consider this example:
font-size: 12px;
The property value:
For creating a hash of the property value both linaria and compiled use an algorithm called murmur
hash [converting the result to base 36].
The resultant value for 12px
is 1fwxnve
. Compiled trims this value to a fixed 4 character string, while linaria doesn’t.
Therefore, compiled will transform this 12px
to 1fwx
while linaria will use 1fwxnve
.
The property key:
Compiled uses the same murmur
hash for property key as well. Therefore, font-size
is tranformed to 14w90va
, and then it trims it to 4 characters, and hence the final value becomes: 14w9
Linaria on the other hand does the following:
- It uses a list of known-css-properties, which is an array. It finds the
index
of the property-key to be hashed in this array. - If found, it converts that
index
tobase 36
string. Which returns a string of 1–2 characters. - If the property is not found, then it also generates
murmur
hash for the property-key and uses it aspropertySlug
Final ClassName logic:
// For the sake of our example lets consider this is the set of
// known css properties
const knownCssProperties = [
'font',
'background',
'height',
'width',
'font-size',
'background-size',
'border',
'border-width',
'border-color',
'background-image',
'color'
]
// lets see how `font-size: 12px` is transformed by linaria & compiled
value to be transformed Linaria Compiled
12px 1fwxnve 1fwx
// linaria found index of font-size to be `4`, hence propertySlug became `04`
font-size 04 14w9
// linaria uses `atm_` as prefix and compiled uses `underscore` as a prefix
font-size: 12px atm_04_1fwxnve _14w91fwx
// linaria didn't find object-fit in list of known-css-properties
object-fit rggctn rggc
contain 1f51e7f 1f51
object-fit: contain atm_rggctn_1f51e7f _rggc1f51
From the above example we deduce the following:
- classnames generated by linaria are longer, and don’t have a fixed length. Linaria’s cx function does deduplication on basis of
propertySlug
by assuming the classname pattern to beatm_[propertySlug]_[valueSlug]
- classnames generated by compiled are smaller and of fixed length [i.e. 9]. Compiled does similar deduplication on basis of
propertySlug
. But in this case the classname pattern is_[propertySlug:4][valueSlug:4]
Scope for Improvement
We thought we could transform to shorter classnames. Let’s take the same inputs as shown in the example above to understand our approach.
// lets see how
// font-size: 12px;
// is transformed by linaria and compiled
value to be transformed Linaria Compiled Custom Implementation
12px 1fwxnve 1fwx 1fwx
// found in known-css-properties list
font-size 04 14w9 04
font-size: 12px atm_04_1fwxnve _14w91fwx _041fwx
// not found in known-css-properties list
object-fit rggctn rggc rggc
contain 1f51e7f 1f51 1f51
object-fit: contain atm_rggctn_1f51e7f _rggc1f51 _rggc1f51
We did the following changes:
- if property-key was found in the
known-css-properties
list we used theindex
, converted it tobase 36
, padded it with zero’s to either get a 2 or 4 character string. Example: ifindex
was1
propertySlug
would become:01
, but ifindex
was12356
it would become09j8
[i.e. (12356).toString(36).padStart(4, ‘0’)]
- if property-key couldn’t be found in
known-css-properties
, we would just slice the murmur hash to first four characters - For
valueSlug
we always sliced the murmur hash to first 4 characters - we used
underscore
as a prefix
This way our generated classnames would be either 9 or 7 characters in length. If 9
is the length then classname.slice(1, 5)
would be the propertySlug
, else className.slice(1, 3)
would be the propertySlug
And then the cx
function can be changed to work accordingly.
How do we achieve this?
A. Submit a PR
We are considering this, but we are not sure if they would accept such a PR. Reasons?
underscore
is a very generic prefix. While we are sure in our projectunderscore
is not used for any other classname generated by any other source, the same might not be true for all projects worldwide, hence linaria team might not be able to generalise this. Read this thread- Trimming the
murmur
hash output might increase the risk of conflicts. Compiled was taking the risk, we took the same.linaria
team might not do so. We however, created apostcss-plugin
, to inform us at build time if any such conflicts arise, so we can switch back to the originallinaria
implementation
B. Find a VERY BAD Hack
Caution: Our solution is a hack. We don’t recommend this to anyone, unless they are sure about what they are doing!
Let’s get back to what linaria
does
// myComponent.js - input
import {css} from '@linaria/atomic'
const myStyle = css`font-size: 12px;`
// myComponent.js - output
import 'myComponent.linaria.css'
const myStyle = 'atm_04_1fwxnve'
// myComponent.linaria.css - output [generated by linaria]
.atm_04_1fwxnve {font-size: 12px;}
There are two places where this className
would appear. one is the source js file, where the css tagged template literal is replaced by this string, other is the generated css file.
We did the following:
- wrote a postcss plugin which will iterate over all css rules, find all the selectors starting with
atm_
and transform them like so:atm_04_1fwxnve
becomes_041fwx
andatm_rggctn_1f51e7f
becomes_rggc1f51
- we wrote a babel plugin, which wraps all the
css
tagged template literals into adummy
function call, beforelinaria
gets a chance to work on them, and oncelinaria
is done this babel plugin finds all the string literals inside that function call which start withatm_
and converts them in a similar manner. And once this is done this babel plugin removes the dummy function call. Therefore, constmyStyle = ‘atm_04_1fwxnve’
becomesmyStyle = ‘_041fwx'
and so on.
We are not sharing the babel and postcss plugins here, because we feel this hack though works well, isn’t recommended. We are just sharing this article for sake for curiousness and general awareness.
What was left now was changing the usage of import {cx} from ‘@linaria/atomic’
to import {cx} from ‘@housing/utils/cx’
. Which we did with a simple codemod.
And we thought we were done. Well, not yet.
The Path of hacks is not easy, and comes with it’s costs
Even after so much we stumbled upon a problem.
There’s another package that we used like so:
import {styled} from ‘@linaria/atomic’
This is a styled components equivalent of linaria, it also supports atomic css, and a few other things, which is why we were using this as well.
Unfortunately for us, this was using the cx
function from @linaria/core
internally. That’s when we completely submitted to pure evil.
We are ashamed to do so! Please don’t commit such evil ever.
We wrote a webpack-loader for @linaria/core
// @housing/build/webpack/loaders/patch-linaria-core.js
module.exports = function () {
return `
export {css} from './css'
export {cx} from '@housing/utils/cx'
`
}
// this is configured as a loader in webpack config like so:
{
test: /@linaria\/core/,
use: [
'@housing/build/webpack/loaders/patch-linaria-core',
]
}
So now within our codebase [including any node_modules we have installed] whenever someone tries to include import {cx} from ‘@linaria/core’
. They’ll effectively be getting @housing/utils/cx
Problem solved! We successfully patched linaria, and created shorter classnames. We are still testing this to see if such an abomination is actually worth all this.
Further things we are exploring:
Part A:
As mentioned in previous post, we used postcss-media-query-filter
plugin to remove desktop specific styles from our mobile builds. But unfortunately their, generated classnames are still present in our are JS files and are applied to the DOM as well. Since, their corresponding styles are already filtered out, applying these classnames to DOM has no impact, but they are increasing our HTML sizes. We are trying to figure out if we can fix this.
Part B:
Consider the following case:
const style1 = css`
font-size: 12px;
color: red;
font-size: 13px;
`
// sample output: const style1 = 'atm_fs_0012px atm_cl_000red atm_fs_0013px'
const style2 = css`
color: green;
font-size: 14px;
`
// sample output: const style2 = 'atm_cl_0green atm_fs_0014px'
const style3 = css`
color: blue;
`
// sample output: const style3 = 'atm_cl_00blue'
const style4 = css`
font-size: 15px;
`
// sample output: const style4 = 'atm_fs_0015px'
export default ({addStyle4, overrideStyle}) => (
<>
<div className={cx(style1, style2, style3)}>
<span className={cx(style2, style3, addStyle4 && style4, overrideStyle)}></span>
</div>
</>
)
In this case the following happen:
- the styles applied on
div
will always be‘atm_fs_0014px atm_cl_00blue’
. Therefore, can’t we modifycx(style1, style2, style3)
to‘atm_fs_0014px atm_cl_00blue’
at build time itself. It will save the runtime call tocx
function as well [which can be costly if the page layout is huge and re-renders frequently] - But the same isn’t true for the
span
sincestyle4
and andoverrideStyle
are dependent on runtime conditions. Same build time optimisations therefore, can’t happen onspan
. But still it should be possible to transformcx(style2, style3, addStyle4 && style4, overrideStyle)
tocx('atm_fs_0014px atm_cl_00blue', addStyle4 && 'atm_fs_0015px', overrideStyle)
These are very minor optimisations, but we saw these patterns at many places in our codebase, hence we thought we’ll give them a shot. But alas, we haven’t yet been successful with PART A. For Part B however, refer our fifth article in this series.
That’s all folks! Please do share your valuable feedback & keep following this space for more such content.
This is a third in the series of articles on our CSS Pipeline.
Check out the other articles of this series: Part 1, Part 2, Part 4, Part 5
Once again shout out to the ones who helped achieve this: Dhruvil Patel, Naman Saini, Nikhil Verma, Sachin Tyagi