This commit is contained in:
Anders Søgaard 2026-09-26 08:50:30 -04:00 • committed by GitHub
commit da324ed205
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 166 additions and 12 deletions

View file

@ -693,13 +693,17 @@ test(
`,
)
// 2.5 Write to a content file
await fs.write('src/index.html', html`
<div class="flex underline"></div>
`)
await process.onStderr((message) => message.includes('"./does-not-exist" is not exported'))
// The recovered build above still needs to finish registering its dependencies
// (including logging "Waiting for file changes...") before it's safe to write
// again -- writing sooner races a second, concurrent build against the one still
// settling, which is a separate, pre-existing sharp edge of this plugin's caching
// model, unrelated to error recovery itself. Flush first so this wait can't match
// a stale occurrence of that same message from an earlier, already-settled build.
process.flush()
await process.onStderr((message) => message.includes('Waiting for file changes...'))
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
@ -709,6 +713,16 @@ test(
"
`)
// 2.5 Write to a content file
await fs.write('src/index.html', html`
<div class="flex underline"></div>
`)
await process.onStderr((message) => message.includes('"./does-not-exist" is not exported'))
process.flush()
await process.onStderr((message) => message.includes('Waiting for file changes...'))
// 3. Fix the CSS file
await fs.write(
'src/tailwind.css',

View file

@ -516,3 +516,83 @@ test('does not register the input file as a dependency, even if it is passed in
plugin: expect.any(String),
})
})
describe('error recovery', () => {
let dir: string
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'tw-postcss'))
await writeFile(path.join(dir, 'index.html'), `<div class="underline"></div>`)
await writeFile(path.join(dir, 'index.css'), css` @import './dependency.css'; `)
await writeFile(path.join(dir, 'dependency.css'), css` @tailwind utilities; `)
})
afterEach(() => rm(dir, { recursive: true, force: true }))
test('dependency messages are still emitted when a build fails outside of `optimize` mode, so watchers do not lose track of the CSS import graph', async () => {
let from = path.join(dir, 'index.css')
let processor = postcss([tailwindcss({ base: dir, optimize: false })])
let dependencyFile = path.join(dir, 'dependency.css')
// 1. A successful build establishes `dependency.css` as a known dependency.
let ok = await processor.process(await readFile(from, 'utf8'), { from })
expect(ok.css).toContain('.underline')
expect(ok.messages).toContainEqual({
type: 'dependency',
plugin: expect.any(String),
file: expect.stringMatching(/dependency\.css$/),
parent: expect.stringMatching(/index\.css$/),
})
// Ensure the mtime below is actually observed as a change.
await new Promise((resolve) => setTimeout(resolve, 10))
// 2. Introduce a compile error (an unknown utility class) into that same file.
await writeFile(
dependencyFile,
css`
@tailwind utilities;
.broken {
@apply this-class-does-not-exist;
}
`,
)
// The build must not reject here. Tools like `postcss-loader` (used by webpack)
// only read `result.messages` -- and therefore only register file dependencies --
// from a *resolved* result. If this rejects instead, every dependency collected
// above is invisible to the consumer, and the entire CSS import graph stops being
// watched until some other, still-watched file happens to change.
let failed = await processor.process(await readFile(from, 'utf8'), { from })
expect(failed.warnings().length).toBeGreaterThan(0)
expect(failed.messages).toContainEqual({
type: 'dependency',
plugin: expect.any(String),
file: expect.stringMatching(/dependency\.css$/),
parent: expect.stringMatching(/index\.css$/),
})
})
test('still fails the build when `optimize` is enabled, so broken CSS is never shipped', async () => {
let from = path.join(dir, 'index.css')
let processor = postcss([tailwindcss({ base: dir, optimize: true })])
await processor.process(await readFile(from, 'utf8'), { from })
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(
path.join(dir, 'dependency.css'),
css`
@tailwind utilities;
.broken {
@apply this-class-does-not-exist;
}
`,
)
await expect(processor.process(await readFile(from, 'utf8'), { from })).rejects.toThrow(
/this-class-does-not-exist/,
)
})
})

View file

@ -348,7 +348,14 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
context.compiler = null
// Ensure all dependencies we have collected thus far are included so that the rebuild
// is correctly triggered
// is correctly triggered. This may run before we ever got to register the CSS import
// graph (`context.fullRebuildPaths`) and/or the candidate-scanning dependencies
// (`context.scanner`) for *this* build, e.g. when the failure happens while resolving
// `@import`/`@reference` targets, before scanning even starts. Re-report whatever we
// still know from the last successful build so watchers don't lose track of files that
// haven't changed.
let resolvedInputFile = path.resolve(base, inputFile)
for (let file of context.fullRebuildPaths) {
result.messages.push({
type: 'dependency',
@ -358,15 +365,68 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin {
})
}
// We found that throwing the error will cause PostCSS to no longer watch for changes
// in some situations so we instead log the error and continue with an empty stylesheet.
console.error(error)
if (context.scanner) {
for (let file of context.scanner.files) {
let absolutePath = path.resolve(file)
if (absolutePath === resolvedInputFile) continue
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file: absolutePath,
parent: result.opts.from,
})
}
if (error && typeof error === 'object' && 'message' in error) {
throw root.error(`${error.message}`)
for (let { base: globBase, pattern } of context.scanner.globs) {
if (pattern === '*' && base === globBase) continue
if (pattern === '') {
result.messages.push({
type: 'dependency',
plugin: '@tailwindcss/postcss',
file: path.resolve(globBase),
parent: result.opts.from,
})
} else {
result.messages.push({
type: 'dir-dependency',
plugin: '@tailwindcss/postcss',
dir: path.resolve(globBase),
glob: pattern,
parent: result.opts.from,
})
}
}
}
throw root.error(`${error}`)
console.error(error)
let message =
error && typeof error === 'object' && 'message' in error ? `${error.message}` : `${error}`
// In optimized (typically production) builds we want compilation errors to fail
// the build outright so broken CSS never ships.
//
// Otherwise, we avoid throwing here. Throwing causes PostCSS's `process()` promise
// to reject instead of resolve. Tools that rely on `result.messages` to register
// file dependencies (e.g. `postcss-loader`/webpack) only ever read `result.messages`
// from a *resolved* result, so on rejection none of the dependency messages above —
// or from any previously successful build — are seen. That drops every file in the
// CSS import graph from the watcher, not just the one that failed, and nothing
// recompiles again until some other, still-watched file happens to change.
//
// We instead report the error as a warning so `result` still resolves and dependency
// tracking keeps working. We keep serving the last known-good output (if any) rather
// than clearing the stylesheet, so a broken edit doesn't strip all styling while it's
// being fixed.
if (optimize) {
throw root.error(message)
}
result.warn(message, { plugin: '@tailwindcss/postcss' })
root.removeAll()
root.append(context.cachedPostCssAst.clone().nodes)
root.raws.indent = ' '
}
},
},