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).

Related issue: #1579
  • Loading branch information
mrtnzlml committed Jun 23, 2022
1 parent 0e1eea9 commit ca47c4f
Show file tree
Hide file tree
Showing 16 changed files with 493 additions and 123 deletions.
3 changes: 3 additions & 0 deletions src/abacus-kochka/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
[![Crowdin](https://badges.crowdin.net/kochkacommx/localized.svg)](https://crowdin.com/project/kochkacommx)

- https://kochka.com.mx/
- [PageSpeed Insights](https://pagespeed.web.dev/report?url=https%3A%2F%2Fkochka.com.mx%2Fmenu&form_factor=mobile)

```text
yarn install
yarn dev
Expand Down
8 changes: 4 additions & 4 deletions src/abacus-kochka/pages/_document.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ type RenderPageResult = {

export default class MyDocument extends Document {
// See: https://nextjs.org/docs/advanced-features/custom-document#customizing-renderpage
static async getInitialProps({ renderPage }: DocumentContext): Promise<RenderPageResult> {
const page = await renderPage();
return { ...page, styles: [sx.getStyleTag()] };
}
// static async getInitialProps({ renderPage }: DocumentContext): Promise<RenderPageResult> {
// const page = await renderPage();
// return { ...page, styles: [sx.getStyleTag()] };
// }

render(): Node {
return (
Expand Down
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 @@ -480,6 +481,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
27 changes: 11 additions & 16 deletions src/sx/src/StyleCollector.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { invariant, warning } from '@adeira/js';

import expandShorthandProperties from './expandShorthandProperties';
import printNodes from './printNodes';
import StyleCollectorAtNode from './StyleCollectorAtNode';
import StyleCollectorPseudoNode from './StyleCollectorPseudoNode';
import { type StyleCollectorNodeInterface } from './StyleCollectorNodeInterface';
Expand Down Expand Up @@ -41,7 +42,7 @@ class StyleCollector {
#styleBuffer: StyleBufferType = new Map();
#keyframes: Map<string, string> = new Map();

collect(baseStyleSheet: { +[sheetName: string]: $FlowFixMe }): {
collectStylesheets(baseStyleSheet: { +[sheetName: string]: $FlowFixMe }): {
+hashRegistry: HashRegistryType,
+styleBuffer: StyleBufferType,
} {
Expand Down Expand Up @@ -106,23 +107,17 @@ class StyleCollector {
};
}

print(): string {
let sxStyle = '';
this.#styleBuffer.forEach((node) => {
sxStyle += node.printNodes().join('');
});
this.#keyframes.forEach((node) => {
sxStyle += node;
});
return sxStyle;
collectKeyframe(name: string, value: string): void {
this.#keyframes.set(name, value);
}

addKeyframe(name: string, value: string): boolean {
if (this.#keyframes.has(name)) {
return true;
}
this.#keyframes.set(name, value);
return false;
// TODO: remove
print(): string {
// TODO: print keyframes as well
// this.#keyframes.forEach((node) => {
// sxStyle += node;
// });
return printNodes([...this.#styleBuffer]);
}

reset(): void {
Expand Down
12 changes: 4 additions & 8 deletions src/sx/src/StyleCollectorAtNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,11 @@ export default class StyleCollectorAtNode implements StyleCollectorNodeInterface
this.nodes = new Map([...this.nodes, ...nodes]);
}

getAtRuleName(): string {
return this.atRuleName;
getNodes() {
return this.nodes;
}

printNodes(config?: PrintConfig): $ReadOnlyArray<string> {
let output = '';
this.nodes.forEach((node) => {
output += node.printNodes({ ...config, bumpSpecificity: true }).join('');
});
return [`${this.atRuleName}{${output}}`];
getAtRuleName(): string {
return this.atRuleName;
}
}
14 changes: 8 additions & 6 deletions src/sx/src/StyleCollectorNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export default class StyleCollectorNode implements StyleCollectorNodeInterface {

// eslint-disable-next-line no-unused-vars
addNodes(nodes: Map<string, StyleCollectorNodeInterface>) {
invariant(false, 'StyleCollectorNode cannot have nested nodes,');
invariant(false, 'StyleCollectorNode cannot have nested nodes.');
}

getNodes(): Map<string, StyleCollectorNode> {
invariant(false, 'StyleCollectorNode cannot have nested nodes.');
}

getHash(): string {
Expand All @@ -52,10 +56,8 @@ export default class StyleCollectorNode implements StyleCollectorNodeInterface {
return this.styleValue;
}

printNodes(config?: PrintConfig): $ReadOnlyArray<string> {
const className = `.${this.hash}`.repeat(config?.bumpSpecificity === true ? 2 : 1);
const pseudo = config?.pseudo ?? '';

return [`${className}${pseudo}{${this.styleName}:${this.styleValue}}`];
// CSSStyleRule.selectorText
rehydrationIdentifier() {
return `.${this.getHash()}`;
}
}
2 changes: 1 addition & 1 deletion src/sx/src/StyleCollectorNodeInterface.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ export type PrintConfig = {

export interface StyleCollectorNodeInterface {
addNodes(nodes: Map<string, StyleCollectorNodeInterface>): void;
printNodes(config?: PrintConfig): $ReadOnlyArray<string>;
getNodes(): Map<string, StyleCollectorNodeInterface>;
}
33 changes: 23 additions & 10 deletions src/sx/src/StyleCollectorPseudoNode.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @flow strict

import type { AllCSSPseudoTypes } from './css-properties/__generated__/AllCSSPseudoTypes';
import StyleCollectorNode from './StyleCollectorNode';
import type { PrintConfig, StyleCollectorNodeInterface } from './StyleCollectorNodeInterface';

/**
Expand All @@ -23,29 +25,40 @@ import type { PrintConfig, StyleCollectorNodeInterface } from './StyleCollectorN
* ],
* }
* ```
*
* Note that nesting pseudo classes is not allowed in SX.
*/
export default class StyleCollectorPseudoNode implements StyleCollectorNodeInterface {
pseudo: string;
nodes: Map<string, StyleCollectorNodeInterface>;
nodes: Map<string, StyleCollectorNode>;

constructor(pseudo: string, nodes: Map<string, StyleCollectorNodeInterface>) {
constructor(pseudo: $Keys<AllCSSPseudoTypes>, nodes: Map<string, StyleCollectorNode>) {
this.pseudo = pseudo;
this.nodes = nodes;
}

addNodes(nodes: Map<string, StyleCollectorNodeInterface>) {
addNodes(nodes: Map<string, StyleCollectorNode>) {
this.nodes = new Map([...this.nodes, ...nodes]);
}

getNodes(): Map<string, StyleCollectorNode> {
return this.nodes;
}

getPseudo(): string {
return this.pseudo;
}

printNodes(config?: PrintConfig): $ReadOnlyArray<string> {
let output = [];
this.nodes.forEach((node) => {
output = output.concat(node.printNodes({ ...config, pseudo: this.pseudo }));
});
return output;
}
// printNodes(config?: PrintConfig): $ReadOnlyArray<string> {
// let output = [];
// this.nodes.forEach((node) => {
// output = output.concat(node.printNodes({ ...config, pseudo: this.pseudo }));
// });
// return output;
// }

// CSSStyleRule.selectorText
// rehydrationIdentifier() {
// return `.${this.getHash()}`;
// }
}
78 changes: 78 additions & 0 deletions src/sx/src/__tests__/printNodes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @flow

import printNodes from '../printNodes';
import StyleCollectorAtNode from '../StyleCollectorAtNode';
import StyleCollectorNode from '../StyleCollectorNode';
import StyleCollectorPseudoNode from '../StyleCollectorPseudoNode';

it('prints `StyleCollectorNode` as expected', () => {
expect(
printNodes([
new StyleCollectorNode('color', 'red'),
new StyleCollectorNode('color', 'green'),
new StyleCollectorNode('color', 'blue'),
]),
).toMatchInlineSnapshot(`
"._324Crd{color:#f00}
.mRoJ3{color:#008000}
._2dHaKY{color:#00f}"
`);
});

it('prints `StyleCollectorPseudoNode` as expected', () => {
expect(
printNodes([
new StyleCollectorPseudoNode(
':hover',
new Map([
// TODO: are the map keys necessary (?)
['c0', new StyleCollectorNode('color', 'red')],
['c1', new StyleCollectorNode('color', 'green')],
['c2', new StyleCollectorNode('color', 'blue')],
]),
),
]),
).toMatchInlineSnapshot(`
"._324Crd:hover{color:#f00}
.mRoJ3:hover{color:#008000}
._2dHaKY:hover{color:#00f}"
`);
});

it('prints `StyleCollectorAtNode` as expected', () => {
expect(
printNodes([
new StyleCollectorAtNode(
'@media screen',
new Map([
['c0', new StyleCollectorNode('fontSize', '14')],
[
'c1',
new StyleCollectorPseudoNode(
':hover',
new Map([['c0', new StyleCollectorNode('color', 'pink')]]),
),
],

[
'c2',
new StyleCollectorAtNode(
'@media (max-width: 12cm)',
new Map([['c0', new StyleCollectorNode('color', 'blue')]]),
),
],
]),
),
]),
).toMatchInlineSnapshot(`
"@media screen{
._1fVgat._1fVgat{font-size:14}
._3ncx7d._3ncx7d:hover{color:#ffc0cb}
@media (max-width: 12cm){
._2dHaKY._2dHaKY{color:#00f}
}
}"
`);
});

// TODO: prefixing
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",
},
}
`);
});
8 changes: 6 additions & 2 deletions 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 @@ -48,10 +50,12 @@ export default function create<T: SheetDefinitions>(sheetDefinitions: T): Create
`Function 'sx.create' cannot be called with empty stylesheet definition.`,
);

const { hashRegistry, styleBuffer } = styleCollector.collect(sheetDefinitions);
const { hashRegistry, styleBuffer } = styleCollector.collectStylesheets(sheetDefinitions);

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

function sxFunction(maybeObject, ...styleSheetsSelectors) {
Expand Down
Loading

0 comments on commit ca47c4f

Please sign in to comment.