CSS Pipeline @ Housing.com

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

Part 4: Limitations with MiniCssExtractPlugin, exploring alternatives. Leveraging Babel & React 18 stream injection during SSR

This article is fourth 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, Part 3

Problem Statement

MiniCssExtractPlugin is a wonderful tool. But even a good tool can lose its charm if not used properly. That’s what happened to us.

It works on chunk level not module level, when talking in webpack terms. [And it has it’s own benefits.]

Let’s take a moment to understand how things work. Consider following example.

// file1.js
// any css file generated by any means.
// in our case this file is generated by linaria
import "file1.linaria.css"

export default () => {
return (
// ... rendering logic ...
)
}


// file2.js
// some other css file
import "file2.linaria.css"

export default () => {
return (
// ... rendering logic ...
)
}

// file3.js
import Comp1 from './file1'
import Comp2 from './file2'

// similar components like Comp1, Comp2
import Comp3 from './file4'
import Comp4 from './file5'

import "file3.linaria.css"

export default ({someCondition, someOtherCondition}) => {
return (
<div className='styles from file3.linaria.css'>
// ... some rendering logic ...
{someCondition ? <Comp1 /> : <Comp2 />}
{someOtherCondition && <Comp3 />}
{anotherCondition && <Comp4 />}
</div>
)
}

Let’s understand module and chunk in webpack’s context

  1. Each file in our sourcecode will become a module in webpack. Example: file1.js, file2.js, file3.js, file4.js, file5.js. Even generated files which webpack will further process are modules. Example: file1.linaria.css, file2.linaria.css, file3.linaria.css.
  2. All of these source files will be put into a single JS file by webpack [if splitChunks or any such optimization doesn’t come into play]. That file is a chunk . Let’s call it ourJsChunk.js .

Several modules together form a chunk. Chunk loading is an async operation [i.e. a physical file is loaded from network], while module loading is a sync operation [just a function is called to return the exports of a particular module]. For more details on how module/chunk loading in webpack works refer this article.

Now lets say we do this:

// some other file
const File3Component = React.lazy(() => import('./file3'))

Here we are forcing file3 to be code-splitted [Along with all it’s dependencies] into a chunk [ourJsChunk.js] of its own.

MiniCssExtractPlugin works in a similar manner for CSS. In the above example it would create a [ourJsChunk.css] file.

What’s the problem with this?

As you can see we are conditionally rendering those components. But using MiniCssExtractPlugin, we’ll be downloading ourJsChunk.css which will have CSS for all the components included in this chunk. That means unused CSS being sent to the browser [CSS files are cacheable, therefore, this might not be a concern for some people].

For us, this was an even greater concern, since we wanted to inline styles [sort of critical styles] from our server, to optimise our critical rendering path. But if we inlined so many unused styles from our server, then it would be counter-intuitive, because CSS parsing and CSSOM construction time would increase, and CSS being render blocking would then delay our First Contentful Paint.

That’s where critical css comes into picture. Runtime CSS-in-JS libraries are very good at extracting critical-css. But doing that with compile time CSS-in-JS [we use linaria, which also provides a utility to extract critical css] libraries might impact time to first byte & is effectively not possible with react’s renderToPipeableStream.

So if this is a generic problem with MiniCssExtractPlugin, won’t there be a generic solution.

Well turns out there is: Code Splitting

If we changed our file3.js like so:

// file3.js
// changing normal imports to dynamic imports
const Comp1 = React.lazy(() => import('./file1'))
const Comp2 = React.lazy(() => import('./file2'))

// similar components like Comp1, Comp2
const Comp3 = React.lazy(() => import('./file4'))
const Comp4 = React.lazy(() => import('./file5'))


import "file3.linaria.css"

export default ({someCondition, someOtherCondition}) => {
return (
<div className='styles from file3.linaria.css'>
// ... some rendering logic ...
{someCondition ? <Comp1 /> : <Comp2 />}
{someOtherCondition && <Comp3 />}
{anotherCondition && <Comp4 />}
</div>
)
}

