At Thread, we love CSS Modules.
One issue we kept seeing was references to styles.foo
long after someone
removed the .foo
class. This dead code seems innocent enough, but over time
this bit rot adds significant noise to your code.
A more embarrassing issue I kept seeing involved long moments of trying to work
out why styles['i-am-a-typo']
wasn’t applying the style I was expecting.
In response, we created
strict-css-modules-loader.
This webpack loader causes your styles
object to log warnings whenever you
attempt to access nonexistent class names.
In this article, we’ll explore a little bit about how the loader works, behind the scenes.
First of all, let’s talk about what CSS Modules are. If you want an in depth explanation, you can read the CSS Modules documentation, and the css-loader documentation.
But as a very quick summary, CSS Modules allows us to write a normal CSS file:
/* styles.module.css */
and then import this CSS file into our JavaScript file, like so:
;elementinnerHTML = ``;
to give our div
the .foo
styles, without creating a global .foo
class.
Instead, behind the scenes, the .foo
class gets namespaced to something like
.foo-018f0s3f
.
This means that each CSS file gets its own .foo
class, and we don’t inherit
the styles of another CSS file by mistake.
The problem here happens when we call styles.foo
, if .foo
was never defined
in the CSS. Rather than any sort of error, we get undefined
returned as the
class name.
;elementinnerHTML = ``;// same as if we wrote:elementinnerHTML = '<div class="undefined">';
There are libraries to help avoid this, but in any case, this code is costly to the developer. When trying to track down how an element is being styled, these nonexistent class names are red herrings. A developer spends the majority of their time reading code, so these red herrings are costly.
It’s worth noting that this problem isn’t specific to CSS Modules. We hit the same problem even if we hardcode our CSS classes. The underlying problem lies in there being no strong typing between the CSS file, and the JavaScript, or the DOM.
Weak typing in web development isn’t an inherently bad thing: it’s a tradeoff for speed of development, and scriptability. But in certain contexts, you want a stricter environment. This is what libraries like PropTypes give you for React props.
If we ran code like this:
;elementinnerHTML = ``;
then we would like to get a warning, if there is no .foo
class defined.
But nothing currently can alert this for us. styles
is a plain old JavaScript
object, and if you ask it for a key that it doesn’t have, it will return
undefined
. No more, no less.
So how do we get our console warnings?
A naive solution here would be a small function wrapper, e.g.:
;{if !stylesconsole;return stylesclassName;}elementinnerHTML = ``;
This would work. But it would also force us to use this new helper function every time we want to refer to a class name in our code. This would involve a large refactor, and also add annoying verbosity to our code (and size to our bundle).
What we want is the functionality of getClassNameSafely
, without having to
type it ourselves.
Fortunately, ES6 gives us the
Proxy
object.
This lets us wrap a vanilla JavaScript object with added functionality on
property access (e.g. getting or setting a property).
For example, it lets us write something like this:
;{return styles{if !objconsole;return objclassName;};}const styles = ;elementinnerHTML = ``;
This gives us the same console warning as before, but we don’t have to change
our reference to styles.foo
.
This is almost perfect. Unfortunately, we still need to include this
wrapStyles
helper in our files. We want to be able to import our styling the
same as we always have:
;elementinnerHTML = ``;
so the styles
object given to us is already wrapped in our proxy.
To do this, we can create our own webpack loader. This sounds daunting, but webpack’s loader interface is surprisingly straightforward! You can read more about loaders, but at its core: a loader is a function that takes some input, and spits out some output.
All we want to do is take a regular styles object as input, and output it wrapped in a proxy.
// strict-css-modules-loadermodule {// the old content will contain `module.exports = { foo: 'foo-local' }`// we assign the old styles object to a `rawLocals` variable instead,// so we can still refer to it latervar newContent = content;// export the old exports, but wrapped in our proxynewContent += `module.exports = new Proxy(rawLocals, {get(obj, prop) {if (!obj.hasOwnProperty(className))console.warn(\`\${className} does not exist!\`);return obj[className];},});`;// we let webpack know we're done, and what the new content isthis;};
And that’s it!
Our actual code is slightly more complex to cover some other cases, but at its core this is all it does. You can check out the source if you’d like to know more.
We now have a loader that:
To use the loader, we add it to the end of our webpack loader chain for our CSS Modules. For example, if you had:
test: '\.module\.css$'use:'style-loader''css-loader?modules=true'
then style-loader
is returning you your class name mappings. So to wrap those
styles, we make our new loader run after it.
test: '\.module\.css$'use:// our new loader is first in the array, since webpack runs these loaders in reverse'@teamthread/strict-css-modules-loader''style-loader''css-loader?modules=true'
Similarly, if you were using MiniCssExtractPlugin
, then we would want to move
from something like:
test: '\.module\.css$'use:MiniCssExtractPluginloader'css-loader?modules=true'
to
test: '\.module\.css$'use:// our new loader is first in the array, since webpack runs these loaders in reverse'@teamthread/strict-css-modules-loader'MiniCssExtractPluginloader'css-loader?modules=true'
The two main tools we’ve used here are ES6’s
Proxy
object,
and
webpack’s simple loader interface.
The combination of these 2 powerful things has allowed us to get detailed warnings when our code attempts to use styles that don’t exist. All by adding 1 line to our webpack config.
So join the good fight against bit rot! Give strict-css-modules-loader a go.
If you have any questions, feel free to ping me on Twitter @StephenCookDev. And if you’re looking for a job, check out our jobs page and get in touch! 🙂