The Dark Side of
Polyfilling CSS

What polyfill authors won't tell you

Philip Walton / philipwalton.com / @philwalton

CSS Houdini on Smashing Magazine

What's so hard about polyfilling CSS?

Let's find out by writing one ourselves…

A new CSS keyword

random: a number between 0 and 1

.foo { width: calc(**random** * 100%); background: hsl(calc(**random** * 360), 50%, 50%); opacity: **random**; }

How do you tell the browser about a keyword it doesn't already understand?

In JavaScript you'd do something like this:

if (typeof Math.random != 'function') { Math.random = function() { // Implement polyfill here... }; }

but you can't write imperative code in CSS.

How CSS polyfills work

Fundamentally, all CSS polyfills do the same thing.

They turn code the browser doesn't understand
into code the browser does understand.

calc(**random** * 100%);
calc(**0.35746** * 100%);

Okay, so we have to update the CSS,
but where do we do that?

The CSS Object Model

  • document.styleSheets
  • CSSStyleSheetList
  • CSSStyleSheet
  • CSSRuleList
  • CSSStyleRule
  • CSSStyleDeclaration
for (const stylesheet of document.styleSheets) { // Flatten nested rules (@media blocks, etc.) into a single array. const rules = [...stylesheet.rules].reduce((prev, next) => { return prev.concat(next.cssRules ? [...next.cssRules] : [next]); }, []); for (const rule of rules) { for (const property of Object.keys(rule.style)) { const value = rule.style[property]; if (value.includes('random')) { rule.style[property] = **value.replace('random', Math.random());** } } } }

If the full CSS isn't in the CSSOM, where is it?

Nowhere. You have to search for it manually.

Getting all CSS style rules

const getPageStyles = () => { // Query the document for any element that could have styles. var styleElements = [...**document.querySelectorAll('style, link[rel="stylesheet"]')**]; // Fetch all styles and ensure the results are in document order. // Resolve with a single string of CSS text. return Promise.all(styleElements.map((el) => { if (el.href) { return **fetch(el.href).then((response) => response.text());** } else { return **el.innerHTML;** } })).then((stylesArray) => stylesArray.join('\n')); }

Now that I have all the page
styles, what do I do next?

You need to parse the styles,
so they can be more easily manipulated.

Okay, can the browser
help me parse the CSS?

#lols

Parsing the raw CSS text

There are many open source parsers that will convert
a string of CSS into an abstract syntax tree (AST).

For this polyfill we'll use PostCSS.

The PostCSS AST

.progress-bar { width: calc(random * 100%); }
{ "type": "root", "nodes": [ { "type": "rule", "selector": ".progress-bar", "nodes": [ { "type": "decl", "prop": "width", "value": "calc(random * 100%)" } ] } ] }

Inspecting the parsed CSS

import postcss from 'postcss'; import getPageStyles from './get-page-styles'; getPageStyles() .then((css) => **postcss.parse(css)**) .then((ast) => console.log(ast));

Checking In

Up to this point, we've written a lot of code…

…but none of it has anything to do with the
core functionality of our polyfill.

Next steps

Modifying the AST

const randomKeywordPlugin = postcss.plugin('random-keyword', () => { return (css) => { css.walkRules((rule) => { rule.walkDecls((decl, i) => { if (decl.value.includes('random')) { decl.value = **decl.value.replace('random', Math.random());** } }); }); }; });

Replacing the page styles

const replacePageStyles = (css) => { // Get a reference to all existing style elements. const existingStyles = [...document.querySelectorAll('style, link[rel="stylesheet"]')]; // Create a new <style> tag with all the polyfilled styles. const polyfillStyles = document.createElement('style'); polyfillStyles.innerHTML = css; **document.head.appendChild(polyfillStyles);** // Remove the old styles once the new styles have been added. existingStyles.forEach((el) => el.parentElement.removeChild(el)); };

Putting the polyfill together

import postcss from 'postcss'; import getPageStyles from './get-page-styles'; import randomKeywordPlugin from './random-keyword-plugin'; import replacePageStyles from './replace-page-styles'; getPageStyles() .then((css) => postcss([**randomKeywordPlugin**]).process(css)) .then((result) => replacePageStyles(result.css));

How can we update the polyfill to target individual elements?

Option #1

Add inline styles to every element matching the rule selector.

// ... rule.walkDecls((decl, i) => { if (decl.value.includes('random')) { const elements = **document.querySelectorAll(rule.selector);** for (const element of elements) { **element.style[decl.prop]** = decl.value.replace('random', Math.random()); } } }); // ...

Option #2

Check the rest of the CSS for matching rules, and then only replace the random keyword with a random number and apply those declarations as inline styles if it's the last matching rule. Wait, that won't work, because we have to account for specificity, so we'll have to manually parse each selector to calculate it. Then we can sort the matching rules in specificity order from low to high, and only apply the declarations from the most specific selector. Oh and then there's @media rules, so we'll have to manually check for matches there as well. And speaking of at-rules, there's also @supports—can't forget about that. And lastly we'll have to account for property inheritance, so for each element we'll have to traverse up the DOM tree and inspect all its ancestors to get the full set of computed properties. Oh, sorry, one more thing: we'll also have to account for !important, which is calculated per-declaration instead of per-rule, so we'll have to maintain a separate mapping for that to figure out which declaration will ultimately win.

Hold up, didn't you just describe
the cascade?

Option #3

Rewrite the CSS to target individual elements
while maintaining cascade order.

* { box-sizing: border-box; } **p {** opacity: random; } .foo { opacity: initial; }
* { box-sizing: border-box; } p**[data-pid="1"]** { opacity: .23421; } p**[data-pid="2"]** { opacity: .82305; } p**[data-pid="3"]** { opacity: .31178; } .foo { opacity: initial; } *​**:not(.z)** { box-sizing: border-box; } p**[data-pid="1"]** { opacity: .23421; } p**[data-pid="2"]** { opacity: .82305; } p**[data-pid="3"]** { opacity: .31178; } .foo**:not(.z)** { opacity: initial; }

Adding a unique ID to each element…

css.walkRules((rule) => { const newRules = {}; rule.walkDecls((decl, i) => { if (decl.value.match('random')) { for (const el of **document.querySelectorAll(rule.selector)**) { const **pid** = el.dataset.pid || (el.dataset.pid = getUniqueId()); const **newRule** = newRules[pid] || (newRules[pid] = rule.clone({ selector: appendToSelectors(rule.selector, **`[data-pid="${pid}"]`**), nodes: [], })); newRule.nodes.push(decl.clone({ value: **decl.value.replace('random', Math.random())**, })); } } // ...

"Up-specifizing" the other rules…

// ... // Clone the current rule and update the selector. rule.parent.insertBefore(rule, rule.clone({ selector: appendToSelectors(rule.selector, **':not(.z)'**) })) // Insert all the new rules before the current rule. for (const id of Object.keys(newRules)) { rule.parent.insertBefore(rule, newRules[id]); } // Remove the current rule and continue iterating. rule.remove(); }); });

The final plugin code

import postcss from 'postcss'; import randomKeywordPlugin from './random-keyword-plugin'; import getPageStyles from './get-page-styles'; import replacePageStyles from './replace-page-styles'; getPageStyles() .then((css) => postcss([randomKeywordPlugin]).process(css)) .then((result) => replacePageStyles(result.css));

Zomg! You've solved everything!

Unresolved issues

Unavoidable problems

Understanding the performance implications

Wrapping Up

Things the browser already does,
but that we can't use in CSS polyfills:

  • Fetching the CSS
  • Parsing the CSS
  • Creating the CSSOM
  • Handling the Cascade
  • Invalidating styles
  • Revalidating styles

Without Houdini APIs,
CSS polyfills will inevitably be:

Final thoughts

Fin

Twitter
@philwalton
Website
philipwalton.com
Github
github.com/philipwalton
Slides
github.com/philipwalton/talks