Skip to main content

JavaScript: Porting from react-css-modules to babel-plugin-react-css-modules (with Less)

I recently found a bug in react-css-modules that prevented me from upgrading react-mobx which prevented us from upgrading to React 16. Then, I found out that react-css-modules is "no longer actively maintained". Hence, whether I wanted to or not, I was kind of forced into moving from react-css-modules to babel-plugin-react-css-modules. Doing the port is mostly straightforward. Once I switched libraries, the rest of the port was basically:
  • Get ESLint to pass now that react-css-modules is no longer available.
  • Get babel-plugin-react-css-modules working with Less.
  • Get my Karma tests to at least build.
  • Get the Karma tests to pass.
  • Test things thoroughly.
  • Fight off merge conflicts from the rest of engineering every 10 minutes ;)
There were a few things that resulted in difficult code changes. That's what the rest of this blog post is about. I don't think you can fix all of these things ahead of time. Just read through them and keep them in mind as you follow the approach above.

.babelrc configuration

My .babelrc configuration looks like this:
{
    "presets": [
        "..."
    ],
    "plugins": [
        [
            "react-css-modules",
            {
                "filetypes": {
                    ".less": {
                        "syntax": "postcss-less"
                    }
                },
                "generateScopedName": "[name]--[local]--[hash:base64:5]"
            }
        ]
    ]
}
Note that Babel uses postcss-less at build time, but that's separate of the code that compiles the Less for things like extract-text-webpack-plugin.

The generateScopedName was an important bit of configuration in order to get the CSS class names to be consistent between build time and extract-text-webpack-plugin. This matches localIdentName: '[name]--[local]--[hash:base64:5]' in our CSS loader options.

Simple changes to your React components

Don't import 'react-css-modules'.

Don't use the cssModules decorator or function.

Generally, if you have an import line like import styles from './hello-colorful.less';, change it to import './hello-colorful.less';. You only need to use names if you import more than one .less file.

Resolving .less files.

babel-plugin-react-css-modules isn't very good at respecting your Webpack resolver roots. Hence, it's best to use relative paths when referring to .less files.

Removing the @cssModules decorator causes subtle API changes

It used to be with @cssModules, you could just return undefined in your render method (or fall through without a return statement). Now you have to explicitly return null. Also, with the @cssModules decorator, if your component returned null, it'd convert that to <noscript></noscript>. This results in changes to your tests:
expect(wrapper.text()).toBe('');  // Or
expect(wrapper.type()).toBe('noscript');
Becomes:
expect(wrapper.html()).toBe(null);
Similarly, you can no longer pass undefined to styleName. Hence, if value might be null, then:
<div styleName={ value }>
Becomes:
<div styleName={ value || '' }>
If you see an error message such as "Cannot read property 'split' of undefined", it probably means you're passing undefined to styleName instead of ''. And in your tests, if you previously were expecting className to be undefined, it'll now be ''.

Don't get too tricky when setting styleName

For instance:
buttonProps.styleName = ...
...
<Button { ...buttonProps }>
Becomes:
<Button { ...buttonProps } styleName=...>
Similarly, you should unpack className as well:
<Popover styleName="box" { ...props }>
Becomes:
// className has to be its own thing when used with styleName and
// ...props. Otherwise babel-plugin-react-css-modules gets confused.
<Popover { ...props }
    styleName="box" className={ props.className }>

Class names are a little different

Testing for class names in your tests is a little different. Hence:
expect(findElementByDataPurposeAttribute('header-bottom'))
    .toHaveClassName(styles['header-bottom--progress']);
Becomes:
expect(findElementByDataPurposeAttribute('header-bottom').node.className)
    .toContain('header-bottom--progress');
For normal HTML elements, it's a little different:
expect(findElementByDataPurposeAttribute('helpfulness-thanks'))
    .toHaveClassName(styles['thanks--hidden']);
Becomes:
expect(findElementByDataPurposeAttribute('helpfulness-thanks').node.props.className)
    .toContain('thanks--hidden');

Fancy Webpack imports for .less files no longer work

If you had weird imports such as:
import icomoonCss from '!!raw!./icomoon.global.less';  // Or
import themeVariables from '!less-vars!./variables.global.less';
That'll no longer work because babel-plugin-react-css-modules will try to actually resolve those imports as normal files.

To work around this problem, you can create a new file, icomoon-global-less-exporter.js, that does an immediate export such as:
export { default } from '!!raw!./icomoon.global.less';
Now, import icomoon-global-less-exporter.js instead:
import icomoonCss from './icomoon-global-less-exporter';
The level of indirection is enough to work around the problem.

See this bug for more details.

Debugging invalid CSS class names

