From 81f01722f3bc3529e359887ca3dc081cc4f3b4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20S=C3=B8gaard?= <9662430+andershagbard@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:24:21 +0200 Subject: [PATCH] Fix @tailwindcss/postcss losing watch dependencies after a compile error Any compile error (invalid `@apply`, a broken `@import`/`@reference`, a plain syntax error in an imported file) causes the plugin to throw, which makes the whole `postcss().process()` promise reject instead of resolve. Consumers that read `result.messages` to register file dependencies -- most notably `postcss-loader` (webpack) -- only ever do so from a *resolved* result, so on rejection none of the dependency messages for the CSS import graph are seen, dropping every file (not just the one that errored) from the watcher until some other, still-watched file happens to change. This was previously fixed in #17754 by not throwing, then unintentionally reintroduced by #18373, which reinstated the throw to fix #18370 (errors weren't failing production builds). Resolve both: throw and fail the build when `optimize` is enabled (preserves #18370's fix), otherwise report the error via `result.warn()` and keep serving the last known-good output, so `result` still resolves and dependency tracking -- including the scanner's content-glob dependencies when the failure happens before this attempt's own scan runs -- survives the error. --- integrations/postcss/index.test.ts | 24 ++++-- .../@tailwindcss-postcss/src/index.test.ts | 80 +++++++++++++++++++ packages/@tailwindcss-postcss/src/index.ts | 72 +++++++++++++++-- 3 files changed, 165 insertions(+), 11 deletions(-) diff --git a/integrations/postcss/index.test.ts b/integrations/postcss/index.test.ts index 80025f945411..9c7c3617bdf4 100644 --- a/integrations/postcss/index.test.ts +++ b/integrations/postcss/index.test.ts @@ -693,13 +693,17 @@ test( `, ) - // 2.5 Write to a content file - await fs.write('src/index.html', html` -
- `) - 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` +
+ `) + + 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', diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index b393cc382ec9..d8d9225b2fc5 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -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'), `
`) + 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/, + ) + }) +}) diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index a4a8d93268ae..bbf89211084c 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -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. + 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, + }) + } + + 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, + }) + } + } + } + console.error(error) - if (error && typeof error === 'object' && 'message' in error) { - throw root.error(`${error.message}`) + 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) } - throw root.error(`${error}`) + result.warn(message, { plugin: '@tailwindcss/postcss' }) + root.removeAll() + root.append(context.cachedPostCssAst.clone().nodes) + root.raws.indent = ' ' } }, },