Write plugin logs to stderr in canonicalize (#20561)

## Summary

`tailwindcss canonicalize` writes its results to stdout, but the design
system is loaded in the same process, so anything a plugin logs with
`console.log` while it loads (e.g. the daisyUI banner) ends up in front
of the results. That breaks `--format json` and shifts every line in
`--stream` mode.

This points `console.log`, `console.info` and `console.debug` at
`console.error` for the `canonicalize` command, so stdout only carries
the results and plugin output goes to stderr.

Fixes #20560

## Test plan

Added an integration test with a plugin that calls `console.log`, it
fails on `main` and passes with this change.

Also checked by hand with the reproduction from the issue:

```
$ tailwindcss canonicalize --css app.css "p-4 px-2 py-2" 2>/dev/null
p-2
$ printf 'p-4 px-2 py-2\nw-4 h-4\n' | tailwindcss canonicalize --css app.css --stream 2>/dev/null
p-2
size-4
```

---------

Co-authored-by: Robin Malfait <robin.malfait@shopify.com>
This commit is contained in:
Lazizbek ErgashevandRobin Malfait authored and GitHub committed 2026-10-13 18:04:22 +02:00
1 parent 9833b487a8
commit 1a38f848f0
7 files changed
+139 -16

No files matched your search

+1
View File
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: keep inline `theme(…)` alphas as an arbitrary percentage when a custom `--opacity-*` theme value would change the opacity (e.g. `theme(colors.red.500/50%)` → `text-red-500/[50%]` with `--opacity-50: 10%`) ([#20563](https://github.com/tailwindlabs/tailwindcss/pull/20563))
- Ensure the `@property` fallback isn't applied in Safari versions that no longer support `margin-trim: inline` ([#20569](https://github.com/tailwindlabs/tailwindcss/pull/20569))
- Treat viewport units like `dvmin` and `svi` and font-relative units like `cap` and `rex` as lengths in arbitrary values (e.g. `text-[10dvmin]` generates `font-size` instead of `color`) ([#20516](https://github.com/tailwindlabs/tailwindcss/pull/20516))
- Write plugin logs to stderr in `tailwindcss canonicalize` so they don't end up in the results on stdout ([#20561](https://github.com/tailwindlabs/tailwindcss/pull/20561))
## [4.3.3] - 2026-07-16
+36
View File
@@ -3184,3 +3184,39 @@ test(
function withBOM(text: string): string {
return '\uFEFF' + text
}
test(
'canonicalize writes plugin logs to stderr',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'plugin.js': js`
module.exports = function () {
console.log('hello from a plugin')
}
`,
'index.css': css`
@import 'tailwindcss';
@plugin './plugin.js';
`,
},
},
async ({ exec, expect }) => {
let { stdout, stderr, exitCode } = await exec(
'pnpm tailwindcss canonicalize --css index.css "p-4 px-2 py-2"',
{},
{ streams: true },
)
expect(exitCode).toBe(0)
expect(stdout.trim()).toMatchInlineSnapshot(`"p-2"`)
expect(stderr.trim()).toMatchInlineSnapshot(`"hello from a plugin"`)
},
)
+28 -3
View File
@@ -35,6 +35,17 @@ interface ChildProcessOptions {
interface ExecOptions {
ignoreStdErr?: boolean
stdin?: string
// Return stdout, stderr and the exit code on their own, instead of one
// string. The promise also resolves when the command exits with a non-zero
// code.
streams?: boolean
}
interface ExecResult {
stdout: string
stderr: string
exitCode: number
}
interface TestConfig {
@@ -49,6 +60,11 @@ interface TestConfig {
interface TestContext {
root: string
expect: ExpectStatic
exec(
command: string,
options: ChildProcessOptions | undefined,
execOptions: ExecOptions & { streams: true },
): Promise<ExecResult>
exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise<string>
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
parseSourceMap(opts: string | SourceMapOptions): SourceMap
@@ -124,7 +140,7 @@ export function test(
command: string,
childProcessOptions: ChildProcessOptions = {},
execOptions: ExecOptions = {},
) {
): Promise<any> {
let cwd = childProcessOptions.cwd ?? root
let originalCommand = command
@@ -165,7 +181,7 @@ export function test(
console.log(`> cd ${relative}`)
}
if (debug) console.log(`> ${command}`)
return new Promise((resolve, reject) => {
return new Promise<string | ExecResult>((resolve, reject) => {
let child = exec(
command,
{
@@ -177,7 +193,16 @@ export function test(
},
},
(error, stdout, stderr) => {
if (error) {
if (execOptions.streams && (!error || typeof error.code === 'number')) {
if (only || debug) {
console.log(stdout.toString() + '\n\n' + stderr.toString())
}
resolve({
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: error?.code ?? 0,
})
} else if (error) {
if (command !== originalCommand) {
error.message = error.message.replace(command, originalCommand)
let mappedError = error as any
@@ -1,13 +1,16 @@
import path from 'node:path'
import { PassThrough, Readable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { runCommandLine, streamStdin } from '.'
import { normalizeWindowsSeparators } from '../../utils/test-helpers'
let css = normalizeWindowsSeparators(
path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/input.css'),
)
let cssWithPluginLogs = normalizeWindowsSeparators(
path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/input-with-plugin-logs.css'),
)
describe('runCommandLine', { timeout: 30_000 }, () => {
test('canonicalizes, collapses, and sorts candidate groups from positional arguments', async () => {
@@ -165,6 +168,30 @@ describe('runCommandLine', { timeout: 30_000 }, () => {
'{"input":"mt-2 mr-2 mb-2 ml-2","output":"m-2","changed":true}\n',
)
})
test('writes plugin logs to stderr', async () => {
let console = globalThis.console
using stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
using stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
let result = await runCommandLine({
argv: ['--css', cssWithPluginLogs, 'p-4 px-2 py-2 plugin-color-red'],
stdinIsTTY: true,
stdoutIsTTY: false,
})
expect(result).toEqual({
exitCode: 0,
stdout: 'p-2 plugin-color-red',
stderr: '',
})
expect(stdout).not.toHaveBeenCalled()
let logs = stderr.mock.calls.map(([chunk]) => chunk)
expect(logs).toContain('hello from a plugin\n')
expect(logs).toContain('compiling plugin-color-red\n')
expect(globalThis.console).toBe(console)
})
})
function createOutput() {
@@ -0,0 +1,2 @@
@import 'tailwindcss';
@plugin './plugin-with-logs.mjs';
@@ -0,0 +1,13 @@
export default function ({ matchUtilities }) {
console.log('hello from a plugin')
matchUtilities(
{
'plugin-color': (value) => {
console.log(`compiling plugin-color-${value}`)
return { color: value }
},
},
{ values: { red: 'red' } },
)
}
@@ -1,4 +1,5 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import { Console } from 'node:console'
import fs from 'node:fs/promises'
import path from 'node:path'
import { createInterface } from 'node:readline'
@@ -100,13 +101,15 @@ export async function runCommandLine({
}
if (flags['--stream']) {
await streamStdin({
css: flags['--css'],
cwd,
format,
input: process.stdin,
output: process.stdout,
})
await redirectConsoleToStderr(() =>
streamStdin({
css: flags['--css'],
cwd,
format,
input: process.stdin,
output: process.stdout,
}),
)
return { exitCode: 0, stdout: '', stderr: '' }
}
@@ -116,11 +119,13 @@ export async function runCommandLine({
return usageError('No candidate groups provided')
}
let results = await processCandidateGroups({
css: flags['--css'],
cwd,
inputs,
})
let results = await redirectConsoleToStderr(() =>
processCandidateGroups({
css: flags['--css'],
cwd,
inputs,
}),
)
return {
exitCode: 0,
@@ -274,6 +279,20 @@ async function loadDesignSystem(cssFile: string | null, cwd: string) {
})
}
// Plugins can log while they load (e.g. the daisyUI banner), and while their
// utilities are compiled during canonicalization. The results are written to
// stdout, so send the console output to stderr instead.
async function redirectConsoleToStderr<T>(fn: () => Promise<T>): Promise<T> {
let console = globalThis.console
globalThis.console = new Console({ stdout: process.stderr, stderr: process.stderr })
try {
return await fn()
} finally {
globalThis.console = console
}
}
function splitCandidateGroups(input: string) {
return input
.split(/\r?\n/g)