Skip to content

Commit

Permalink
feat: add ignored and excluded classes to critical CSS extraction in …
Browse files Browse the repository at this point in the history
…`collect` (#1102)

* feat: add ignored and blocked classes lists to collect

* feat: add exclusion functionality in handleAtRule

* chore(changeset): add changeset for collect updates

Co-authored-by: Ezlie Nguyen <ezlie.nguyen@airbnb.com>
  • Loading branch information
ezlie-nguyen and Ezlie Nguyen committed Dec 20, 2022
1 parent ac2d341 commit 77bcf2e
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 26 deletions.
6 changes: 6 additions & 0 deletions .changeset/wet-cheetahs-approve.md
@@ -0,0 +1,6 @@
---
'@linaria/server': minor
---

- 714a8e86: Modify the handling of CSS @ rules in `collect` to only include child rules if critical
- 1559cfa9: Add support in `collect` for ignoring and blocking classes from the regex for critical CSS extraction
8 changes: 6 additions & 2 deletions packages/server/__tests__/__snapshots__/collect.test.ts.snap
Expand Up @@ -213,15 +213,19 @@ exports[`include atrule once critical 1`] = `
h1 {
font-size: 20px;
}
}
"
`;

exports[`include atrule once other 1`] = `
"@media screen {
.class {
font-size: 15px;
}
}
"
`;

exports[`include atrule once other 1`] = `""`;

exports[`simple class name critical 1`] = `
".linaria {
}
Expand Down
72 changes: 72 additions & 0 deletions packages/server/__tests__/collect.test.ts
Expand Up @@ -251,3 +251,75 @@ describe('ignore empty class attribute', () => {
const { critical } = collect(code, css);
test('critical should be empty', () => expect(critical).toEqual(''));
});

test('does not match selectors in ignoredClasses list', () => {
const code = dedent`
<div class="lily ltr dir"></div>
`;

const css = dedent`
.linaria.dir {}
.linaria.ltr {}
.lily {}
`;

const { critical, other } = collect(code, css, {
ignoredClasses: ['ltr', 'dir'],
});
expect(critical).toMatchInlineSnapshot(`".lily {}"`);
expect(other).toMatchInlineSnapshot(`".linaria.dir {}.linaria.ltr {}"`);
});

test('does not match selectors in blockedClasses list', () => {
const code = dedent`
<div class="lily linaria ltr dir"></div>
`;

const css = dedent`
.linaria.ltr {}
.linaria.rtl {}
.lily {}
`;

const { critical, other } = collect(code, css, { blockedClasses: ['rtl'] });
expect(critical).toMatchInlineSnapshot(`".linaria.ltr {}.lily {}"`);
expect(other).toMatchInlineSnapshot(`".linaria.rtl {}"`);
});

test('does not match child selectors in blockedClasses list for media queries', () => {
const code = dedent`
<div class="lily linaria ltr dir"></div>
`;

const css = dedent`
@media only screen and (max-width:561.5px) {
.dir-ltr.left_6_3_l9xil3s {
padding-left: 24px !important;
}
.dir-rtl.left_6_3_l9xil3s {
padding-right: 24px !important;
}
}
`;

const { critical, other } = collect(code, css, {
blockedClasses: ['dir-rtl'],
});

expect(critical).toMatchInlineSnapshot(`
"@media only screen and (max-width:561.5px) {
.dir-ltr.left_6_3_l9xil3s {
padding-left: 24px !important;
}
}"
`);
expect(other).toMatchInlineSnapshot(`
"@media only screen and (max-width:561.5px) {
.dir-rtl.left_6_3_l9xil3s {
padding-right: 24px !important;
}
}"
`);
});
91 changes: 67 additions & 24 deletions packages/server/src/collect.ts
@@ -1,7 +1,3 @@
/**
* This utility extracts critical CSS from given HTML and CSS file to be used in SSR environments
*/

import type { AtRule, ChildNode } from 'postcss';
import postcss from 'postcss';

Expand All @@ -10,60 +6,107 @@ type CollectResult = {
other: string;
};

const extractClassesFromHtml = (html: string): RegExp => {
interface ClassnameModifiers {
ignoredClasses?: string[];
blockedClasses?: string[];
}

/**
* Used to escape `RegExp`
* [syntax characters](https://262.ecma-international.org/7.0/#sec-regular-expressions-patterns).
*/
function escapeRegex(string: string) {
return string.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
}

const extractClassesFromHtml = (
html: string,
ignoredClasses: string[]
): RegExp => {
const htmlClasses: string[] = [];
const regex = /\s+class="([^"]+)"/gm;
let match = regex.exec(html);
const ignoredClassesDeduped = new Set(ignoredClasses);

while (match !== null) {
match[1].split(' ').forEach((className) => {
// eslint-disable-next-line no-param-reassign
className = className.replace(
/\\|\^|\$|\{|\}|\[|\]|\(|\)|\.|\*|\+|\?|\|/g,
'\\$&'
);
htmlClasses.push(className);
className = escapeRegex(className);
if (className !== '' && !ignoredClassesDeduped.has(className)) {
htmlClasses.push(className);
}
});
match = regex.exec(html);
}

return new RegExp(htmlClasses.join('|'), 'gm');
};

export default function collect(html: string, css: string): CollectResult {
/**
* This utility extracts critical CSS from given HTML and CSS file to be used in SSR environments
* @param {string} html the HTML from which classes will be parsed
* @param {string} css the CSS file from which selectors will be parsed and determined as critical or other
* @param {string[]} ignoredClasses classes that, when present in the HTML, will not be included in the regular expression used to match selectors
* @param {string[]} blockedClasses classes that, when contained in a selector, will cause the selector to be marked as not critical
* @returns {CollectResult} object containing the critical and other CSS styles
*/
export default function collect(
html: string,
css: string,
classnameModifiers?: ClassnameModifiers
): CollectResult {
const animations = new Set();
const other = postcss.root();
const critical = postcss.root();
const stylesheet = postcss.parse(css);
const htmlClassesRegExp = extractClassesFromHtml(html);
const ignoredClasses = classnameModifiers?.ignoredClasses ?? [];
const blockedClasses = classnameModifiers?.blockedClasses ?? [];

const htmlClassesRegExp = extractClassesFromHtml(html, ignoredClasses);
const blockedClassesSanitized = blockedClasses.map(escapeRegex);
const blockedClassesRegExp = new RegExp(
blockedClassesSanitized.join('|'),
'gm'
);

const isCritical = (rule: ChildNode) => {
// Only check class names selectors
if ('selector' in rule && rule.selector.startsWith('.')) {
const isExcluded =
blockedClasses.length > 0 && blockedClassesRegExp.test(rule.selector);
if (isExcluded) return false;

return Boolean(rule.selector.match(htmlClassesRegExp));
}

return true;
};

const handleAtRule = (rule: AtRule) => {
let addedToCritical = false;
if (rule.name === 'keyframes') {
return;
}

const criticalRule = rule.clone();
const otherRule = rule.clone();

rule.each((childRule) => {
if (isCritical(childRule) && !addedToCritical) {
critical.append(rule.clone());
addedToCritical = true;
let removedNodesFromOther = 0;
criticalRule.each((childRule: ChildNode, index: number) => {
if (isCritical(childRule)) {
otherRule.nodes[index - removedNodesFromOther]?.remove();
removedNodesFromOther += 1;
} else {
childRule.remove();
}
});

if (rule.name === 'keyframes') {
return;
}
rule.remove();

if (addedToCritical) {
rule.remove();
} else {
other.append(rule);
if (criticalRule.nodes.length > 0) {
critical.append(criticalRule);
}
if (otherRule.nodes.length > 0) {
other.append(otherRule);
}
};

Expand Down

0 comments on commit 77bcf2e

Please sign in to comment.