CSS Pipeline @ Housing.com

sachin agrawal
Engineering @ Housing/Proptiger/Makaan
9 min readMar 16, 2023

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:

  1. style deduplication leads to plateauing of css bundles gradually.
  2. 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:

  1. Linaria created a css file, and added an import for it in our source file.
  2. It modified the css tagged template literals and replaced them with strings. These strings represent the generated classnames which can be found in the linaria.css file

Notice the value of these classnames: atm_cl_sdfs4g and atm_cl_sdfr33 . These classnames follow a pattern

  1. Constant identifier: atm_ a common prefix to identify that these classnames are atomic classnames
  2. propertySlug identifier: cl_. Here cl represents a propertySlug [i.e. its a 2 letter representation for ‘color’]
  3. valueSlug identifier: an alphanumeric string of 6–8 characters. This string valueSlug [i.e. sdfs4g represents red, sdfr33 represents blue]

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:

  1. 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.
  2. If found, it converts that index to base 36 string. Which returns a string of 1–2 characters.
  3. If the property is not found, then it also generates murmur hash for the property-key and uses it as propertySlug

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:

  1. 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 be atm_[propertySlug]_[valueSlug]
  2. 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:

  1. if property-key was found in the known-css-properties list we used the index, converted it to base 36, padded it with zero’s to either get a 2 or 4 character string. Example: if index was 1 propertySlug would become: 01, but if index was 12356 it would become 09j8 [i.e. (12356).toString(36).padStart(4, ‘0’)]
  2. if property-key couldn’t be found in known-css-properties, we would just slice the murmur hash to first four characters
  3. For valueSlug we always sliced the murmur hash to first 4 characters
  4. 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?

  1. underscore is a very generic prefix. While we are sure in our project underscore 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
  2. 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 a postcss-plugin, to inform us at build time if any such conflicts arise, so we can switch back to the original linaria 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:

  1. 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 and atm_rggctn_1f51e7f becomes _rggc1f51
  2. we wrote a babel plugin, which wraps all the css tagged template literals into a dummy function call, before linaria gets a chance to work on them, and once linaria is done this babel plugin finds all the string literals inside that function call which start with atm_ and converts them in a similar manner. And once this is done this babel plugin removes the dummy function call. Therefore, const myStyle = ‘atm_04_1fwxnve’ becomes myStyle = ‘_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:

  1. the styles applied on div will always be ‘atm_fs_0014px atm_cl_00blue’ . Therefore, can’t we modify cx(style1, style2, style3) to ‘atm_fs_0014px atm_cl_00blue’ at build time itself. It will save the runtime call to cx function as well [which can be costly if the page layout is huge and re-renders frequently]
  2. But the same isn’t true for the span since style4 and and overrideStyle are dependent on runtime conditions. Same build time optimisations therefore, can’t happen on span. But still it should be possible to transform cx(style2, style3, addStyle4 && style4, overrideStyle) to cx('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

--

--