Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/server-collect-linear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@linaria/server': patch
---

`collect` no longer moves rules out of the parsed stylesheet one by one. That made it quadratic in the number of top-level rules and took seconds on large stylesheets.

It also fixes two bugs:

- `other` lost rules from an at-rule when a non-critical rule came before a critical one;
- `blockedClasses` let every second matching rule into `critical`.

Whitespace between rules in the output now follows the source stylesheet.
17 changes: 15 additions & 2 deletions packages/server/__tests__/__snapshots__/collect.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -108,47 +108,57 @@ exports[`collects complex css critical 1`] = `
".lotus {
vertical-align: top;
}

@media (max-width: 1200px) {
.lotus {
vertical-align: bottom;
}
}

@supports (object-fit: contain) {
.lotus {
object-fit: contain;
}

.linaria::before,
.linaria::after {
content: \\"\\";
content: "";
object-fit: contain;
}
}

.linaria {
float: left;
flex: 1;
animation: custom-animation 0.2s;
}

.linaria:hover {
float: right;
}

.linaria > span,
.linaria + .linaria,
.linaria ~ div {
display: none;
}

.linaria > span {
display: none;
}

.linaria::after {
display: block;
}

.lily {
color: #fff;
}
[data-theme=\\"dark\\"] .lily {

[data-theme="dark"] .lily {
color: #000;
}

.linaria ~ div {
}
.linaria.linaria2 {
Expand Down Expand Up @@ -266,12 +276,15 @@ exports[`works with global css critical 1`] = `
"body {
font-size: 13.37px;
}

html {
-webkit-font-smoothing: antialiased;
}

h1 {
font-weight: bold;
}

.linaria:active {
}
.linaria::before {
Expand Down
103 changes: 91 additions & 12 deletions packages/server/__tests__/collect.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import dedent from 'dedent';
import prettier from 'prettier';
import postcss from 'postcss';
import * as postcssPlugin from 'prettier/plugins/postcss';
import { format } from 'prettier/standalone';

import collect from '../src/collect';

const prettyPrint = (src: string) => prettier.format(src, { parser: 'scss' });
// prettier 3 formats asynchronously; the standalone build with an explicit plugin also runs without
// --experimental-vm-modules
const prettyPrint = (src: string) =>
format(src, { parser: 'scss', plugins: [postcssPlugin] });

const testCollect = (html: string, css: string) => {
const { critical, other } = collect(html, css);
test('critical', () => expect(prettyPrint(critical)).toMatchSnapshot());
test('other', () => expect(prettyPrint(other)).toMatchSnapshot());
test('critical', async () =>
expect(await prettyPrint(critical)).toMatchSnapshot());
test('other', async () => expect(await prettyPrint(other)).toMatchSnapshot());
};

const html = dedent`
Expand Down Expand Up @@ -195,8 +201,9 @@ describe('works with global css', () => {

const { critical, other } = collect(html, css);

test('critical', () => expect(prettyPrint(critical)).toMatchSnapshot());
test('other', () => expect(prettyPrint(other)).toMatchSnapshot());
test('critical', async () =>
expect(await prettyPrint(critical)).toMatchSnapshot());
test('other', async () => expect(await prettyPrint(other)).toMatchSnapshot());
});

describe('handles top-level @font-face', () => {
Expand All @@ -210,8 +217,9 @@ describe('handles top-level @font-face', () => {
`;
const { critical, other } = collect(html, css);

test('critical', () => expect(prettyPrint(critical)).toMatchSnapshot());
test('other', () => expect(prettyPrint(other)).toMatchSnapshot());
test('critical', async () =>
expect(await prettyPrint(critical)).toMatchSnapshot());
test('other', async () => expect(await prettyPrint(other)).toMatchSnapshot());
});

// there was a bug when the whole atrule was included for each child rule
Expand All @@ -231,8 +239,9 @@ describe('include atrule once', () => {
`;
const { critical, other } = collect(html, css);

test('critical', () => expect(prettyPrint(critical)).toMatchSnapshot());
test('other', () => expect(prettyPrint(other)).toMatchSnapshot());
test('critical', async () =>
expect(await prettyPrint(critical)).toMatchSnapshot());
test('other', async () => expect(await prettyPrint(other)).toMatchSnapshot());
});

describe('ignore empty class attribute', () => {
Expand Down Expand Up @@ -267,7 +276,10 @@ test('does not match selectors in ignoredClasses list', () => {
ignoredClasses: ['ltr', 'dir'],
});
expect(critical).toMatchInlineSnapshot(`".lily {}"`);
expect(other).toMatchInlineSnapshot(`".linaria.dir {}.linaria.ltr {}"`);
expect(other).toMatchInlineSnapshot(`
".linaria.dir {}
.linaria.ltr {}"
`);
});

test('does not match selectors in blockedClasses list', () => {
Expand All @@ -282,7 +294,10 @@ test('does not match selectors in blockedClasses list', () => {
`;

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

Expand Down Expand Up @@ -323,3 +338,67 @@ test('does not match child selectors in blockedClasses list for media queries',
}"
`);
});

const selectorsOf = (css: string) => {
const selectors: string[] = [];
postcss.parse(css).walkRules((rule) => {
selectors.push(rule.selector);
});
return selectors;
};

test('splits rules of an at-rule between critical and other', () => {
const css = dedent`
@media (min-width: 600px) {
.unrelated { color: red; }
.linaria { color: blue; }
.unrelated2 { color: green; }
}
`;

const { critical, other } = collect(html, css);
expect(selectorsOf(critical)).toEqual(['.linaria']);
expect(selectorsOf(other)).toEqual(['.unrelated', '.unrelated2']);
});

test('excludes every rule with a blocked class', () => {
const css = dedent`
.linaria.rtl { color: red; }
.linaria.rtl { color: blue; }
.linaria.rtl { color: green; }
.linaria { color: black; }
`;

const { critical } = collect(html, css, { blockedClasses: ['rtl'] });
expect(selectorsOf(critical)).toEqual(['.linaria']);
});

test('keeps the whitespace between rules', () => {
const critical = '.linaria {}\n.lily {}\n\n.lotus {}\n\n\n.linaria .lily {}';
const other = '.other {}\n.other2 {}\n\n.other3 {}';

expect(collect(html, `${critical}\n\n${other}`)).toEqual({ critical, other });
});

test('keeps the order of rules in a large stylesheet', () => {
const rules: string[] = [];
const expectedCritical: string[] = [];
const expectedOther: string[] = [];
for (let i = 0; i < 20000; i += 1) {
if (i % 1000 === 0) {
rules.push(
`@media (min-width: ${i}px) { .linaria-${i} {} .other-${i} {} }`
);
expectedCritical.push(`.linaria-${i}`);
expectedOther.push(`.other-${i}`);
} else {
const selector = i % 2 ? `.linaria-${i}` : `.other-${i}`;
rules.push(`${selector} {}`);
(i % 2 ? expectedCritical : expectedOther).push(selector);
}
}

const { critical, other } = collect(html, rules.join('\n'));
expect(selectorsOf(critical)).toEqual(expectedCritical);
expect(selectorsOf(other)).toEqual(expectedOther);
});
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"build:declarations": "tsc --emitDeclarationOnly --outDir types",
"build:esm": "babel src --out-dir esm --extensions '.js,.jsx,.ts,.tsx' --source-maps --delete-dir-on-start",
"build:lib": "cross-env NODE_ENV=legacy babel src --out-dir lib --extensions '.js,.jsx,.ts,.tsx' --source-maps --delete-dir-on-start",
"test": "jest --config ../../jest.config.js --rootDir .",
"typecheck": "tsc --noEmit --composite false",
"watch": "pnpm build:lib --watch & pnpm build:esm --watch & pnpm build:declarations --watch"
},
Expand Down
Loading
Loading