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:
parent
45fb21e753
commit
84ebe19da2
4 changed files with 92 additions and 26 deletions
|
|
@ -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`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue