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:
@modular-css/rollup
sticks an extra property on each of the chunks in thebundles
argument to thegenerateBundle()
hook describing which CSS assets that bundle depends on- Rollup tracks and provides for plugins all the resolvable dynamic
import()
s in each chunk - The
code
property of each chunk is just a string, and it you make changes to it ingenerateBundle
rollup will happily write the changedcode
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.
- Loading another module is extremely fast since there's no network latency
- We use Coherent GT and it natively supports ES Modules and
import()
(though withshimport
that's true in most places these days) - Svelte is highly component-focused and easy to code-split
- We're using rollup for bundling so we get very clean chunks with low overhead
- I put a bunch of free-time effort into
@modular-css/rollup
so it supports code-splitting pretty well
There's also some annoying caveats with this approach so far.
- Your CSS loader has to return a
Promise
- The CSS loader has to be loaded into the page ahead-of-time or else trivially imported
- The function for loading CSS is specified as a simple string and doesn't allow for any extra args
- No ability to modify the URLs before the bundle is written
Some of these are surmountable, particularly the last two, but none of them are a deal-breaker for my use-case.