Skip to content
Balazs Koskoci edited this page May 16, 2020 · 25 revisions

EDIT: After sharing this document with many people, I ended up at a different conclusion: that going runtime-only was the better approach! I need to write up a blog post about the whole process, but I talked about it a bunch on Elm Town in the meantime.

This is where I see elm-css going in the long term. It's not a roadmap or timetable, just an exploration of which direction makes the most sense.

tl;dr basically like CSS Modules, except you can define the styles in the same file as your view (like glamor, or, with one helper function, glamorous), yet still compile them to a separate .css file. (Dead code elimination means nothing elm-css related ends up in compiled .js files, just classname literals.)

Background

The big question is whether elm-css should assemble styles at build time or at runtime. It turns out to be a complicated question with a clear answer at the end. The rest of this document explains the reasoning that leads to the conclusion.

There are a ton of different approaches to CSS styling that are seeing use in the industry, and it's important to consider their benefits and drawbacks.

Inline Styles

Prior to Christopher Chedeau's presentation about "CSS in JS" in 2014, the consensus best practice for years had been to put all your CSS in a separate .css file (or compile such a .css file using a preprocessor), with the possible exception of a few situational inline styles.

The presentation calls out some shortcomings of the .css file approach:

  1. Global Namespace
  2. Dependencies
  3. Dead Code Elimination
  4. Minification
  5. Sharing Constants
  6. Non-deterministic Resolution
  7. Breaking Isolation

