Linaria: Polyfill CSS Variables Support

Created on 10 Sep 2017  路  6Comments  路  Source: callstack/linaria

API

The API for writing styles remains the same. For example:

const title = css`
  color: var(--title-color);
`;

When using this class name in a component:

function MyComponent({ color }) {
  return <h1 {...styles(title, { '--title-color': color })} />;
}

How to implement

  1. Write a Babel plugin which looks for styles calls with variables and assigns an unique ID for each. Something like:
    js styles(title, { __id: 'xgfs65d', '--title-color': color });
    This ID is necessary so that we can keep track of dynamic styles and avoid inserting same styles multiple times. The ID should be unique, but shouldn't change between builds. The easiest way is to hash the current file path and the sequential number in the file.
  2. We will need to generate an AST from the CSS string. We can probably use the ast plugin for stylis to generate the ast, but I had a broken ast when I used nested media queries. We will probably need to fix the plugin. We will also need to write a simple serializer which takes the AST and serializes it to plain CSS string.
  3. The css tagged template literal should parse the CSS string to an AST to determine if there are CSS variables in the string. It should generate 2 different CSS strings, one with properties using variables, and one without. The one without is inserted normally to the sheet API, but the ones with variables is returned as a templates. It's an array because there can be multiple rules. The returned object from css can look like following:
    js { toString() { return 'xgsf64f'; }, templates: ['.xgsf64f { color: var(--title-color); }'] }
    Something to keep in mind is that if a property is redeclared after a variable use, we should discard the one with variable, i.e. color: var(--title-color); color: blue should be considered as color: blue.
  4. The names function should be updated to look for these objects and use toString when necessary
  5. The styles function should look for templates and do a search replace in them with the value of the variables, then insert it to the CSSOM with the insertRule API. If CSS custom properties are supported in the browser, it should avoid the search replace and use inline styles to set the variable values.
  6. The babel presets should be separated to client and server presets, where the server preset excludes the preval-extract extract plugin and includes a plugin which just converts css to css.named calls.
  7. Server rendering APIs and instructions should be updated to read the stylesheet from the string in memory instead of from a file.
  8. This should be configurable in the Babel preset options. If the polyfill behaviour is disabled, templates shouldn't be generated and inline styles should be used to set the variables instead.

Advantages

  1. A consistent prop-based way to use dynamic styles with components
  2. Variables don't cascade anymore, and results are more predictable
  3. No overhead for browsers which already support CSS variables

Disadvantages

  1. Some overhead for browsers which don't support CSS variables

Most helpful comment

Crazy experiment

This is just an experiment and I don't want to add this to linaria.

Babel plugin which converts styled-component like syntax to linaria syntax:

var Title = styled('div')`
  color: ${props => props.color};
  background: ${props => props.background};
  text-align: center;
  font-family: ${font.family};
`;

becomes:

var Title = props => <div
  {...props}
  {...styles(_title, props.className, {
    "--prop-0": (props => props.color)(props),
    "--prop-1": (props => props.background)(props)
  })} />;

var _title = css`
  color: ${"var(--prop-0)"};
  background: ${"var(--prop-1)"};
  text-align: center;
  font-family: ${font.family};
`;

plugin implementation:

export default function(babel) {
  const { types: t } = babel;

  return {
    visitor: {
      TaggedTemplateExpression(path, state) {
        if (t.isCallExpression(path.node.tag) && path.node.tag.callee.name === "styled") {
          const tagName = path.node.tag.arguments[0].value;
          const variableName = path.parentPath.node.id.name;
          const interpolations = {};
          const node = path.node;

          node.tag = t.identifier("css");
          node.quasi.expressions = node.quasi.expressions.map((ex, i) => {
            if (t.isArrowFunctionExpression(ex)) {
              const name = `--prop-${i}`;
              interpolations[name] = ex;
              return t.stringLiteral(`var(${name})`);
            }
            return ex;
          });

          let parent = path.parentPath;

          while (t.isVariableDeclarator(parent) || t.isExportNamedDeclaration(parent.parentPath)) {
            parent = parent.parentPath;
          }

          const className = parent.scope.generateUidIdentifier(variableName.toLowerCase()).name;

          parent.insertAfter(t.variableDeclaration("var", [t.variableDeclarator(t.identifier(className), node)]));
          path.replaceWith(
            t.arrowFunctionExpression(
              [t.identifier("props")],
              t.jSXElement(
                t.jSXOpeningElement(
                  t.jSXIdentifier("div"),
                  [
                    t.jSXSpreadAttribute(t.identifier("props")),
                    t.jSXSpreadAttribute(
                      t.callExpression(t.identifier("styles"), [
                        t.identifier(className),
                        t.identifier("props.className"),
                        t.objectExpression(
                          Object.keys(interpolations).map(it =>
                            t.objectProperty(
                              t.stringLiteral(it),
                              t.callExpression(interpolations[it], [t.identifier("props")])
                            )
                          )
                        )
                      ])
                    )
                  ],
                  true
                ),
                null,
                []
              )
            )
          );
        }
      }
    }
  };
}

