Skip to content

Commit e5e65bd

Browse files
authored
perf: cache postcss processor by compiler.modifiedFiles (#33)
## Summary Add caches to avoid re-creating postcss processor in development. > In the previous version, Tailwind CSS is re-created in every compilation. > > <img width="862" alt="image" src="https://github.com/user-attachments/assets/76395c9e-88a4-476f-a94d-50541d63e8d2" /> ## Details There are two cases we want to cache: 1. Modifying existing module(s). - We use `isSubsetOf(compiler.modifiedFiles, cachedEntryModules)` to test if the cache hit 2. Modifying module(s) that do not belong to this entry. - We use `isSubsetOf(entryModules, cachedEntryModules)` to test if the cache hit
1 parent 35f44dc commit e5e65bd

File tree

6 files changed

+134
-47
lines changed

6 files changed

+134
-47
lines changed

playground/src/index.css

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1 @@
1-
body {
2-
margin: 0;
3-
color: #fff;
4-
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
5-
background-image: linear-gradient(to bottom, #020917, #101725);
6-
}
7-
8-
.content {
9-
display: flex;
10-
min-height: 100vh;
11-
line-height: 1.1;
12-
text-align: center;
13-
flex-direction: column;
14-
justify-content: center;
15-
}
16-
17-
.content h1 {
18-
font-size: 3.6rem;
19-
font-weight: 700;
20-
}
21-
22-
.content p {
23-
font-size: 1.2rem;
24-
font-weight: 400;
25-
opacity: 0.5;
26-
}
1+
@tailwind utilities;

playground/src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import './index.css';
22

33
document.querySelector('#root').innerHTML = `
4-
<div class="content">
4+
<div class="flex">
55
<h1>Vanilla Rsbuild</h1>
66
<p>Start building amazing things with Rsbuild.</p>
77
</div>

src/Set.prototype.isSubsetOf.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* See: {@link https://github.com/tc39/proposal-set-methods | Set Methods for JavaScript}
3+
*/
4+
export function isSubsetOf<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {
5+
// Node.js v22 will have native implementation.
6+
if (typeof a.isSubsetOf === 'function') {
7+
return a.isSubsetOf(b);
8+
}
9+
10+
if (a.size > b.size) {
11+
return false;
12+
}
13+
14+
for (const item of a) {
15+
if (!b.has(item)) {
16+
return false;
17+
}
18+
}
19+
return true;
20+
}

src/TailwindCSSRspackPlugin.ts

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { pathToFileURL } from 'node:url';
77

88
import { createFilter } from '@rollup/pluginutils';
99
import type { PostCSSLoaderOptions, Rspack } from '@rsbuild/core';
10+
import type { Processor } from 'postcss';
11+
12+
import { isSubsetOf } from './Set.prototype.isSubsetOf.js';
1013

1114
/**
1215
* The options for {@link TailwindRspackPlugin}.
@@ -176,6 +179,11 @@ export type { TailwindRspackPluginOptions };
176179
class TailwindRspackPluginImpl {
177180
name = 'TailwindRspackPlugin';
178181

182+
static #postcssProcessorCache = new Map<
183+
/** entryName */ string,
184+
[entryModules: ReadonlySet<string>, Processor]
185+
>();
186+
179187
constructor(
180188
private compiler: Rspack.Compiler,
181189
private options: TailwindRspackPluginOptions,
@@ -185,7 +193,6 @@ class TailwindRspackPluginImpl {
185193
resolve: compiler.options.context!,
186194
});
187195

188-
const { RawSource } = compiler.webpack.sources;
189196
compiler.hooks.thisCompilation.tap(this.name, (compilation) => {
190197
compilation.hooks.processAssets.tapPromise(this.name, async () => {
191198
await Promise.all(
@@ -202,6 +209,20 @@ class TailwindRspackPluginImpl {
202209
return;
203210
}
204211

212+
const cache =
213+
TailwindRspackPluginImpl.#postcssProcessorCache.get(entryName);
214+
if (compiler.modifiedFiles && cache) {
215+
const [cachedEntryModules, cachedPostcssProcessor] = cache;
216+
if (isSubsetOf(compiler.modifiedFiles, cachedEntryModules)) {
217+
await this.#transformCSSAssets(
218+
compilation,
219+
cachedPostcssProcessor,
220+
cssFiles,
221+
);
222+
return;
223+
}
224+
}
225+
205226
// collect all the modules corresponding to specific entry
206227
const entryModules = new Set<string>();
207228

@@ -213,6 +234,18 @@ class TailwindRspackPluginImpl {
213234
}
214235
}
215236

237+
if (compiler.modifiedFiles && cache) {
238+
const [cachedEntryModules, cachedPostcssProcessor] = cache;
239+
if (isSubsetOf(entryModules, cachedEntryModules)) {
240+
await this.#transformCSSAssets(
241+
compilation,
242+
cachedPostcssProcessor,
243+
cssFiles,
244+
);
245+
return;
246+
}
247+
}
248+
216249
const [
217250
{ default: postcss },
218251
{ default: tailwindcss },
@@ -226,7 +259,7 @@ class TailwindRspackPluginImpl {
226259
),
227260
]);
228261

229-
const postcssTransform = postcss([
262+
const processor = postcss([
230263
...(options.postcssOptions?.plugins ?? []),
231264
// We use a config path to avoid performance issue of TailwindCSS
232265
// See: https://github.com/tailwindlabs/tailwindcss/issues/14229
@@ -235,30 +268,43 @@ class TailwindRspackPluginImpl {
235268
}),
236269
]);
237270

238-
// iterate all css asset in entry and inject entry modules into tailwind content
239-
await Promise.all(
240-
cssFiles.map(async (asset) => {
241-
const content = asset.source.source();
242-
// transform .css which contains tailwind mixin
243-
// FIXME: add custom postcss config
244-
const transformResult = await postcssTransform.process(
245-
content,
246-
{ from: asset.name, ...options.postcssOptions },
247-
);
248-
// FIXME: add sourcemap support
249-
compilation.updateAsset(
250-
asset.name,
251-
new RawSource(transformResult.css),
252-
);
253-
}),
254-
);
271+
TailwindRspackPluginImpl.#postcssProcessorCache.set(entryName, [
272+
entryModules,
273+
processor,
274+
]);
275+
276+
await this.#transformCSSAssets(compilation, processor, cssFiles);
255277
},
256278
),
257279
);
258280
});
259281
});
260282
}
261283

284+
async #transformCSSAssets(
285+
compilation: Rspack.Compilation,
286+
postcssProcessor: Processor,
287+
cssFiles: Array<Rspack.Asset>,
288+
) {
289+
const { RawSource } = this.compiler.webpack.sources;
290+
291+
// iterate all css asset in entry and inject entry modules into tailwind content
292+
await Promise.all(
293+
cssFiles.map(async (asset) => {
294+
const content = asset.source.source();
295+
// transform .css which contains tailwind mixin
296+
// FIXME: add custom postcss config
297+
const transformResult = await postcssProcessor.process(content, {
298+
from: asset.name,
299+
...this.options.postcssOptions,
300+
});
301+
// FIXME: avoid `updateAsset` when no change is found.
302+
// FIXME: add sourcemap support
303+
compilation.updateAsset(asset.name, new RawSource(transformResult.css));
304+
}),
305+
);
306+
}
307+
262308
async ensureTempDir(entryName: string): Promise<string> {
263309
const prefix = path.join(tmpdir(), entryName);
264310
await mkdir(path.dirname(prefix), { recursive: true });

test/isSubsetOf.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import { isSubsetOf } from '../src/Set.prototype.isSubsetOf';
4+
5+
test.describe('Set.prototype.isSubsetOf', () => {
6+
test('subsets', () => {
7+
const set1 = new Set([1, 2, 3]);
8+
const set2 = new Set([4, 5, 6]);
9+
const set3 = new Set([1, 2, 3, 4, 5, 6]);
10+
11+
expect(isSubsetOf(set1, set2)).toBe(false);
12+
expect(isSubsetOf(set2, set1)).toBe(false);
13+
expect(isSubsetOf(set1, set3)).toBe(true);
14+
expect(isSubsetOf(set2, set3)).toBe(true);
15+
});
16+
17+
test('empty sets', () => {
18+
const s1 = new Set([]);
19+
const s2 = new Set([1, 2]);
20+
21+
expect(isSubsetOf(s1, s2)).toBe(true);
22+
23+
const s3 = new Set([1, 2]);
24+
const s4 = new Set([]);
25+
26+
expect(isSubsetOf(s3, s4)).toBe(false);
27+
28+
const s5 = new Set([]);
29+
const s6 = new Set([]);
30+
31+
expect(isSubsetOf(s5, s6)).toBe(true);
32+
});
33+
34+
test('self', () => {
35+
const s1 = new Set([1, 2]);
36+
37+
expect(isSubsetOf(s1, s1)).toBe(true);
38+
});
39+
40+
test('same', () => {
41+
const s1 = new Set([1, 2]);
42+
const s2 = new Set([1, 2]);
43+
44+
expect(isSubsetOf(s1, s2)).toBe(true);
45+
});
46+
});

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"outDir": "./dist",
44
"baseUrl": "./",
55
"target": "ES2021",
6-
"lib": ["DOM", "ES2021"],
6+
"lib": ["DOM", "ES2021", "ESNext.Collection"],
77
"module": "Node16",
88
"moduleResolution": "Node16",
99
"strict": true,

0 commit comments

Comments
 (0)