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>
This commit is contained in:
Philipp Spiess 2024-08-21 12:54:42 +02:00 • committed by GitHub
parent 45fb21e753
commit 84ebe19da2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 26 deletions

View file

@ -1,5 +1,5 @@
import { expect } from 'vitest'
import { candidate, css, fetchStylesFromIndex, js, json, retryAssertion, test } from '../utils'
import { candidate, css, fetchStyles, js, json, retryAssertion, test } from '../utils'
test(
'production build',
@ -131,7 +131,7 @@ test(
await spawn(`pnpm next dev ${bundler === 'turbo' ? '--turbo' : ''} --port ${port}`)
await retryAssertion(async () => {
let css = await fetchStylesFromIndex(port)
let css = await fetchStyles(port)
expect(css).toContain(candidate`underline`)
})
@ -145,7 +145,7 @@ test(
)
await retryAssertion(async () => {
let css = await fetchStylesFromIndex(port)
let css = await fetchStyles(port)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`text-red-500`)
})

View file

@ -262,8 +262,15 @@ export function test(
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(full, content)
},
read(filePath: string) {
return fs.readFile(path.resolve(root, filePath), 'utf8')
async read(filePath: string) {
let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
// Ensure that files read on Windows have \r\n line endings removed
if (IS_WINDOWS) {
content = content.replace(/\r\n/g, '\n')
}
return content
},
async glob(pattern: string) {
let files = await fastGlob(pattern, { cwd: root })
@ -500,8 +507,8 @@ export async function retryAssertion<T>(
throw error
}
export async function fetchStylesFromIndex(port: number): Promise<string> {
let index = await fetch(`http://localhost:${port}`)
export async function fetchStyles(port: number, path = '/'): Promise<string> {
let index = await fetch(`http://localhost:${port}${path}`)
let html = await index.text()
let regex = /<link rel="stylesheet" href="([a-zA-Z0-9\/_\.\?=%-]+)"/g

View file

@ -1,6 +1,17 @@
import path from 'node:path'
import { expect } from 'vitest'
import { candidate, css, fetchStylesFromIndex, html, js, json, test, ts, yaml } from '../utils'
import {
candidate,
css,
fetchStyles,
html,
js,
json,
retryAssertion,
test,
ts,
yaml,
} from '../utils'
test(
'production build',
@ -106,6 +117,14 @@ test(
<div class="underline">Hello, world!</div>
</body>
`,
'project-a/about.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="font-bold ">Tailwind Labs</div>
</body>
`,
'project-a/src/index.css': css`
@import 'tailwindcss/theme' theme(reference);
@import 'tailwindcss/utilities';
@ -119,15 +138,27 @@ test(
},
async ({ root, spawn, getFreePort, fs }) => {
let port = await getFreePort()
let process = await spawn(`pnpm vite dev --port ${port}`, {
await spawn(`pnpm vite dev --port ${port}`, {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((message) => message.includes('ready in'))
// Candidates are resolved lazily, so the first visit of index.html
// will only have candidates from this file.
await retryAssertion(async () => {
let css = await fetchStyles(port, '/index.html')
expect(css).toContain(candidate`underline`)
expect(css).not.toContain(candidate`font-bold`)
})
let css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`underline`)
// Going to about.html will extend the candidate list to include
// candidates from about.html.
await retryAssertion(async () => {
let css = await fetchStyles(port, '/about.html')
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`font-bold`)
})
// Updates are additive and cause new candidates to be added.
await fs.write(
'project-a/index.html',
html`
@ -139,11 +170,14 @@ test(
</body>
`,
)
await process.onStdout((message) => message.includes('page reload'))
css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`m-2`)
await retryAssertion(async () => {
let css = await fetchStyles(port)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`font-bold`)
expect(css).toContain(candidate`m-2`)
})
// Manually added `@source`s are watched and trigger a rebuild
await fs.write(
'project-b/src/index.js',
js`
@ -151,9 +185,33 @@ test(
module.exports = { className }
`,
)
await process.onStdout((message) => message.includes('page reload'))
await retryAssertion(async () => {
let css = await fetchStyles(port)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`font-bold`)
expect(css).toContain(candidate`m-2`)
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
})
css = await fetchStylesFromIndex(port)
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
// After updates to the CSS file, all previous candidates should still be in
// the generated CSS
await fs.write(
'project-a/src/index.css',
css`
${await fs.read('project-a/src/index.css')}
.red {
color: red;
}
`,
)
await retryAssertion(async () => {
let css = await fetchStyles(port)
expect(css).toContain(candidate`red`)
expect(css).toContain(candidate`m-2`)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`[.changed_&]:content-['project-b/src/index.js']`)
expect(css).toContain(candidate`font-bold`)
})
},
)

View file

@ -11,7 +11,7 @@ export default function tailwindcss(): Plugin[] {
let config: ResolvedConfig | null = null
let scanner: Scanner | null = null
let changedContent: { content: string; extension: string }[] = []
let candidates: string[] = []
let candidates = new Set<string>()
// In serve mode this is treated as a set — the content doesn't matter.
// In build mode, we store file contents to use them in renderChunk.
@ -69,10 +69,9 @@ export default function tailwindcss(): Plugin[] {
}
// Parse all candidates given the resolved files
let newCandidates = scanner.scanFiles([{ content: src, extension }])
for (let candidate of newCandidates) {
for (let candidate of scanner.scanFiles([{ content: src, extension }])) {
updated = true
candidates.push(candidate)
candidates.add(candidate)
}
return updated
@ -100,11 +99,13 @@ export default function tailwindcss(): Plugin[] {
// This should not be here, but right now the Vite plugin is setup where we
// setup a new scanner and compiler every time we request the CSS file
// (regardless whether it actually changed or not).
let initialCandidates = scanner.scan()
for (let candidate of scanner.scan()) {
candidates.add(candidate)
}
if (changedContent.length > 0) {
for (let candidate of scanner.scanFiles(changedContent.splice(0))) {
initialCandidates.push(candidate)
candidates.add(candidate)
}
}
@ -128,7 +129,7 @@ export default function tailwindcss(): Plugin[] {
addWatchFile(path.posix.join(relative, glob.pattern))
}
return build(candidates.splice(0).concat(initialCandidates))
return build(Array.from(candidates))
}
async function generateOptimizedCss(