All 6 comments

Alternative (better) approach

const title = css`
  color: var(--title-color);
  font-family: sans-serif;

  &:hover {
    color: var(--title-color-active);
  }

  @media (max-width: 600px) {
    color: var(--title-color-small);
  }
`;

is transpiled into something like:

const title = {
  toString() { return 'title_456fgf' },
  template: '.title_jhdsj5h { color: var(--title-color); }.title_jhdsj5h:hover { color: var(--title-color-active); }@media (max-width: 600px) { color: var(--title-color-small); }';
};

basically, the dynamic part is extracted from the static parts after pre-compiling and stored as a template. this makes it much more performant and super easy to do dynamic styling and also we don't have to deal with cors issues.

Crazy experiment

This is just an experiment and I don't want to add this to linaria.

Babel plugin which converts styled-component like syntax to linaria syntax:

var Title = styled('div')`
  color: ${props => props.color};
  background: ${props => props.background};
  text-align: center;
  font-family: ${font.family};
`;

becomes:

var Title = props => <div
  {...props}
  {...styles(_title, props.className, {
    "--prop-0": (props => props.color)(props),
    "--prop-1": (props => props.background)(props)
  })} />;

var _title = css`
  color: ${"var(--prop-0)"};
  background: ${"var(--prop-1)"};
  text-align: center;
  font-family: ${font.family};
`;

plugin implementation:

export default function(babel) {
  const { types: t } = babel;

  return {
    visitor: {
      TaggedTemplateExpression(path, state) {
        if (t.isCallExpression(path.node.tag) && path.node.tag.callee.name === "styled") {
          const tagName = path.node.tag.arguments[0].value;
          const variableName = path.parentPath.node.id.name;
          const interpolations = {};
          const node = path.node;

          node.tag = t.identifier("css");
          node.quasi.expressions = node.quasi.expressions.map((ex, i) => {
            if (t.isArrowFunctionExpression(ex)) {
              const name = `--prop-${i}`;
              interpolations[name] = ex;
              return t.stringLiteral(`var(${name})`);
            }
            return ex;
          });

          let parent = path.parentPath;

          while (t.isVariableDeclarator(parent) || t.isExportNamedDeclaration(parent.parentPath)) {
            parent = parent.parentPath;
          }

          const className = parent.scope.generateUidIdentifier(variableName.toLowerCase()).name;

          parent.insertAfter(t.variableDeclaration("var", [t.variableDeclarator(t.identifier(className), node)]));
          path.replaceWith(
            t.arrowFunctionExpression(
              [t.identifier("props")],
              t.jSXElement(
                t.jSXOpeningElement(
                  t.jSXIdentifier("div"),
                  [
                    t.jSXSpreadAttribute(t.identifier("props")),
                    t.jSXSpreadAttribute(
                      t.callExpression(t.identifier("styles"), [
                        t.identifier(className),
                        t.identifier("props.className"),
                        t.objectExpression(
                          Object.keys(interpolations).map(it =>
                            t.objectProperty(
                              t.stringLiteral(it),
                              t.callExpression(interpolations[it], [t.identifier("props")])
                            )
                          )
                        )
                      ])
                    )
                  ],
                  true
                ),
                null,
                []
              )
            )
          );
        }
      }
    }
  };
}

If anyone is interested in implementing or discussing, I updated the proposal with the steps needed to achieve this.

I was trying to do something similar with this plugin custom-properties

If your open to a runtime-only variant, you could create a queue of rules that use variables and process them at the end of a parse if the css spec allows you to use variables before you declare them.

We will need to generate an AST from the CSS string. We can probably use the ast plugin for stylis to generate the ast, but I had a broken ast when I used nested media queries.

Do you have a test case i can use to see this.

If your open to a runtime-only variant

I think we would really prefer to have no runtime as that's the main idea behind the library. Curious to see your approach though.

Do you have a test case i can use to see this.

Not right now, but I'll prepare a test case.

We are not going to support this.

Was this page helpful?
0 / 5 - 0 ratings