babel-plugin-react-css-modules doesn't output enough information when you use invalid CSS class names. I found it helpful to just hack the source code in node_modules to output a little bit more information when I got stuck. I hacked the source code in either node_modules/babel-plugin-react-css-modules/dist/getClassName.js or node_modules/babel-plugin-react-css-modules/dist/browser/getClassName.js. In getClassNameForNamespacedStyleName, I added either a console.log statement or included more information in the exceptions.

Update: They accepted my patch to improve the error messages.

Similarly, MobX can be a little unhelpful when render raises an exception. I tweaked node_modules/mobx/lib/mobx.js so that anytime I saw finally, I added:
catch (e) { debugger; }
Those changes were enough to debug all of my errors.

Using & in .less files

Our .less code confused postcss-less a bit. Hence:
.strength-box { ... &--ok { } }
Becomes:
.strength-box--ok { }
We had actually deprecated the previous pattern anyway since it makes the code harder to grep.

Mutating props

For whatever reason, you can no longer mutate props in a test and then call componentWillReceiveProps:
wrapper.node.props.value = 'zwek';
wrapper.node.componentWillReceiveProps(wrapper.node.props);
Becomes:
const newProps = {
    ...wrapper.node.props,
    value: 'zwek',
};
wrapper.node.componentWillReceiveProps(newProps);

Funky psuedo inheritance in .less

I saw a bunch of cases where a React component would import a .less file. Then, that .less file would import another .less file with real CSS in it. In effect, this provided a sort of inheritance. However, it also meant that the same base CSS was getting output in multiple places. Furthermore, it confused postcss-less.

Hence, I broke apart this inheritance and just made use of each .less file separately in the React component:
import baseStyles from './base.less';
import styles from './my-component.less';
...
<div styleName="baseStyles.extended-css-class styles.extended-css-class
    styles.some-other-css-class">
In my-component.less, you can still use (reference) in order to import variables and mixins. You just can't import actual CSS code.
@import (reference) './base.less';
It kind of sucks to lose this implicit inheritance. However, fixing the underlying performance gotcha and being a little more explicit is a win.

Comments

Popular posts from this blog

Ubuntu 20.04 on a 2015 15" MacBook Pro

I decided to give Ubuntu 20.04 a try on my 2015 15" MacBook Pro. I didn't actually install it; I just live booted from a USB thumb drive which was enough to try out everything I wanted. In summary, it's not perfect, and issues with my camera would prevent me from switching, but given the right hardware, I think it's a really viable option. The first thing I wanted to try was what would happen if I plugged in a non-HiDPI screen given that my laptop has a HiDPI screen. Without sub-pixel scaling, whatever scale rate I picked for one screen would apply to the other. However, once I turned on sub-pixel scaling, I was able to pick different scale rates for the internal and external displays. That looked ok. I tried plugging in and unplugging multiple times, and it didn't crash. I doubt it'd work with my Thunderbolt display at work, but it worked fine for my HDMI displays at home. I even plugged it into my TV, and it stuck to the 100% scaling I picked for the othe

Drawing Sierpinski's Triangle in Minecraft Using Python

In his keynote at PyCon, Eben Upton, the Executive Director of the Rasberry Pi Foundation, mentioned that not only has Minecraft been ported to the Rasberry Pi, but you can even control it with Python . Since four of my kids are avid Minecraft fans, I figured this might be a good time to teach them to program using Python. So I started yesterday with the goal of programming something cool for Minecraft and then showing it off at the San Francisco Python Meetup in the evening. The first problem that I faced was that I didn't have a Rasberry Pi. You can't hack Minecraft by just installing the Minecraft client. Speaking of which, I didn't have the Minecraft client installed either ;) My kids always play it on their Nexus 7s. I found an open source Minecraft server called Bukkit that "provides the means to extend the popular Minecraft multiplayer server." Then I found a plugin called RaspberryJuice that implements a subset of the Minecraft Pi modding API for B

Creating Windows 10 Boot Media for a Lenovo Thinkpad T410 Using Only a Mac and a Linux Machine

TL;DR: Giovanni and I struggled trying to get Windows 10 installed on the Lenovo Thinkpad T410. We struggled a lot trying to create the installation media because we only had a Mac and a Linux machine to work with. Everytime we tried to boot the USB thumb drive, it just showed us a blinking cursor. At the end, we finally realized that Windows 10 wasn't supported on this laptop :-/ I've heard that it took Thomas Edison 100 tries to figure out the right material to use as a lightbulb filament. Well, I'm no Thomas Edison, but I thought it might be noteworthy to document our attempts at getting it to boot off a USB thumb drive: Download the ISO. Attempt 1: Use Etcher. Etcher says it doesn't work for Windows. Attempt 2: Use Boot Camp Assistant. It doesn't have that feature anymore. Attempt 3: Use Disk Utility on a Mac. Erase a USB thumb drive: Format: ExFAT Scheme: GUID Partition Map Mount the ISO. Copy everything from