Shoving CSS into a JS Bundler

There's a variety of reasons why supporting a totally-different paradigm like CSS Modules in a fundamentally JS-focused bundler like rollup. Let's go through some of them with some code samples from @modular-css/rollup.

Converting CSS to JS #

The first step is figuring out what import css from "./file.css"; should turn into. Fortunately for me the CSS Modules project had already defined what that should be. There's more than a few extra complications there though.

Shadow dependency graphs #

The functionality in modular-css that allows you to cross-reference classes or @values from another file ends up creating a sort of shadow dependency graph. Until rollup@0.61.0 that wasn't a concept at all in rollup. To work around it modular-css had to export a bunch of bogus import statements.

out = out.concat(
processor.dependencies(id).map((file) =>
`import "${slash(file)}";`

This was a lie, but a convenient one.

Once rollup@0.61.0 was out declaring dependencies had a specific API which meant a lot less kludge.

Watch mode updates #

A recurring issue with the rollup plugin has been that until rollup@0.61.0 it didn't really work well with rollup's watcher. Files wouldn't be invalidated correctly, or at all, and in generaly it was a passable-but-not-great experience. Once rollup added dependency support as a first-class plugin concept things improved a fair bit! There was one area though where my decisions in @modular-css/processor made things more difficult than they should've been.

The Processor for modular-css caches all files passed to it by default. This is a reasonable choice given the number of times it might be asked to process the same file. It only starts to become difficult when we're dealing with an API like the previous rollup APIs where it was impossible to know why a file was being reprocesssed. It led to a bunch of complicated flagging logic in the rollup plugin that never really worked quite right.

Then rollup@0.65.0 added the watchChange plugin hook and everything was mostly wonderful.

Until the transform hook stopped being called for some files due to changes in rollup's plugin caching. Then I had to totally change how the Processor handled replacing files by adding a concept of valid/invalid so that invalid files that were re-processed would actually be processed again.

It still never removes old files if they stop being referenced while in watch mode. I could use the fs module to check if a file still existed on disk when rollup tells the plugin it changed but... eh. So far it hasn't been a problem.

Named exports vs default exports #

One of the options supported in the rollup plugin for modular-css is namedExports. Instead of the default, which is a single object:

// Normal export
export default {
rule : "abcd123_rule"

// Enabled namedExports also adds this
export var rule = "abcd123_rule";

This is nice if consumers only want to use part of a file because it lets them take advantage of rollup's built-in treeshaking so that any unused exports are simply removed from the resulting bundle. There's a catch though, all the named exports have to be valid JS identifiers.

Object.entries(exported).forEach(([ ident, value ]) => {
if(keyword.isReservedWordES6(ident) || !keyword.isIdentifierNameES6(ident)) {
this.warn(`Invalid JS identifier "${ident}", unable to export`);


out.push(`export var ${ident} = ${JSON.stringify(value)};`);

It's a small thing, but building software is all about sweating the details I suppose.

JS chunks vs. CSS chunks #

Until the styleExport option was added in @modular-css/rollup@14.1.0 the rollup plugin only supported exporting to external .css files. This was a concious choice, albeit not always a popular one. The thinking was that exporting to external .css files is almost-always going to be the more-performant way to load CSS, and being fast by default is important. This choice has a whole raft of downstream effects that aren't always fun though.

The biggest one is that the .css files should, generally, map nicely to the .js files being output by rollup. Once rollup added support for code-splitting this got a ton more exciting to support. Here's a rough list of everything the modular-css rollup plugin has to do to support code-splitting correctly:

  1. Duplicate the dependency graph that rollup creates internally for the JS chunks
  2. Check all the nodes of that graph for CSS dependencies
  3. Check all of the CSS dependencies of each node for their dependencies
  4. Check all of the discovered CSS files to ensure they haven't already been output
  5. *waves hands and mumbles something about hashes* we'll get back to hashes later
  6. Mark all of the files that haven't already been output as queued to output
  7. Collect any unreferenced-by-JS CSS files and either stick them into the first chunk or their own discrete chunk
  8. Write out all of these CSS files to the correct locations, taking the rollup options for asset naming into account
  9. Outputting any external source maps that might've been requested (PS they have to match up with all the chunks above!)
  10. Outputting a JSON manifest of all the CSS files and their default exports

It looks like a lot in list form, and it's even more fun in the code.

Output hashes in file names #

Output hashes in file names (as supported in rollup via the [hash] property) is maybe my least favorite rollup feature at the moment. I 100% understand why end-users would use it and even like it but it makes my life as a plugin author significantly harder.

To properly support CSS chunks that have names that align correctly with JS chunks I had to write a whole parser that can take rollup options like entryFileNames, chunkFileNames, or assetFileNames with their [name]/[extname]/[ext]/[hash] syntax and figure out all the distinct parts. It's a mess.

Once I have all those parts there's a lot of directory manipulation code to turn all of those rollup options into actual potential file locations that can be passed as a to property to postcss when generating the output. Stuff like this:

// Determine the correct to option for PostCSS by doing a bit of a dance
let to;

if(!outputOptions.file && !outputOptions.dir) {
to = path.join(processor.options.cwd, assetFileNames);
} else {
to = path.join(
outputOptions.dir ? outputOptions.dir : path.dirname(outputOptions.file),

and this

to  : to.replace(/\[(name|extname)\]/g, (match, field) => (field === "name" ? name : ".css")),

Why is to important? It's used by all sorts of postcss plugins that need to know where the file will eventually live.

There's another trick around the [hash] property. It can't be looked up until the contents of the file have been set using the .setAssetSource() api in rollup. That makes sense since you wouldn't be able to hash content that doesn't exist, but does create a fun situation that doesn't really have an answer.

If the hash is part of the output filename, and you need to know the output filename to generate the contents, and you can't know the hash until you've generated the content, what do you do?

In my case the answer is "cross your fingers and hope the source maps work anyways". It's mostly true although I had to write some really dumb code to make it happen. It also involes two chunks of code solely to strip the /*# sourceMappingURL=... */ comment from the end of the generated CSS when using external source maps, and then add it back later manually.

This ends up looking a bit like a rant, but none of this should be taken as a knock against rollup.

Rollup is a tool that I use every day for work & my own projects and one that I love dearly. Rich Harris, Lukas Taegert-Atkinson, and Guy Bedford have all done amazing work making it maybe my most-used tool.

This is more the result of me doing some thinking about the rollup plugin lately. I figured it was worth writing down some of the weird edge-cases in my plugin that have necessitated making it more complicated than I ever expected. I'm doing a slightly strange thing with it and there's a bunch of places where it doesn't quite fit right into the mold.

@modular-css/rollup is a tool I use all the time & a tool that I personally really enjoy using, but dang it's a weird bit of code.

Updated 2019/01/19: correcting some typos & awkward wording

← Home