From 77bcf2e729cf9c681477077f07200f7dbc3b99e7 Mon Sep 17 00:00:00 2001 From: Ezlie Nguyen Date: Tue, 20 Dec 2022 11:07:51 -0500 Subject: [PATCH] feat: add ignored and excluded classes to critical CSS extraction in `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 --- .changeset/wet-cheetahs-approve.md | 6 ++ .../__snapshots__/collect.test.ts.snap | 8 +- packages/server/__tests__/collect.test.ts | 72 +++++++++++++++ packages/server/src/collect.ts | 91 ++++++++++++++----- 4 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 .changeset/wet-cheetahs-approve.md diff --git a/.changeset/wet-cheetahs-approve.md b/.changeset/wet-cheetahs-approve.md new file mode 100644 index 000000000..40818aacb --- /dev/null +++ b/.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 diff --git a/packages/server/__tests__/__snapshots__/collect.test.ts.snap b/packages/server/__tests__/__snapshots__/collect.test.ts.snap index c5ff6392a..18615477b 100644 --- a/packages/server/__tests__/__snapshots__/collect.test.ts.snap +++ b/packages/server/__tests__/__snapshots__/collect.test.ts.snap @@ -213,6 +213,12 @@ exports[`include atrule once critical 1`] = ` h1 { font-size: 20px; } +} +" +`; + +exports[`include atrule once other 1`] = ` +"@media screen { .class { font-size: 15px; } @@ -220,8 +226,6 @@ exports[`include atrule once critical 1`] = ` " `; -exports[`include atrule once other 1`] = `""`; - exports[`simple class name critical 1`] = ` ".linaria { } diff --git a/packages/server/__tests__/collect.test.ts b/packages/server/__tests__/collect.test.ts index 083a75f28..a25564ba6 100644 --- a/packages/server/__tests__/collect.test.ts +++ b/packages/server/__tests__/collect.test.ts @@ -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` +
+ `; + + 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` +
+ `; + + 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` +
+ `; + + 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; + } + }" + `); +}); diff --git a/packages/server/src/collect.ts b/packages/server/src/collect.ts index 5ca22a8a0..e79892550 100644 --- a/packages/server/src/collect.ts +++ b/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'; @@ -10,19 +6,35 @@ 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); } @@ -30,16 +42,40 @@ const extractClassesFromHtml = (html: string): RegExp => { 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)); } @@ -47,23 +83,30 @@ export default function collect(html: string, css: string): CollectResult { }; 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); } };