I have some thoughts on how this list pertains to elm-css.

  • elm-css has namespace to help with the Global Namespace problem (#1), but it's not a bulletproof solution. We can do better.
  • Because elm-css lets you write stylesheets in Elm, we get dependencies (#2) and sharing constants (#5) for free.
  • I was surprised how in the presentation, "Dead Code Elimination" (#3) turned out to be "Dead Code Elimination where the programmer does it by hand"—and since elm-css easily lets you share constants between view code and stylesheets, manual cleanup gets a lot easier. That said, automatic dead code elimination on elm-css stylesheets should also be arriving for free in the next release of the Elm compiler.
  • I doubt minifying classnames (#4) moves the needle for most of us, especially once the stylesheets are gzipped. At Facebook's scale, though, the traffic coefficient means that every byte shaved is a bunch of money, so maybe this is a reasonable thing to consider at their size.
  • Non-deterministic Resolution (#6) and Breaking Isolation (#7) could be resolved by a helper library that exposes a "safe subset" of elm-css where doing things like increasing specificity or breaking isolation is a validation error. I'm not sure if something like this should be in scope for elm-css itself, but it seems reasonable. It's doable regardless of whether stylesheets are assembled at build time or at runtime.

Radium uses the "embrace inline styles" approach this presentation discusses.

Injecting into a <style> element

The "inline styles" approach has some serious drawbacks. One is performance; Pete Hunt noted:

inserting huge strings of inline styles caused serious performance problems (several hundred millisecond pauses or more) at Smyte.

This approach seems to perform particularly slowly in comparison to .css files.

Another downside (also noted in Pete's article) is that you can't use pseudo-classes, pseudo-elements, or media queries in inline styles. This is a big usability hit.

One way to "get the best of both worlds" is to have a library build up per-element descriptions of the styles you want, and then inject them into a <style> element which the library controls. It can automatically generate classes behind the scenes to attach the right styles to the right elements, so you get the UX of inline styles but with better performance and while getting to use pseudo-classes, media queries, etc.

JavaScript libraries which do this typically inject the <style> element into the <head> - for example Aphrodite, jsxstyle, and styled-components do this. elm-css supports this by rendering a <style> element inside the <body> instead of inside the <head>. Pre-HTML5 it was against W3C spec to include <style> elements outside the <head>, but today browsers consistently support it and the HTML5 spec explicitly permits it.

Although putting precompiled CSS in the <head> potentially has a performance benefit (since the browser can process it on a different thread while it works on the page's JavaScript), these <style> elements are generated by JavaScript, meaning it is too late to get that benefit. As such, supposing that elm-css somehow introduced a way to generate <style> elements inside the <head>, any noticeable performance benefit would be unlikely.

One concrete benefit of putting the <style> in the <head> is that it's "safer" there; third-party JavaScript is more likely to mutate elements inside the <body> than elements inside the <head>. However, it is already true that if third-party JavaScript is mutating DOM Elements generated by Elm, bad things are going to happen. <style> elements are not special in this regard.

One concrete downside for elm-css in particular of <head> injection is that it would remove an invariant that has always existed: Elm libraries only affect the DOM subtree in which their programs are embedded. (That is, some part of the <body>.) Giving elm-css the ability to perform <head> injection would break this invariant, leading to questions such as "what happens if multiple Elm programs are doing this on the same page?" and creating a larger surface area to cover when debugging.

Since there seems to be no meaningful benefit to putting <style> elements in the <head> instead of in the <body> (which is already possible), and there is at least one meaningful downside in addition to the usual "let's not make the API more complex without good reason" downside...pursuing support for <head> injection does not make sense as a long-term goal for elm-css.

Preprocessors

The "preprocess a different language into a .css file at build time" approach has been around for a long time. elm-css was originally built to be a preprocessor, and was directly influenced by Sass and Stylus. Since then PostCSS has been released as well.

To differentiate between this approach and the above two approaches (inline styles and <style> element injection), I'll refer to preprocessors (and handwritten .css files) as "build-time CSS" and to the others as "runtime CSS."

One problem build-time CSS has always had is namespace collisions. What if you name a class .menu in one file and then also name a class .menu in a different file, but they're meant to style different menus? The browser will not complain if you do this; instead, you'll probably get some nasty styling bugs. Trying to avoid this is why elm-css ships with a namespace transformation, but it's not a bulletproof solution, and using it takes nontrivial effort.

It would be better if build-time CSS didn't have this downside, and CSS Modules offers a solution. It's a preprocessor that auto-generates classnames at build time, based on the hash of the declarations in that style. It also automatically synchronizes those classnames with the classnames used in the corresponding JS code, making namespace collisions effectively impossible.

Generating classnames removes the downside that made build-time CSS more error-prone than runtime CSS. elm-css can and should offer something like this, but does not yet. Perhaps when it does, it should directly auto-generate an Attribute msg which contains a class value (now that multiple class attributes stack), so it would not be possible to mess up the class name even by accident.

Unlike most other preprocessors (jss being an exception), elm-css at build-time has no difference in expressivity compared to runtime. In either case you have access to the complete Elm language—modules, functions, Elm's entire package ecosystem, etc. Also, because elm-css styles are written in Elm, you can put your styles in the same files as your views and still compile them to .css files.

Dead Code Elimination means this has no impact on the compiled .js file; to end users, it would be as if the styles had been defined in a completely separate file all along.

Comparing build-time CSS to runtime CSS

Even in the world where elm-css auto-generates classnames (meaning no more namespace conflicts, and less having to name things), and auto-generates filenames (relevant in a post-Elm 0.19 world, where Elm does asset management and can presumably take care of loading the appropriate auto-generated .css files when they're needed) and has Dead Code Elimination (meaning you can define your styles in the same file as your view logic, without runtime cost), runtime CSS still maintains at least one advantage over build-time CSS:

Properties can be set based on runtime state (e.g. setting opacity to be model.opacity rather than to a constant that was fixed at build time).

In the same way that it's essentially impossible for runtime CSS to have the performance benefits (a single hardcoded string literal instead of parsing and/or assembling data structures on the fly) or caching benefits (given that browsers cache .css files, but don't cache style information contained on elements) of build-time CSS, it's also essentially impossible for build-time CSS to set properties dynamically based on runtime state. There's no real way to design around this tradeoff.

Another problem that elm-css has which, say, styled-components does not, is that elm-css aspires to present a reliable interface to the enormous CSS spec. This means that elm-css has (and will always have) a lot of functions in it. This means that even assuming ideal Dead Code Elimination, anyone who uses elm-css for runtime style creation will accumulate a larger and larger JS download the more different CSS properties they happen to be using.

That said, if class names are automatically generated at build time from style definitions that are located in the same file as the element definitions, then using a "styled elements" API (a la styled-components) becomes as easy as calling withClass instead of needing an entire separate library.

Transformations and Optimizations

There are various build-time transformations and optimizations that would be costly or infeasible to perform at runtime. PostCSS has examples of things like automatic prefixing, minification, and polyfilling.

Precompiled styles also benefit more from gzip. The reason elm-css supports mixin (but not an inheritance feature like @extend in Sass) is that in practice such inheritance features are worse for both performance and download size (because of gzip) than mixins, and it would be better not to introduce a feature that came with the disclaimer "you really ought to use something else instead of this."

One final thing to note: it's possible to further optimize time to interaction by separating "critical styles" from the rest and loading them differently. Fortunately for those who need this, it's already possible in elm-css, so this is a non-issue here.

Choosing the Right Default

Regardless of which approach is best in the here and now, all of the following seem worth pursuing as future goals:

  1. Dead Code Elimination
  2. elm-css can auto-generate classnames (and animation names)
  3. elm-css can auto-generate compiled .css filenames, and Elm can automatically load them when necessary

Given that we are already working toward this world, then what should we work towards in terms of build-time CSS versus runtime CSS?

Here are the pros and cons in this world, assuming we are talking about the sort of runtime CSS that uses style tag injection:

Benefits of runtime CSS

  • Properties can be set based on runtime state (e.g. setting opacity to be model.opacity rather than to a constant that was fixed at build time)

Benefits of build-time CSS

  • Uses fewer system resources. The browser is dealing with a hardcoded string literal (the classname) which indexes into a lookup table of cached styles. Systems which construct and combine objects to accomplish the same thing are going to use strictly more CPU and memory to describe the same style.
  • Less JS to download. In the runtime CSS approach, the actual functions have to exist in the JS, meaning there are more functions for the end user to download.
  • Parallelization. Browsers can download CSS files and parse them in parallel with script tags on the page.
  • Caching. Browsers cache .css files and do not cache styles that are attached to elements.

Relevance of these benefits

We can boil this comparison down to:

  • Runtime CSS can dynamically set properties based on current state
  • Build-time CSS has many performance benefits

Aside from edge cases such as state-based theming (which will naturally gravitates toward the <style> approach until Internet Explorer support becomes sufficiently unimportant that CSS Variables can fill this role instead), the number of properties that need to be set based on current state tends to be extremely small. With a library like mdgriffith/elm-style-animation to do things like animating opacity or left, it becomes vanishingly small.

For those few cases where it's still required, the performance penalty of using the style attribute seems likely to be insignificant. Having those few styles unverified (and thus more error-prone) is probably not the end of the world if it's isolated to that one tiny piece of the code base, and even if it were, writing a tiny library that only added types to those few properties (opacity, left, etc.) would probably be a more reasonable solution than importing all of elm-css.

An important question to ask about any question where the tradeoff is "one has worse performance" is: if the performance ever gets bad, what will we do to optimize it? In the case of runtime CSS, the answer is almost certainly "convert large chunks of styles to build-time CSS until the performance becomes acceptable." This would lead to inconsistencies across the code base, and would be more or less work depending on how often we'd (unnecessarily) coupled our styles to runtime state just because we could.

Conversely, if we embrace build-time CSS and ever need state-based declarations, it is easy to opt into them on a case-by-case basis.

Conclusion

With all this in mind, it seems clear that build-time CSS is the right default. There are many significant performance benefits, and the drawbacks are both minor in comparison and easy to work around.

In the long term, elm-css should build toward being a preprocessor in which you never have to define class names by hand, can define styles in the same file as views, and all the compiled .js knows about is the string literal for the generated class name.