By changing the normal static imports to dynamic imports, we can easily force MiniCssExtractPlugin to form CSS chunks corresponding to our JS chunks, and hence the CSS would also be code split. Therefore, until each component is actually rendered to DOM, CSS won’t be downloaded for it.

Pheww!! problem solved. Well yes, technically. But what we observed was in a fast paced development environment, people tend to forget dynamic imports. And at few places it just isn’t possible to do dynamic imports. While that isn’t something we promote, since not using dynamic imports will also hamper our JS performance significantly. But, what’s more problematic is the fact that a lot of unused CSS will directly impact our critical rendering path. And hence, we wanted to find a solution, which works irrespective of code-splitting.

Therefore, our solution must do the following:

  1. take us as close to critical css as possible with compile time css. Here it meant component level css. i.e. the CSS for only those components which are rendered.
  2. be as invisible to our developers as possible, to avoid any mistake which might impact our performance.
  3. works with both SSR & CSR.
  4. Be flexible, to work with inline style tags and with link tags
  5. Be able to handle any type of CSS file imported. Be it generated by tools like linaria, or any third party CSS file imported directly by a developer [in our case we used things like leaflet]

We built an API similar to isomorphic-style-loader. It’s a utility which we had worked with in the past [some 5 years earlier]. Which basically works with css-modules and works by wrapping the component in a HOC. That HOC encapsulates the styles with the component and is responsible for loading styles. Let’s see how it works

import React from 'react'
// the HOC which we were talking about
import withStyles from 'isomorphic-style-loader/withStyles'
// using css-modules here => therefore, this "s" will be a JS object
// which holds all the classnames declared in this scss file
import s from './App.scss'

function App(props, context) {
return (
<div className={s.root}>
<h1 className={s.title}>Hello, world!</h1>
</div>
)
}

// this line here does the magic, it wraps App with styles `s`.
// Whenever this default export is used as a react component
// withStyles will add the styles to the DOM/server rendered HTML
export default withStyles(s)(App)

We needed something similar. Let’s see a code sample:

// utitilyStyle.jsx
import {css} from '@linaria/core'

export const util1 = css`font-size: 11px;`

// componentStyle.jsx
import {css} from '@linaria/core'

export const style1 = css`font-size: 12px;`

// unused export
export const style2 = css`color: red;`
// simpleComponent.jsx

// some styles imported from other file(s)
import {style1} from './componentStyle.jsx'
import {util1} from './utitilyStyle.jsx'

// some third party css directly included
import "leaflet/dist/map.css"


export default () => <div className={
cx(style1, util1, 'leaflet-map-class')
} />
// complicatedComponent.jsx

// everything remains the same as simpleComponent.jsx
// except

// 1. util1 is now not used in defaultExport
// 2. util1 is used in a NamedExport

export const NamedExport = () => <div className={util1} />

export default () => <div className={
cx(style1, 'leaflet-map-class')
} />

Let’s look the at simpleComponent.jsx for now. It has styles from a few sources

  1. style1, util1 from external files [which will be compiled by linaria]
  2. leaflet/dist/main.css is a compiled css file which will be directly used

Let’s see how all of this looks after linaria’s compilation is done:

// utitilyStyle.jsx
// css file generated by linaria
import "utitilyStyles.linaria.css"
export const util1 = 'unique_className_for_util1'
// utitilyStyles.linaria.css
.unique_className_for_util1 {font-size: 11px;}
// componentStyle.jsx
// css file generated by linaria
import "componentStyle.linaria.css"
export const style1 = 'unique_className_for_style1'

// unused export
export const style2 = 'unique_className_for_style2'
// componentStyle.linaria.css
.unique_className_for_style1 {font-size: 12px;}
.unique_className_for_style2 {color: red;}
// simpleComponent.jsx

// some styles imported from other file(s)
import {style1} from './componentStyle.jsx'
import {util1} from './utitilyStyle.jsx'

// some third party css directly included
import "leaflet/dist/map.css"

