Skip to content

Commit 52b5691

Browse files
authored
Merge pull request #67 from AlexandrHoroshih/feat-serializable-style-def
Add APIs to support serializable lookup table
2 parents 270067c + 35c42d0 commit 52b5691

7 files changed

+252
-13
lines changed

README.md

+60-12
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,57 @@ import { enableReactOptimization } from 'used-styles';
134134
enableReactOptimization(); // just makes it a but faster
135135
```
136136

137+
## Serialize API
138+
139+
Use it to separate generation of styles lookup from your runtime.
140+
141+
It is useful in cases, where you can't directly use Discovery APIs on your client CSS bundles during app's runtime, e.g. various serverless runtimes.
142+
Also it may be useful for you, if you want to save on the size of your container for the server app, since it allows you to only load styles lookup into it, without CSS bundles.
143+
144+
1. `serializeStylesLookup(def: StyleDef): SerializedStyleDef` - creates a serializable object from original styles lookup. Result can be then stringified with `JSON.stringify`
145+
2. `loadSerializedLookup(def: SerializedStyleDef): StyleDef` - transforms serialized style definition back to normal `StyleDef`, which can be used with any Scanner API
146+
147+
### Example
148+
149+
#### During your build
150+
151+
1. Add separate script to generate style lookup and store it as you like.
152+
```js
153+
// project/scripts/generate_styles_lookup.mjs
154+
import { serializeStylesLookup, discoverProjectStyles } from 'used-styles'
155+
import { writeFileSync } from 'fs'
156+
157+
const stylesLookup = discoverProjectStyles('./path/to/dist/client');
158+
159+
await stylesLookup;
160+
161+
writeFileSync('./path/to/dist/server/styles-lookup.json', JSON.stringify(serializeStyles(lookup)))
162+
```
163+
2. Run this code after your build
164+
```sh
165+
yarn build
166+
node ./scripts/generate_styles_lookup.mjs
167+
```
168+
169+
Notice, that you can store serialized lookup in any way, that suits you and your case, example above is not the only valid option.
170+
171+
#### During your runtime
172+
173+
1. Access previously created and stored styles lookup, convert it to `StyleDef` with `loadSerializedLookup` and use it normally
174+
```js
175+
import { loadSerializedLookup } from 'used-styles'
176+
177+
const stylesLookup = loadSerializedLookup(require('./dist/server/styles-lookup.json');
178+
179+
// ...
180+
181+
getCriticalStyles(markup, stylesLookup)
182+
```
183+
137184
# Example
138185
139186
## Demo
187+
140188
- [React SSR](/example/ssr-react/README.md)
141189
- [React SSR + TS](/example/ssr-react-ts/README.md)
142190
- [React Streaming SSR](/example/ssr-react-streaming/README.md)
@@ -197,10 +245,10 @@ similar how StyledComponents works
197245
198246
```js
199247
import express from 'express';
200-
import {
201-
discoverProjectStyles,
248+
import {
249+
discoverProjectStyles,
202250
loadStyleDefinitions,
203-
createCriticalStyleStream,
251+
createCriticalStyleStream,
204252
createStyleStream,
205253
createLink,
206254
} from 'used-styles';
@@ -209,9 +257,9 @@ const app = express();
209257

210258
// generate lookup table on server start
211259
const stylesLookup = isProduction
212-
? discoverProjectStyles('./dist/client')
213-
// load styles for development
214-
: loadStyleDefinitions(async () => []);
260+
? discoverProjectStyles('./dist/client')
261+
: // load styles for development
262+
loadStyleDefinitions(async () => []);
215263

216264
app.use('*', async (req, res) => {
217265
await stylesLookup;
@@ -222,7 +270,7 @@ app.use('*', async (req, res) => {
222270
// create a style steam
223271
const styledStream = createStyleStream(stylesLookup, (style) => {
224272
// _return_ link tag, and it will be appended to the stream output
225-
return createLink(`dist/${style}`) // <link href="dist/mystyle.css />
273+
return createLink(`dist/${style}`); // <link href="dist/mystyle.css />
226274
});
227275

228276
// or create critical CSS stream - it will inline all styles
@@ -245,7 +293,7 @@ const ABORT_DELAY = 10000;
245293

246294
async function renderApp({ res, styledStream }) {
247295
let didError = false;
248-
296+
249297
const { pipe, abort } = renderToPipeableStream(
250298
<React.StrictMode>
251299
<App />
@@ -262,7 +310,7 @@ async function renderApp({ res, styledStream }) {
262310
res.write(`<!DOCTYPE html><html><head><script defer src="client.js"></script></head><body><div id="root">`);
263311

264312
styledStream.pipe(res, { end: false });
265-
313+
266314
// start by piping react and styled transform stream
267315
pipe(styledStream);
268316

@@ -273,8 +321,8 @@ async function renderApp({ res, styledStream }) {
273321
onError(error) {
274322
didError = true;
275323
console.error(error);
276-
}
277-
},
324+
},
325+
}
278326
);
279327

280328
setTimeout(() => {
@@ -297,7 +345,7 @@ import ReactDOM from 'react-dom/client';
297345
import App from './App';
298346

299347
// Call before `ReactDOM.hydrateRoot`
300-
moveStyles()
348+
moveStyles();
301349

302350
ReactDOM.hydrateRoot(
303351
document.getElementById('root'),

__tests__/__snapshots__/react.integration.spec.tsx.snap

+13
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ input { display: none; }
1313
}</style>"
1414
`;
1515
16+
exports[`File based css stream works with (de)serialized styles definition 1`] = `
17+
"<style type=\\"text/css\\" data-used-styles=\\"file1.css,file2.css\\">html { color: htmlRED; }
18+
input { display: none; }
19+
@keyframes ANIMATION_NAME {
20+
0% {
21+
-webkit-transform: rotate(0deg);
22+
}
23+
to {
24+
-webkit-transform: rotate(359deg);
25+
}
26+
}</style>"
27+
`;
28+
1629
exports[`React css stream React.renderToStream critical 1`] = `
1730
"<style type=\\"text/css\\" data-used-styles=\\"file1,file2\\">input { color: rightInput; }
1831
.a,

__tests__/extraction.spec.tsx

+82-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { alterProjectStyles, getCriticalRules, loadStyleDefinitions, StyleDefinition } from '../src';
1+
import {
2+
alterProjectStyles,
3+
loadSerializedLookup,
4+
getCriticalRules,
5+
loadStyleDefinitions,
6+
serializeStylesLookup,
7+
StyleDefinition,
8+
} from '../src';
29

310
describe('extraction stories', () => {
411
it('handles duplicated selectors', async () => {
@@ -208,6 +215,80 @@ describe('extraction stories', () => {
208215
`);
209216
});
210217

218+
describe('Serializable definitions', () => {
219+
test('Serialized defintion is equal to original', async () => {
220+
const styles: StyleDefinition = loadStyleDefinitions(
221+
() => ['test.css'],
222+
() => `
223+
@media screen and (min-width:1350px){.content__L0XJ\\+{color:red}}
224+
.primary__L4\\+dg{ color: blue}
225+
.primary__L4+dg{ color: wrong}
226+
`
227+
);
228+
await styles;
229+
230+
const serializedDefinition = JSON.stringify(serializeStylesLookup(styles));
231+
const deserializedDefinition = loadSerializedLookup(JSON.parse(serializedDefinition));
232+
233+
expect(deserializedDefinition.lookup).toEqual(styles.lookup);
234+
expect(deserializedDefinition.ast).toEqual(styles.ast);
235+
expect(deserializedDefinition.urlPrefix).toEqual(styles.urlPrefix);
236+
expect(deserializedDefinition.isReady).toEqual(styles.isReady);
237+
expect(typeof deserializedDefinition.then).toEqual(typeof styles.then);
238+
});
239+
240+
test('Serializing unready definition throws', async () => {
241+
const styles: StyleDefinition = loadStyleDefinitions(
242+
async () => ['test.css'],
243+
async () => `
244+
@media screen and (min-width:1350px){.content__L0XJ\\+{color:red}}
245+
.primary__L4\\+dg{ color: blue}
246+
.primary__L4+dg{ color: wrong}
247+
`
248+
);
249+
250+
expect(() => serializeStylesLookup(styles)).toThrowErrorMatchingInlineSnapshot(
251+
`"used-styles: style definitions are not ready yet. You should \`await discoverProjectStyles(...)\`"`
252+
);
253+
});
254+
255+
test('Invalid value in serializers throws', async () => {
256+
expect(() => serializeStylesLookup({} as any)).toThrowErrorMatchingInlineSnapshot(
257+
`"used-styles: style definitions has to be created using discoverProjectStyles or loadStyleDefinitions"`
258+
);
259+
260+
expect(() => loadSerializedLookup({} as any)).toThrowErrorMatchingInlineSnapshot(
261+
`"used-styles: serialized style definition should be created with serializeStylesLookup"`
262+
);
263+
264+
expect(() => loadSerializedLookup('invalid' as any)).toThrowErrorMatchingInlineSnapshot(
265+
`"used-styles: got a string instead of serialized style definition object, make sure to parse it back to JS object first"`
266+
);
267+
});
268+
269+
test('Serialized defintion is awaitable just like original', async () => {
270+
const styles: StyleDefinition = loadStyleDefinitions(
271+
() => ['test.css'],
272+
() => `
273+
@media screen and (min-width:1350px){.content__L0XJ\\+{color:red}}
274+
.primary__L4\\+dg{ color: blue}
275+
.primary__L4+dg{ color: wrong}
276+
`
277+
);
278+
await styles;
279+
280+
const serializedDefinition = JSON.stringify(serializeStylesLookup(styles));
281+
const deserializedDefinition = loadSerializedLookup(JSON.parse(serializedDefinition));
282+
283+
await deserializedDefinition;
284+
285+
const resolve = jest.fn();
286+
await deserializedDefinition.then(resolve);
287+
288+
expect(resolve).toBeCalledTimes(1);
289+
});
290+
});
291+
211292
describe('CSS Cascade Layers', () => {
212293
it('handles CSS Cascade Layers', async () => {
213294
const styles = loadStyleDefinitions(

__tests__/react.integration.spec.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
getCriticalStyles,
1414
getUsedStyles,
1515
parseProjectStyles,
16+
serializeStylesLookup,
17+
loadSerializedLookup,
1618
} from '../src';
1719
import { StyleDefinition } from '../src/types';
1820

@@ -104,6 +106,47 @@ describe('File based css stream', () => {
104106

105107
expect(usedCritical).toMatch(/htmlRED/);
106108
});
109+
110+
test('works with (de)serialized styles definition', async () => {
111+
await styles;
112+
113+
const lookupAfterSerialization = loadSerializedLookup(JSON.parse(JSON.stringify(serializeStylesLookup(styles))));
114+
115+
expect(getUsedStyles('', lookupAfterSerialization)).toEqual(['file1.css']);
116+
expect(getCriticalStyles('', lookupAfterSerialization)).toMatchSnapshot();
117+
118+
const output = renderToString(
119+
<div>
120+
<div className="only someclass">
121+
<div className="another class11">
122+
{Array(10)
123+
.fill(1)
124+
.map((_, index) => (
125+
<div key={index}>
126+
<span className="d">{index}</span>
127+
</div>
128+
))}
129+
<div className="class1" />
130+
</div>
131+
</div>
132+
</div>
133+
);
134+
135+
const usedFiles = getUsedStyles(output, lookupAfterSerialization);
136+
const usedCritical = getCriticalStyles(output, lookupAfterSerialization);
137+
138+
expect(usedFiles).toEqual(['file1.css', 'file2.css']);
139+
140+
expect(usedCritical).toMatch(/selector-11/);
141+
expect(usedCritical).toMatch(/data-from-file1/);
142+
expect(usedCritical).not.toMatch(/data-wrong-file1/);
143+
expect(usedCritical).toMatch(/data-from-file2/);
144+
expect(usedCritical).not.toMatch(/data-wrong-file1/);
145+
146+
expect(usedCritical).toMatch(/ANIMATION_NAME/);
147+
148+
expect(usedCritical).toMatch(/htmlRED/);
149+
});
107150
});
108151

109152
describe('React css stream', () => {

src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { discoverProjectStyles, loadStyleDefinitions, parseProjectStyles } from
88

99
import { createUsedFilter as createUsedSelectorsFilter } from './utils/cache';
1010

11+
export { serializeStylesLookup, loadSerializedLookup } from './serialize';
12+
1113
export { UsedTypes, StyleDefinition, SelectionFilter } from './types';
1214

1315
export {

src/serialize.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { StyleDefinition, SerializedStyleDefinition } from './types';
2+
import { assertIsReady } from './utils/async';
3+
4+
export function serializeStylesLookup(def: StyleDefinition): SerializedStyleDefinition {
5+
assertIsReady(def);
6+
7+
return {
8+
lookup: def.lookup,
9+
ast: def.ast,
10+
urlPrefix: def.urlPrefix,
11+
};
12+
}
13+
14+
export function loadSerializedLookup(def: SerializedStyleDefinition): StyleDefinition {
15+
assertValidLookup(def);
16+
17+
return {
18+
isReady: true,
19+
lookup: def.lookup,
20+
ast: def.ast,
21+
urlPrefix: def.urlPrefix,
22+
/**
23+
* Serialized style definition is already ready,
24+
* so `then` here is just a noop for compatibility
25+
*/
26+
then: (res) => {
27+
if (res) {
28+
res();
29+
}
30+
31+
return Promise.resolve();
32+
},
33+
};
34+
}
35+
36+
function assertValidLookup(def: any): asserts def is SerializedStyleDefinition {
37+
if (typeof def === 'string') {
38+
throw new Error(
39+
'used-styles: got a string instead of serialized style definition object, make sure to parse it back to JS object first'
40+
);
41+
}
42+
43+
if (!('lookup' in def) || typeof def.lookup !== 'object') {
44+
throw new Error('used-styles: serialized style definition should be created with serializeStylesLookup');
45+
}
46+
}

src/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export type StyleDefinition = Readonly<{
3838
then(resolve?: () => void, reject?: () => void): Promise<void>;
3939
}>;
4040

41+
export type SerializedStyleDefinition = Readonly<{
42+
lookup: Readonly<StylesLookupTable>;
43+
ast: Readonly<StyleAst>;
44+
urlPrefix: string;
45+
}>;
46+
4147
/**
4248
* A function used to control which selectors should be used
4349
* @param selector - DEPRECATED

0 commit comments

Comments
 (0)