Skip to content

Commit 84ebe19

Browse files
Vite: Retain candidates between input CSS updates (#14228)
This PR fixes an issue introduced with the changed candidate cache behavior in #14187. Prior to #14187, candidates were cached globally within an instance of Oxide. This meant that once a candidate was discovered, it would not reset until you either manually cleared the cache or restarted the Oxide process. With the changes in #14187 however, the cache was scoped to the instance of the `Scanner` class with the intention of making the caching behavior more easy to understand and to avoid a global cache. This, however, had an unforeseen side-effect in our Vite extension. Vite, in dev mode, discovers files _lazily_. So when a developer goes to `/index.html` the first time, we will scan the `/index.html` file for Tailwind candidates and then build a CSS file with those candidate. When they go to `/about.html` later, we will _append_ the candidates from the new file and so forth. The problem now arises when the dev server detects changes to the input CSS file. This requires us to do a re-scan of that CSS file which, after #14187, caused the candidate cache to be gone. This is usually fine since we would just scan files again for the changed candidate list but in the Vite case we would only get the input CSS file change _but no subsequent change events for all other files, including those currently rendered in the browser_). This caused updates to the CSS file to remove all candidates from the CSS file again. Ideally, we can separate between two concepts: The candidate cache and the CSS input file scan. An instance of the `Scanner` could re-parse the input CSS file without having to throw away previous candidates. This, however, would have another issue with the current Vite extension where we do not properly retain instances of the `Scanner` class anyways. To properly improve the cache behavior, we will have to fix the Vite `Scanner` retaining behavior first. Unfortunately this means that for the short term, we have to add some manual bookkeeping to the Vite client and retain the candidate cache between builds ourselves. --------- Co-authored-by: Jordan Pittman <jordan@cryptica.me>
1 parent 45fb21e commit 84ebe19

File tree

4 files changed

+92
-26
lines changed

4 files changed

+92
-26
lines changed

integrations/postcss/next.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from 'vitest'
2-
import { candidate, css, fetchStylesFromIndex, js, json, retryAssertion, test } from '../utils'
2+
import { candidate, css, fetchStyles, js, json, retryAssertion, test } from '../utils'
33

44
test(
55
'production build',
@@ -131,7 +131,7 @@ test(
131131
await spawn(`pnpm next dev ${bundler === 'turbo' ? '--turbo' : ''} --port ${port}`)
132132

133133
await retryAssertion(async () => {
134-
let css = await fetchStylesFromIndex(port)
134+
let css = await fetchStyles(port)
135135
expect(css).toContain(candidate`underline`)
136136
})
137137

@@ -145,7 +145,7 @@ test(
145145
)
146146

147147
await retryAssertion(async () => {
148-
let css = await fetchStylesFromIndex(port)
148+
let css = await fetchStyles(port)
149149
expect(css).toContain(candidate`underline`)
150150
expect(css).toContain(candidate`text-red-500`)
151151
})

integrations/utils.ts

+11-4
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,15 @@ export function test(
262262
await fs.mkdir(dir, { recursive: true })
263263
await fs.writeFile(full, content)
264264
},
265-
read(filePath: string) {
266-
return fs.readFile(path.resolve(root, filePath), 'utf8')
265+
async read(filePath: string) {
266+
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
267+
268+
// Ensure that files read on Windows have \r\n line endings removed
269+
if (IS_WINDOWS) {
270+
content = content.replace(/\r\n/g, '\n')
271+
}
272+
273+
return content
267274
},
268275
async glob(pattern: string) {
269276
let files = await fastGlob(pattern, { cwd: root })
@@ -500,8 +507,8 @@ export async function retryAssertion<T>(
500507
throw error
501508
}
502509

503-
export async function fetchStylesFromIndex(port: number): Promise<string> {
504-
let index = await fetch(`http://localhost:${port}`)
510+
export async function fetchStyles(port: number, path = '/'): Promise<string> {
511+
let index = await fetch(`http://localhost:${port}${path}`)
505512
let html = await index.text()
506513

507514
let regex = /<link rel="stylesheet" href="([a-zA-Z0-9\/_\.\?=%-]+)"/g

integrations/vite/index.test.ts

+70-12
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import path from 'node:path'
22
import { expect } from 'vitest'
3-
import { candidate, css, fetchStylesFromIndex, html, js, json, test, ts, yaml } from '../utils'
3+
import {
4+
candidate,
5+
css,
6+
fetchStyles,
7+
html,
8+
js,
9+
json,
10+
retryAssertion,
11+
test,
12+
ts,
13+
yaml,
14+
} from '../utils'
415

516
test(
617
'production build',
@@ -106,6 +117,14 @@ test(
106117
<div class="underline">Hello, world!</div>
107118
</body>
108119
`,
120+
'project-a/about.html': html`
121+
<head>
122+
<link rel="stylesheet" href="./src/index.css" />
123+
</head>
124+
<body>
125+
<div class="font-bold ">Tailwind Labs</div>
126+
</body>
127+
`,
109128
'project-a/src/index.css': css`
110129
@import 'tailwindcss/theme' theme(reference);
111130
@import 'tailwindcss/utilities';
@@ -119,15 +138,27 @@ test(
119138
},
120139
async ({ root, spawn, getFreePort, fs }) => {
121140
let port = await getFreePort()
122-
let process = await spawn(`pnpm vite dev --port ${port}`, {
141+
await spawn(`pnpm vite dev --port ${port}`, {
123142
cwd: path.join(root, 'project-a'),
124143
})
125144

126-
await process.onStdout((message) => message.includes('ready in'))
145+
// Candidates are resolved lazily, so the first visit of index.html
146+
// will only have candidates from this file.
147+
await retryAssertion(async () => {
148+
let css = await fetchStyles(port, '/index.html')
149+
expect(css).toContain(candidate`underline`)
150+
expect(css).not.toContain(candidate`font-bold`)
151+
})
127152

128-
let css = await fetchStylesFromIndex(port)
129-
expect(css).toContain(candidate`underline`)
153+
// Going to about.html will extend the candidate list to include
154+
// candidates from about.html.
155+
await retryAssertion(async () => {
156+
let css = await fetchStyles(port, '/about.html')
157+
expect(css).toContain(candidate`underline`)
158+
expect(css).toContain(candidate`font-bold`)
159+
})
130160

161+
// Updates are additive and cause new candidates to be added.
131162
await fs.write(
132163
'project-a/index.html',
133164
html`
@@ -139,21 +170,48 @@ test(
139170
</body>
140171
`,
141172
)
142-
await process.onStdout((message) => message.includes('page reload'))
143-
144-
css = await fetchStylesFromIndex(port)
145-
expect(css).toContain(candidate`m-2`)
173+
await retryAssertion(async () => {
174+
let css = await fetchStyles(port)
175+
expect(css).toContain(candidate`underline`)
176+
expect(css).toContain(candidate`font-bold`)
177+
expect(css).toContain(candidate`m-2`)
178+
})
146179

180+
// Manually added `@source`s are watched and trigger a rebuild
147181
await fs.write(
148182
'project-b/src/index.js',
149183
js`
150184
const className = "[.changed_&]:content-['project-b/src/index.js']"
151185
module.exports = { className }
152186
`,
153187
)
154-
await process.onStdout((message) => message.includes('page reload'))
188+
await retryAssertion(async () => {
189+
let css = await fetchStyles(port)
190+
expect(css).toContain(candidate`underline`)
191+
expect(css).toContain(candidate`font-bold`)
192+
expect(css).toContain(candidate`m-2`)
193+
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
194+
})
195+
196+
// After updates to the CSS file, all previous candidates should still be in
197+
// the generated CSS
198+
await fs.write(
199+
'project-a/src/index.css',
200+
css`
201+
${await fs.read('project-a/src/index.css')}
155202
156-
css = await fetchStylesFromIndex(port)
157-
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
203+
.red {
204+
color: red;
205+
}
206+
`,
207+
)
208+
await retryAssertion(async () => {
209+
let css = await fetchStyles(port)
210+
expect(css).toContain(candidate`red`)
211+
expect(css).toContain(candidate`m-2`)
212+
expect(css).toContain(candidate`underline`)
213+
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
214+
expect(css).toContain(candidate`font-bold`)
215+
})
158216
},
159217
)

packages/@tailwindcss-vite/src/index.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export default function tailwindcss(): Plugin[] {
1111
let config: ResolvedConfig | null = null
1212
let scanner: Scanner | null = null
1313
let changedContent: { content: string; extension: string }[] = []
14-
let candidates: string[] = []
14+
let candidates = new Set<string>()
1515

1616
// In serve mode this is treated as a set — the content doesn't matter.
1717
// In build mode, we store file contents to use them in renderChunk.
@@ -69,10 +69,9 @@ export default function tailwindcss(): Plugin[] {
6969
}
7070

7171
// Parse all candidates given the resolved files
72-
let newCandidates = scanner.scanFiles([{ content: src, extension }])
73-
for (let candidate of newCandidates) {
72+
for (let candidate of scanner.scanFiles([{ content: src, extension }])) {
7473
updated = true
75-
candidates.push(candidate)
74+
candidates.add(candidate)
7675
}
7776

7877
return updated
@@ -100,11 +99,13 @@ export default function tailwindcss(): Plugin[] {
10099
// This should not be here, but right now the Vite plugin is setup where we
101100
// setup a new scanner and compiler every time we request the CSS file
102101
// (regardless whether it actually changed or not).
103-
let initialCandidates = scanner.scan()
102+
for (let candidate of scanner.scan()) {
103+
candidates.add(candidate)
104+
}
104105

105106
if (changedContent.length > 0) {
106107
for (let candidate of scanner.scanFiles(changedContent.splice(0))) {
107-
initialCandidates.push(candidate)
108+
candidates.add(candidate)
108109
}
109110
}
110111

@@ -128,7 +129,7 @@ export default function tailwindcss(): Plugin[] {
128129
addWatchFile(path.posix.join(relative, glob.pattern))
129130
}
130131

131-
return build(candidates.splice(0).concat(initialCandidates))
132+
return build(Array.from(candidates))
132133
}
133134

134135
async function generateOptimizedCss(

0 commit comments

Comments
 (0)