export default () => <div className={
cx(style1, util1, 'leaflet-map-class')
} />

Now we have 3 css files which need to be loaded when the default export from simpleComponent.jsx is used, namely: componentStyle.linaria.css, utitilyStyles.linaria.css , leaflet/dist/map.css

For the complicatedComponent.jsx , utitilyStyles.linaria.css must only be loaded when NamedExport is used

We modified these files like so:

// utitilyStyles.jsx
import __importedStyle from "utitilyStyles.linaria.css"
export const util1 = 'unique_className_for_util1'

export const __styles = [__importedStyle]
// componentStyle.jsx

import __importedStyle from "componentStyle.linaria.css"
export const style1 = 'unique_className_for_style1'

export const style2 = 'unique_className_for_style2'

export const __styles = [__importedStyle]
// simpleComponent.jsx
import withStyles from '@housing/isomorphic-style-loader'
import {style1, __styles as __importedStyle} from './componentStyle.jsx'
import {util1, __styles as __importedStyle2} from './utitilyStyle.jsx'

import __importedStyle3 from "leaflet/dist/map.css"

export const __styles = [__importedStyle, __importedStyle2, __importedStyle3]

export default withStyles(() => <div className={
cx(style1, util1, 'leaflet-map-class')
} />, __styles)

We have done the following:

  1. Wrapped our component in withStyles HOC
  2. This HOC has references to all CSS files required by this component. The ones which are locally imported, or the ones which are exported by other files, which have been imported in this particular component

This alone wont work. We need to do the following:

  1. Use a raw-loader or a file-loader for .css files in webpack so that these references either resolve to a css-string [to be used for inlining styles with style tag] or a url of the file [to be used for adding link tags]
  2. implement the withStyles HOC, which can add styles to server rendered HTML [preferrably with streaming] and on client check if the styles are already added to the DOM, if not, add them

But, before we go into these implementations don’t you think this is a lot of boilerplate code to write. Well, we thought so. Here enters babel.

We wrote a babel-plugin yet again. Which will take the linaria’s output and transform our code like the one shown above.

Our plugin had a few assumptions though:

  1. All style related files must follow a filename nomenclature: *Style.jsx or *style.jsx
  2. All files must have just one react component, which is the default export
  3. Styles must only be imported into files which have a react-component which is the default export

While the explanation for how that babel-plugin works is out of scope for this article, you can still checkout a simplified version of the babel-plugin here. The plugin code unfortunately is not very polished at this moment. We don’t really expect everyone to understand the plugin itself. But you can use astexplorer.net to check how the plugin modifies the input code samples above.

Voila! all done? No, we just forgot about the complicatedComponent.jsx . This example had small but significant changes

  1. util1 is not used by the default export
  2. util1 is used by NamedExport . So it should make sense to load the corresponding CSS file when this component is loaded not when the default export is loaded.

Basically this example highlights that there might be cases when developer wants has imported a few css files but not all of them have to be bound to the default export.

Therefore, we need to modify the code like

// complicatedComponent.jsx

import withStyles from '@housing/isomorphic-style-loader'
import {style1, __styles as __importedStyle} from './componentStyle.jsx'
import {util1, __styles as __importedStyle2} from './utitilyStyle.jsx'

import __importedStyle3 from "leaflet/dist/map.css"

export const __styles = [__importedStyle, __importedStyle2, __importedStyle3]

export const NamedExport = withStyles(() => <div className={util1} />, [__importedStyle2])

export default withStyles(() => <div className={
cx(style1, 'leaflet-map-class')
} />, [__importedStyle, __importedStyle3])

Basically we want __importedStyle2 to be linked with NamedExport and all others with default export .

This causes a problem. Because such a decision can only be taken by a developer, not by the babel-plugin.

But the developer himself can’t do this all, because the exports __styles in componentStyle.jsx and utitilyStyle.jsx are added during compilation, which makes it impossible for the developer to manually write all this.

But developer can still write the following:

// complicatedComponent.jsx

