Skip to content

Commit

Permalink
SX: implement styles rehydration
Browse files Browse the repository at this point in the history
I have these changes sitting locally for a very long time, and it's time
to offload them. The idea behind the styles rehydration is very simple:
we collect all existing styles from `<style data-adeira-sx />` (once)
and use this information later for deciding whether the styles should be
injected or not (this is what I call rehydration).

The difference from the current solution is that we do it only once and
we don't have to go through all the styles everytime there is something
to inject.

Motivation for this change is performance: this should significantly
improve the runtime style perf + be much easier to deal with (for
example, we are basically brute-forcing @at rules and pseudo rules now).

Note: this is just a partial solution, there are other changes coming
(custom styles printer and @at nodes/pseudo nodes optimization).
  • Loading branch information
mrtnzlml committed Mar 5, 2022
1 parent 3b3e1e4 commit b974b64
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 83 deletions.
9 changes: 9 additions & 0 deletions src/sx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ In conventional applications, CSS rules are duplicated throughout the stylesheet
- [Automatic vendor prefixes](#automatic-vendor-prefixes)
- [Server-side rendering](#server-side-rendering)
- [Architecture](#architecture)
- [Runtime styles architecture](#runtime-styles-architecture)
- [Prior Art](#prior-art)

## Installation and Usage
Expand Down Expand Up @@ -478,6 +479,14 @@ Internally, these steps are happening:

5. and finally, we collect the values of the final object and print them as `className`

### Runtime styles architecture

Runtime styles are styles that were not rendered by server (are for whatever reason missing or SSR is not enabled). Here is how SX deals with this situation:

1. SX tries to find `<style data-adeira-sx />` (or creates it if it doesn't exist yet) and does "rehydration" where it goes through the existing styles and remembers which ones are already applied.
2. SX performs runtime injection of the styles while checking whether the styles already exist or not.
3. The rest is the same.

## Prior Art

_sorted alphabetically_
Expand Down
6 changes: 1 addition & 5 deletions src/sx/src/StyleCollector.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,8 @@ class StyleCollector {
return sxStyle;
}

addKeyframe(name: string, value: string): boolean {
if (this.#keyframes.has(name)) {
return true;
}
addKeyframe(name: string, value: string): void {
this.#keyframes.set(name, value);
return false;
}

reset(): void {
Expand Down
58 changes: 58 additions & 0 deletions src/sx/src/__tests__/rehydrateStyles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @flow
* @jest-environment jsdom
*/

/* global document */

import { invariant } from '@adeira/js';

import rehydrateStyles from '../rehydrateStyles';

it('correctly rehydrates styles from a style element', () => {
// First, we need to create a `CSSStyleSheet` by actually creating a style element:
const styleElement = document.createElement('style');
document.head?.appendChild(styleElement);
const styleSheet = styleElement.sheet;

invariant(styleSheet != null, 'Unable to create test StyleSheet.');

// Insert some simple CSS rules:
styleSheet.insertRule('._2tPCgL { font-size: 10px; }', 0);
styleSheet.insertRule('._1Kmfck:hover { color: rgba(var(--sx-foreground), 0.5); }', 1);

// Insert some @at rules:
styleSheet.insertRule(
`@media (prefers-reduced-motion: reduce) { .VdrO3.VdrO3 { animation-duration: 1s; } }`,
2,
);
styleSheet.insertRule(
`@media (prefers-reduced-motion: reduce) { ._2tPCgL._2tPCgL { font-size: 10px; } }`,
3,
);
styleSheet.insertRule(
`@keyframes oxCh9 { 33% { transform: translateY(-10px); } 66% { transform: translateY(10px); } }
`,
4,
);

// We should be able to decide whether the style needs to be injected later based on the
// following information:
expect(rehydrateStyles(styleSheet)).toMatchInlineSnapshot(`
Object {
"rehydratedKeyframeRules": Set {
"oxCh9",
},
"rehydratedMediaRules": Map {
"(prefers-reduced-motion: reduce)" => Set {
".VdrO3.VdrO3",
"._2tPCgL._2tPCgL",
},
},
"rehydratedStyleRules": Set {
"._2tPCgL",
"._1Kmfck:hover",
},
}
`);
});
6 changes: 5 additions & 1 deletion src/sx/src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import { invariant, isBrowser, isObjectEmpty, isObject } from '@adeira/js';
import levenshtein from 'fast-levenshtein';

import getStyleSheetFromStyleTag from './getStyleSheetFromStyleTag';
import injectRuntimeStyles from './injectRuntimeStyles';
import rehydrateStyles from './rehydrateStyles';
import styleCollector from './StyleCollector';
import type { AllCSSPropertyTypes } from './css-properties/__generated__/AllCSSPropertyTypes';
import type { AllCSSPseudoTypes } from './css-properties/__generated__/AllCSSPseudoTypes';
Expand Down Expand Up @@ -51,7 +53,9 @@ export default function create<T: SheetDefinitions>(sheetDefinitions: T): Create
const { hashRegistry, styleBuffer } = styleCollector.collect(sheetDefinitions);

if (isBrowser()) {
injectRuntimeStyles(styleBuffer);
const styleSheet = getStyleSheetFromStyleTag();
const rehydratedRules = rehydrateStyles(styleSheet);
injectRuntimeStyles(styleSheet, rehydratedRules, styleBuffer);
}

function sxFunction(maybeObject, ...styleSheetsSelectors) {
Expand Down
40 changes: 40 additions & 0 deletions src/sx/src/getStyleSheetFromStyleTag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// @flow

/* global document */

import { invariant } from '@adeira/js';

// <style data-adeira-sx />
opaque type StyleElementType = HTMLStyleElement | null;
let styleAdeiraSXTag: StyleElementType = null;

/**
* This function will try to get already existing `<style data-adeira-sx />` HTML tag or it will
* create a new virtual one in case it doesn't exist yet.
*/
export default function getStyleSheetFromStyleTag(): CSSStyleSheet {
if (styleAdeiraSXTag === null) {
styleAdeiraSXTag = ((document.querySelector('style[data-adeira-sx]'): any): StyleElementType);
if (styleAdeiraSXTag === null) {
// Still `null` so let's create the style element:
const htmlHead = document.head;
styleAdeiraSXTag = document.createElement('style');
styleAdeiraSXTag.type = 'text/css';
styleAdeiraSXTag.setAttribute('data-adeira-sx', '');
/* $FlowFixMe[incompatible-call] This comment suppresses an error when
* upgrading Flow. To see the error delete this comment and run Flow. */
htmlHead?.appendChild(styleAdeiraSXTag);
}
}

/* $FlowFixMe[incompatible-use] This comment suppresses an error when
* upgrading Flow. To see the error delete this comment and run Flow. */
const styleSheet = styleAdeiraSXTag.sheet;

invariant(
styleSheet != null,
'SX cannot apply runtime styles because HTMLStyleElement.sheet does not exist.',
);

return styleSheet;
}
110 changes: 42 additions & 68 deletions src/sx/src/injectRuntimeStyles.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,28 @@
// @flow

/* global document */

import { invariant, warning } from '@adeira/js';
import { warning } from '@adeira/js';
import { compile, serialize, stringify, prefixer, middleware } from 'stylis';

import StyleCollectorAtNode from './StyleCollectorAtNode';
import StyleCollectorNode from './StyleCollectorNode';
import StyleCollectorPseudoNode from './StyleCollectorPseudoNode';
import type { RehydratedStyles } from './rehydrateStyles';
import type { StyleBufferType } from './StyleCollector';

// <style data-adeira-sx />
opaque type StyleElementType = HTMLStyleElement | null;
let styleAdeiraSXTag: StyleElementType = null;

const getStyleTag = (): CSSStyleSheet => {
if (styleAdeiraSXTag === null) {
styleAdeiraSXTag = ((document.querySelector('style[data-adeira-sx]'): any): StyleElementType);
if (styleAdeiraSXTag === null) {
// Still `null` so let's create the style element:
const htmlHead = document.head;
styleAdeiraSXTag = document.createElement('style');
styleAdeiraSXTag.type = 'text/css';
styleAdeiraSXTag.setAttribute('data-adeira-sx', '');
/* $FlowFixMe[incompatible-call] This comment suppresses an error when
* upgrading Flow. To see the error delete this comment and run Flow. */
htmlHead?.appendChild(styleAdeiraSXTag);
}
}

/* $FlowFixMe[incompatible-use] This comment suppresses an error when
* upgrading Flow. To see the error delete this comment and run Flow. */
const styleSheet = styleAdeiraSXTag.sheet;

invariant(
styleSheet != null,
'SX cannot apply runtime styles because HTMLStyleElement.sheet does not exist.',
);

return styleSheet;
};

function hasStyleRule(match: (CSSRule) => boolean) {
const styleSheet = getStyleTag();
for (const cssRule of styleSheet.cssRules) {
if (match(cssRule)) {
return true;
export function injectRuntimeKeyframes(
styleSheet: CSSStyleSheet,
rehydratedRules: RehydratedStyles,
css: string,
name: string,
) {
const hasStyleRule = (match: (CSSRule) => boolean) => {
for (const cssRule of styleSheet.cssRules) {
if (match(cssRule)) {
return true;
}
}
}
return false;
}

export function injectRuntimeKeyframes(css: string, name: string) {
const styleSheet = getStyleTag();
return false;
};

const matchFunction = (cssRule) => {
if (cssRule.type === CSSRule.KEYFRAMES_RULE) {
Expand All @@ -63,6 +33,7 @@ export function injectRuntimeKeyframes(css: string, name: string) {
};

if (hasStyleRule(matchFunction) === false) {
// TODO: leverage rehydrated rules
const rules = [];
serialize(compile(css), middleware([prefixer, (rule) => rules.push(rule)]));

Expand All @@ -73,44 +44,47 @@ export function injectRuntimeKeyframes(css: string, name: string) {
}

// https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model
export default function injectRuntimeStyles(styleBuffer: StyleBufferType) {
const styleSheet = getStyleTag();

const matchFunction = (node) => (cssRule) => {
if (cssRule.type === CSSRule.STYLE_RULE) {
const styleRule = ((cssRule: any): CSSStyleRule);
if (styleRule.selectorText === `.${node.getHash()}`) {
return true;
}
}
return false;
};

export default function injectRuntimeStyles(
styleSheet: CSSStyleSheet, // TODO: note: we are mutating this
{ rehydratedStyleRules, rehydratedMediaRules, rehydratedKeyframeRules }: RehydratedStyles,
styleBuffer: StyleBufferType,
) {
styleBuffer.forEach((node) => {
const insertIndex = styleSheet.cssRules.length;

if (node instanceof StyleCollectorNode) {
if (hasStyleRule(matchFunction(node)) === false) {
if (rehydratedStyleRules.has(`.${node.getHash()}`) === false) {
// apply missing styles
const rule = serialize(
compile(node.printNodes({ trailingSemicolon: true }).join('')),
middleware([prefixer, stringify]),
);

// Apply the missing styles:
styleSheet.insertRule(rule, insertIndex);

// Register the rule into rehydrated styles, so we don't attempt to process it again:
rehydratedStyleRules.add(`.${node.getHash()}`);
}
} else if (node instanceof StyleCollectorAtNode) {
// TODO: make sure we are not adding already added styles (?)
const rule = serialize(
compile(node.printNodes({ trailingSemicolon: true }).join('')),
middleware([prefixer, stringify]),
);
styleSheet.insertRule(rule, insertIndex);
// TODO: leverage rehydrated rules (to add only new rules)
// TODO: media queries & keyframe rules
if (rehydratedMediaRules.has(node.getAtRuleName().replace(/.*(\(.+\)).*/, '$1')) === false) {
const rule = serialize(
compile(node.printNodes({ trailingSemicolon: true }).join('')),
middleware([prefixer, stringify]),
);
styleSheet.insertRule(rule, insertIndex);
}
} else if (node instanceof StyleCollectorPseudoNode) {
// TODO: leverage rehydrated rules (to add only new rules)
const nodes = node.printNodes({ trailingSemicolon: true });
for (const nodeElement of nodes) {
// TODO: make sure we are not adding already added styles (?)
const rule = serialize(compile(nodeElement), middleware([prefixer, stringify]));
styleSheet.insertRule(rule, insertIndex);
const selectorText = nodeElement.replace(/{.+$/, ''); // TODO: just a quick hack to show where do we go
if (rehydratedStyleRules.has(selectorText) === false) {
const rule = serialize(compile(nodeElement), middleware([prefixer, stringify]));
styleSheet.insertRule(rule, insertIndex);
}
}
} else {
warning(false, 'Node not supported in runtime styles: %j', node);
Expand Down
36 changes: 27 additions & 9 deletions src/sx/src/keyframes.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// @flow

import { isBrowser, nullthrows } from '@adeira/js';
import stringify from 'json-stable-stringify';
import stringifyStable from 'json-stable-stringify';

import getStyleSheetFromStyleTag from './getStyleSheetFromStyleTag';
import hash from './hashStyle';
import type { AllCSSPropertyTypes } from './css-properties/__generated__/AllCSSPropertyTypes';
import rehydrateStyles from './rehydrateStyles';
import styleCollector from './StyleCollector';
import transformStyleName from './transformStyleName';
import transformStyleValue from './transformValue';
import { injectRuntimeKeyframes } from './injectRuntimeStyles';
import type { AllCSSPropertyTypes } from './css-properties/__generated__/AllCSSPropertyTypes';

type KeyFrames = {
+from?: AllCSSPropertyTypes,
Expand All @@ -30,10 +32,27 @@ const extractStyles = (styles: AllCSSPropertyTypes) => {

const transformKey = (key: string) => key.replace(/\s/g, '');

/**
* Helper function generating animation name. Usage:
*
* ```js
* const bounce = sx.keyframes({
* '33%': { transform: `translateY(-1.4em)` },
* '66%': { transform: `translateY(1.4em)` },
* });
*
* const styles = sx.create({
* svgCircle: {
* animationName: bounce,
* }
* // …
* });
* ```
*/
export default function keyframes(styleDefinitions: KeyFrames): string {
let cssDefinition = '';
// `from{maxHeight:0,opacity:0}` should be the same as `from{opacity:0,maxHeight:0}`
const parsedDefinitions: KeyFrames = JSON.parse(stringify(styleDefinitions));
const parsedDefinitions: KeyFrames = JSON.parse(stringifyStable(styleDefinitions));

for (const key of Object.keys(parsedDefinitions)) {
const styleValue = nullthrows(parsedDefinitions[key]);
Expand All @@ -42,13 +61,12 @@ export default function keyframes(styleDefinitions: KeyFrames): string {

const name = hash(cssDefinition);
const frame = `@keyframes ${name} {${cssDefinition}}`;
const exists = styleCollector.addKeyframe(name, frame);
styleCollector.addKeyframe(name, frame);

if (isBrowser() && !exists) {
// It is possible that the keyframe was added on the server
// The StyleCollector will return false, but the injectRuntimeKeyframes checks
// If the rule is already added, so it won't be duplicated
injectRuntimeKeyframes(frame, name);
if (isBrowser()) {
const styleSheet = getStyleSheetFromStyleTag();
const rehydratedRules = rehydrateStyles(styleSheet);
injectRuntimeKeyframes(styleSheet, rehydratedRules, frame, name);
}

return name;
Expand Down
Loading

0 comments on commit b974b64

Please sign in to comment.