Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions integrations/postcss/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -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',
Expand Down
80 changes: 80 additions & 0 deletions packages/@tailwindcss-postcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
)
})
})
72 changes: 66 additions & 6 deletions packages/@tailwindcss-postcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' })
Comment on lines 402 to +426

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the direct console.error call.

Line 402 prints every error before the plugin reports it through result.warn() or throws it. Non-optimized builds therefore produce duplicate diagnostics. Optimized builds also print an error before PostCSS reports the thrown error. Let the PostCSS warning and error paths own diagnostic output.

Proposed fix
-            console.error(error)
-
             let message =
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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' })
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 = ' '
}
},
},
Expand Down