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