Try   HackMD

RFC: Lazy-loadable, type-safe, scalable style support

[ HackMD | #2767 ]

Goal

Have an ergonomic way of styling components where the styles are:

  • lazy-loadable: Load the styles on as needed basis.
  • type-safe: Developer should not worry that they have miss placed a style, or should feel that refactoring / renaming styles is hard.
  • tree-shakable: System should be able to remove unneeded styles.
  • Scalable: It should work on small as well as large projects. Should work with MFE composition.

Prior Art

A quick overview of existing styling solutions and their pros-cons.

Inlinable Scopes Style Composable Type Safe Needs Name Scope DOM
Pure CSS
Emotion
Vanila Extract
CSS Modules
useStyleScope$()
Tailwind
StyleX

Legend:

  • Inlinable: The developer should be able to place the styling inline with the markup. Why? Because creating a new file breaks the flow of the coding. Putting things in new file requires: Choosing a file name; Choosing a file location; Choosing the symbol name (name of class) which is a source of typos (see type-safe.); Moving styles between files does not feel safe (see type-safety.)
    I think this is part of the reason why CSS-in-JS, and tailwind are so popular, because they don't derail the developer flow. The developer styles the element and moves on without having to make any decisions.
  • Scopes Style: Large applications run into the risk of colliding style names. So a good solution should remove collision risk from the developers hands. There are two approaches: (1) mangle the style-names AND (2) append additional scoping selector and add the additional class to each element. We think that adding additional selectors to the DOM elements is sub-optimal because it creates issues such as: https://github.com/BuilderIO/qwik/issues/2726; https://github.com/BuilderIO/qwik/issues/2071; https://github.com/BuilderIO/qwik/issues/1710; and https://github.com/BuilderIO/qwik/discussions/1063.
  • Composable: It should be easy to reuse same styles across many different DOM elements/components. (Here I think tailwind is excluded because each element repeats all of the stylings over again.)
  • Type Safe: If developers are forced to create names for the classes, than these names should be type-safe, in the sense that a type (or refactoring) should tell the developer which code is incorrect. (Right now current CSS is not typesafe, which makes it append only kind of coding, as developers are afraid to remove selectors or rename them for a fear of breaking something.)
  • Needs Name (CON): Asking the developer to create a name should not be required. It is just extra work and decisions that they need to make. Things which are inlineable naturally do not need a name.
  • Scope DOM (CON): Scoping the DOM is suboptimal as we need to add the scoping information to each DOM element as we don't know ahead of time if it will be needed. Also the thing to scope should be the CSS not the DOM elements.

Influence

There is an interesting article on Atomic CSS called StyleX This points out that as applications get very large the need for additional CSS reaches zero growth. This is because the styling is broken up into primitives which are then reused. We think this is a good approach for Qwik as well.

Proposal

import { component$, CSS$ } from "@builder.io/qwik";

export default component$(() => {
  return (
    <div class={CSS$`border: 1px solid green`}>
      <Greeter class={CSS$`color: red`} name="World" />
    </div>
  );
});

export const Greeter = component$<{ name: string; class: any }>((props) => {
  return <span class={props.class}>Hello {props.name}!</span>;
});

The basic idea is to create a CSS$ tagged string literal which can be used as:

const redBorderFromString = CSS$`
  border: 1px solid red; 
  border-radius: 50%
`;
const redBorderFromObjLiteral = CSS$({
  border: '1px solid red',
  border-radiues: '50%',
});

Both of the above examples are equally supported and are identical. Advantages between the two approaches are:

  • redBorderFromString: devs can cut&paste from the dev-tools. Downside is that to get code completion they have to install an editor plugin.
  • redBorderFromObjLiteral: TypeScript can verify types, but cut&paste would not work from dev-tools/existing CSS.

Pseudo Selectors and Media Queries

const hover = CSS$.hover`color: blue`
const hoverOL = CSS$.hover({ 'color': blue });

const smallScreen$ = MEDIA$`screen and (min-width: 480px)`;

const media = smallScreen$.hover`background-color: lightgreen`;
const mediaOL = smallScreen$.hover({backgroundColor: 'lightgreen';});

Return value

The return value of CSS$ is an opaque object which can contain 1 or more classes along with the associated QRLs. The returned values can be composed together in markup.

<div class={[redBorder,  blueText, etc...]}/>

Transformation

As you can see the CSS$ ends with $ which means it is subject to optimizer and lazy-loading.

import { component$, CSS$ } from "@builder.io/qwik";

const redBorder = CSS$`border: 1px solid red; border-radius: 50%`;

Will be transformed to:

import {QRL_PREFIX} from "@builder.io/qwik";
const redBorder = QRL_PREFIX + 'HASH_OF_JS#HASH_OF_STYLE1#HASH_OF_STYLE2';

file: HASH_OF_JS

export const HASH_OF_STYLE1="border:1px solid red";
export const HASH_OF_STYLE2="border-radius:50%";

NOTE: exact implementation to be determined and may be different.

Runtime & SSR

The Qwik runtime will be able to recognize the strings which are QRLs and will know to load a specific JS files and create <style> tags from those JS files.

The SSR will insert the <style> tags into the corresponding SSR output.

Because the styles are just JS loaded through QRLs, existing prefetching and bundling system will be able to optimize the loading of the styles.

The runtime can easily see which styles have already been loaded and which still need to be loaded.

No need for useStyle$()

With the CSS$ approach there is no need for useStyle$() to load the styles. The renderer is now intelligent enough to recognize when a QRL is being passed into the class and if it needs to be loaded. Because the rendering can delay flushing of the UI to DOM, the renderer can load the CSS without causing a flash of unstyled content.

Constraints

The CSS$ will be able to refer to static content only. So things like this will not be supported and will be a compiled error.:

const redBorder = CSS$`border: ${Math.rand()}px solid red`;

If the CSS needs to have variable, than CSS variables should be used.

Thoughts on CSS

CSS selectors can have complex rules such as body>ul>li. We think such rules are very hard to reason about and make the CSS append only as devs are worried that changing them will break something. Such rules are also hard to tree-shake for.

We think for styling components such rules are an anti-pattern and will not be supported by the CSS$ which has one-to-one connection.

Instead if you want to use such complex rules, global.css is a good place to put them, but you lose the ability to lazy load such rules.

Advantages

  • Inlineable: The resulting syntax is inlineable into JSX markup. This allows the developer to naturally style the components without thinking how to, name the class, the file, or how to structure the files.
  • Type-safe: There is no risk that a class name is miss-spelled resulting in incorrect styling.
  • Scoped: Styles are automatically scoped, so there is no risk of leakage to other components.
  • Composable: multiple the CSS$ can be composed together or grouped into arrays and referred to by other JSX.
  • Lazy-loadable: The runtime is able to lazy load the styles without having the developer spending time to think about lazy loading.