import withStyles from '@housing/isomorphic-style-loader'
import {style1} from './componentStyle.jsx'
import {util1} from './utitilyStyle.jsx'

import leafletStyle from "leaflet/dist/map.css"

export const NamedExport = withStyles(() => <div className={util1} />, [util1])

export default withStyles(() => <div className={
cx(style1, 'leaflet-map-class')
} />, [style1, leafletStyle])

Here developer has manually imported withStyle, since they needed a finer control. But since they didn’t have the actual references [which would be generated during build times], they provided other references to each style file. Example: style1 [for componentStyle.linaria.css]

Therefore, we modified our babel-plugin like so:

  1. If @housing/isomorphic-style-loader is already imported in a file, don’t wrap the default export automatically, because in such a case babel-plugin assumes that the developer needed a finer control and must have themselves wrapped the components in withStyles HOC whenever required
  2. Babel-plugin needs to still create the __styles exports in the relevant files, and it needs to changes the style references provided by user to the actual references. Example: style1 to __importedStyle2, style1 to __importedStyle. Therefore our babel plugin will transform the above code like so:
// complicatedComponent.jsx

import withStyles from '@housing/isomorphic-style-loader'
import {style1, __styles as __importedStyle} from './componentStyle.jsx'
import {util1, __styles as __importedStyle2} from './utitilyStyle.jsx'

import leafletStyle from "leaflet/dist/map.css"

export const NamedExport = withStyles(() => <div className={util1} />, [__importedStyle2])

export default withStyles(() => <div className={
cx(style1, 'leaflet-map-class')
} />, [__importedStyle, leafletStyle])

And that’s all. Whats left now is to actually make withStyles work. But that shouldn’t be difficult. If appropriate wepack-loaders are in place, which provide the css string when importing a css file, then withStyle is a simple HOC which has all the CSS strings/public urls to css files, and whenever the component is rendered, it just has to add those style/link tags. Now if you are not streaming, then it’s pretty simple. You can use something like react-helmet, if you are using streaming though, things are a little more complicated:

  1. You need to inject style tags between the HTML created by react. Explore react-streaming for that
  2. You also need to ensure that your style tags are rendered before your HTML, otherwise you’ll see a flash of unstyled content [FOUC].

While the details of the implementation are out of scope for this article, we are sharing a pseudo implementation of withStyles’ server & client side implementations using react-streaming. Please note that we use a modified fork of react-streaming for our purposes, which ensure we don’t see FOUC.

NOTE: using style tags with something like css-loader to get the css-string has its own sets of challenges:

  1. Your JS bundles will contain your CSS as a string. Might not be ideal. Since your CSS will be parsed as a JS string as well, which might increase your JS compile times, and you’ll also be downloading your CSS twice, once in your JS bundles and other as inlined style tags with your HTML. Hence, you might want inline styles for server side and link tags for client side builds
  2. If you do modifications with your css depending on devices, then tighten your belts for some more trouble. We run different postcss plugins on basis of device i.e. mobile vs desktop. Therefore, a single CSS file might have different output on the basic of device. But if you are using a single server to serve both mobile and desktop users, then your server webpack-loaders for .css files need to provide both mobile and desktop css output, and then you withStyles component also need to be changed accordingly to use the css according to the request.
  3. Last but not the least, css-loader doesn’t actually return a CSS string. It returns a JS module which when evaluated returns a CSS string. For us this was another problem, which required us to evaluate those JS modules at build time, thankfully webpack’s importModule came to our rescue.

We know that’s a lot to take in, and probably a lot of this might not be useful for most of the folks. But we are sharing it in the interest of the adventurous ones.

Thanks a lot for reading. Please do share your valuable feedback & keep following this space for more such content.

This is a fourth in the series of articles on our CSS Pipeline.

Check out the other articles of this series: Part 1, Part 2, Part 3, Part 5

Once again shout out to the ones who helped achieve this: Dhruvil Patel, Naman Saini, Nikhil Verma, Sachin Tyagi

--

--