Skip to content
This repository has been archived by the owner on Jan 11, 2023. It is now read-only.

Extract CSS #388

Closed
Rich-Harris opened this issue Aug 30, 2018 · 5 comments
Closed

Extract CSS #388

Rich-Harris opened this issue Aug 30, 2018 · 5 comments

Comments

@Rich-Harris
Copy link
Member

Currently, Sapper doesn't do anything special with CSS — it expects your bundler to handle it. Which sort of works, in that if you don't do anything the CSS will be included as a string inside your JS, and if you configure webpack just right you can extract the CSS by emitting a separate .css asset and importing it from the JS, but it's still a little clunky.

It feels like it ought to be possible to extract the CSS for any components in the app, and make the client-side runtime aware of them. So that when it loads the JS for (say) /about, it loads about-xyz123.css as well, and creates a link tag. We can avoid any duplication (as currently happens between the critical CSS and the initial route load) while also avoiding FOUC. If we were smart we could probably coalesce blobs of CSS into more optimized chunks.

It would mean spelunking through the meta information generated by webpack/Rollup, pulling out things that look like components (based on .html extension, presumably?) and pre-compiling them (since this would have to happen prior to bundling) to get the CSS. If someone were using that extension for non-Svelte things, or if they were doing something wacky, there might be creative ways to break it. In a framework like this I guess it's okay to have a few constraints though, especially ones that most people will never become aware of?

@Rich-Harris
Copy link
Member Author

This might be a little trickier than I thought initially. We can't discover which components are used by each route without bundling, we can't bundle without generating a manifest, and we can't generate a manifest that includes CSS information without knowing which components are used by each route.

Will think further on this.

@Rich-Harris
Copy link
Member Author

A plan that might just work: we shove a bunch of placeholder strings into the manifest, like so:

// app/manifest/client.js
const index = {
    js: () => import('../../routes/index.html'),
    css: '__REPLACE_ME_IN_A_TRANSFORMCHUNK_HOOK_XYZ123__';
};

const about = {
    js: () => import('../../routes/about.html'),
    css: '__REPLACE_ME_IN_A_TRANSFORMCHUNK_HOOK_XYZ234__';
};

const blog = {
    js: () => import('../../routes/blog.html'),
    css: '__REPLACE_ME_IN_A_TRANSFORMCHUNK_HOOK_XYZ345__';
};

const blog_ = {
    js: () => import('../../routes/blog/[slug].html'),
    css: '__REPLACE_ME_IN_A_TRANSFORMCHUNK_HOOK_XYZ456__';
};

export const manifest = {
    ignore: [/^\/blog.json$/, /^\/blog\/([^\/]+?).json$/],

    pages: [
        {
            // index.html
            pattern: /^\/?$/,
            parts: [
                { component: index }
            ]
        },
        ...

When bundling, we extract information about which components are contained in which chunk, and emit CSS files accordingly. We also note which routes depend on which chunks. Then we simply replace the strings in a transformChunk hook, so that it becomes something like this:

// app/manifest/client.js
const index = {
    js: () => import('../../routes/index.html'),
    css: ['xyz123', 'xyz234'];
};

Then the runtime can load /client/css/xyz123.css and /client/css/xyz234.css as necessary. We're already injecting a Rollup plugin to extract information, so that part is straightforward (though it might be necessary to unshift rather than push, so there's no sourcemap weirdness if the transformation happens after minification). I'm sure the same thing is possible in webpack somehow

@Rich-Harris
Copy link
Member Author

Argh. I got halfway through implementing this before I realised the fatal flaw: it's not enough to know which components and chunks each route depends on, because we can't actually generate the CSS for each chunk. We almost can — we know which components belong to each chunk, and could compile them easily enough — but Sapper can't account for any preprocess options that are used in rollup/client.config.js, or any subsequent transformations that might exist.

Not sure what the right solution here is. If the Rollup plugin is invoked with emitCss: true, Sapper can intercept those imports, store the CSS, and create CSS bundles matching each route later. But it doesn't have access to sourcemaps, because Rollup only passes code to transform hooks (because the expectation is that you'll return {code, map}, and it will take care of composing sourcemaps). So our generated CSS chunks also can't have sourcemaps, which is a dealbreaker for me.

Back to the drawing board.

@Rich-Harris
Copy link
Member Author

Quick update: I'm making progress here. The solution I'm going with is a teensy bit hacky but it seems to work; we can always take steps to remove some of the hackiness later once it beds in.

For now, I'm focusing on Rollup apps. I'm sure this is stuff is possible with webpack but we can come back to that.


First step: add sourcemaps to the virtual CSS files generated by rollup-plugin-svelte. I've done this work locally but haven't PR'd it yet. It's fairly straightforward — for the sake of simplicity I'm doing inline sourcemaps (i.e. base64 data URIs).

Second step: use emitCss: true in Rollup client configs.

Third step: Sapper, which is already adding a sapper-internal Rollup plugin so it can gain knowledge about the different chunks (though this should become unnecessary with Rollup 1.0), adds a transform hook to that plugin that intercepts any imports of .css files (including the virtual .css files emitted by rollup-plugin-svelte with emitCss: true), stores their contents, and replaces them with an empty module. This is the most 'magical' part of the process, and the most brittle since other Rollup plugins that transform CSS could already have accounted for it. There are probably ways it could be made less brittle (e.g. checking that the file hasn't already been transformed into a JS module), but I suspect this is mostly a documentation thing.

Fourth step: for each generated JS chunk, see which CSS files the chunk depends on, and (if n > 0) generate a CSS chunk with the same name (e.g. about.xyz123.css), using the code and sourcemaps we slurped up in the prior step. We replace the placeholder strings in the manifest with an array of chunks each component depends on.

Fifth step: some CSS may be left over — that which is referenced from the entry point rather than via a page component, or is dynamically imported. We concatenate them and compose sourcemaps using the same technique as before, hash the contents, and write out main.xyz123.css.

Sixth step: the middleware injects <link> tags for main.xyz123.css plus any CSS chunks used by the current page.

Seventh step: the runtime, upon navigation, checks to see if any needed styles haven't been loaded yet (by simply querying the DOM for links with the expected href), and blocks navigation until they're ready.


This might all sound a bit convoluted. And it is, but that's because it's solving what turns out to be a very hard problem. I think this is one of those times when a little bit of magic is okay, because trying to solve it in userland is prohibitively difficult.

A nice thing about this solution is that it's not Svelte-specific — you can import CSS from any source (e.g. if you're using something like CodeMirror on one specific route, your CodeMirror component can import the CSS it needs directly from node_modules) and it'll still be baked out and lazily loaded as a code-split CSS chunk.

I think this is pretty cool. I'm not aware of any other framework that can do this — Next and Nuxt, for example, include blobs of style in JavaScript, which is bad for performance on multiple fronts (adds to parse/eval time, reduces cacheability of assets, prevents you from loading styles and JS in parallel), so we might be setting a new benchmark here.

@lmf-git
Copy link

lmf-git commented Sep 12, 2020

Keep up the good work Rich, maybe some day we'll have it.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants