tivac.com

Fighting FOUC with modular-css

I build game UI with web technology. This leads to a lot of focus on performance optimizations, and thus a lot of work on the modular-css packages with an eye towards supporting the most-performant workflows possible. I've spent a fair bit of time trying to ensure that @modular-css/rollup in particular fully supports code-splitting at least as well as rollup itself. That's... not always a mark I've hit, but it's always something I try for.

But splitting code into smaller chunks is only part of the problem. The other tricky bit is actually loading that code once you've got it split out. Even trickier is loading all its dependencies if you're doing something like we are and outputting CSS in modules that live alongside the JS. The loading JS chunks bit is actually pretty straightforward these days thanks to ES Modules and dynamic import(), but easily getting the CSS loaded alongside the JS had always eluded me.

Some of the feedback on twitter from my post about @modular-css/rollup circled around an idea I'd been puttering around with for months. How could I ensure that when I use import() in my JS to load a module that it would also load all the CSS the module depended on? Not only load it, but load it simultaneously with the module to avoid a FOUC? It would need to do this automatically and with a minimum of changes to the code as well because I care about readable bundler output.

After some recent updates to rollup APIs I've got an answer.

@modular-css/rollup-rewriter #

The short version of what I did is this: I created another rollup plugin that can be used after @modular-css/rollup to turn code like this

// index.js
async () => {
const mod = await import("./module.js");

mod.render();
}

// module.js
import css from "./module.css";

export default ...

into code like this

// index.js
async () => {
const mod = await Promise.all([
lazyLoadCss("./chunk.css"),
import("./chunk.js")
]).then((results) => results[results.length - 1]);

mod.render();
}

ensuring that all the module's CSS is loaded by the time the JS is returned and starts drawing anything.

It takes advantage of a couple of things to do its job:

  1. @modular-css/rollup sticks an extra property on each of the chunks in the bundles argument to the generateBundle() hook describing which CSS assets that bundle depends on
  2. Rollup tracks and provides for plugins all the resolvable dynamic import()s in each chunk
  3. The code property of each chunk is just a string, and it you make changes to it in generateBundle rollup will happily write the changed code value out to disk.

On our current project at work I've been looking into aggressively code-splitting along component lines (we hit chunk47.js recently and I'm barely halfway done) and having a tool like this means that I don't have to manually load all of the CSS up front or laboriously figure out which CSS will be in which chunk. I just use import() wherever I want and the end result does what I want in the most performant way possible.

Caveats and Notes #

We have some architectural advantages around extensive code-splitting that don't exist on a traditional web app.

There's also some annoying caveats with this approach so far.

Some of these are surmountable, particularly the last two, but none of them are a deal-breaker for my use-case.

← Home