Skip to content

Commit d4a0b01

Browse files
authored
Merge branch 'main' into fix/issue-19422
2 parents a7d30ab + 6e2b60e commit d4a0b01

19 files changed

Lines changed: 182 additions & 77 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Allow multiple `@utility` definitions with the same name but different value types ([#19777](https://github.com/tailwindlabs/tailwindcss/pull/19777))
2828
- Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707))
2929
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
30+
- Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003))
3031

3132
## [4.2.4] - 2026-04-21
3233

integrations/postcss/core-as-postcss-plugin.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe.each(Object.keys(variantConfig))('%s', (variant) => {
5050
async ({ exec, expect }) => {
5151
await expect(
5252
exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }),
53-
).rejects.toThrowError(
53+
).rejects.toThrow(
5454
`It looks like you're trying to use \`tailwindcss\` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install \`@tailwindcss/postcss\` and update your PostCSS configuration.`,
5555
)
5656
},

integrations/utils.ts

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import dedent from 'dedent'
22
import fastGlob from 'fast-glob'
3-
import { exec, spawn } from 'node:child_process'
3+
import { exec, execFile, spawn, type ChildProcess } from 'node:child_process'
44
import fs from 'node:fs/promises'
5+
import { createServer } from 'node:net'
56
import { platform, tmpdir } from 'node:os'
67
import path from 'node:path'
7-
import { stripVTControlCharacters } from 'node:util'
8+
import { promisify, stripVTControlCharacters } from 'node:util'
89
import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
910
import { test as defaultTest, type ExpectStatic } from 'vitest'
1011
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
@@ -64,12 +65,15 @@ interface TestFlags {
6465
only?: boolean
6566
skip?: boolean
6667
debug?: boolean
68+
concurrent?: boolean
6769
}
6870

6971
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
7072

7173
export const IS_WINDOWS = platform() === 'win32'
7274

75+
const execFileAsync = promisify(execFile)
76+
7377
const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000
7478
const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000
7579

@@ -82,7 +86,7 @@ export function test(
8286
name: string,
8387
config: TestConfig,
8488
testCallback: TestCallback,
85-
{ only = false, skip = false, debug = false }: TestFlags = {},
89+
{ only = false, skip = false, debug = false, concurrent = false }: TestFlags = {},
8690
) {
8791
return defaultTest(
8892
name,
@@ -91,7 +95,7 @@ export function test(
9195
retry: process.env.CI ? 2 : 0,
9296
only: only || (!process.env.CI && debug),
9397
skip,
94-
concurrent: true,
98+
concurrent,
9599
},
96100
async (options) => {
97101
let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
@@ -170,6 +174,7 @@ export function test(
170174
if (debug) console.log(`>& ${command}`)
171175
let child = spawn(command, {
172176
cwd,
177+
detached: !IS_WINDOWS,
173178
shell: true,
174179
...childProcessOptions,
175180
env: {
@@ -178,19 +183,22 @@ export function test(
178183
},
179184
})
180185

181-
function dispose() {
182-
if (!child.kill()) {
183-
child.kill('SIGKILL')
184-
}
186+
let disposed = false
187+
188+
async function dispose() {
189+
if (disposed) return disposePromise
190+
disposed = true
191+
192+
await killProcessTree(child)
185193

186-
let timer = setTimeout(
187-
() =>
188-
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
189-
ASSERTION_TIMEOUT,
194+
let timer = setTimeout(() => {
195+
forceKillProcessTree(child)
196+
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`))
197+
}, ASSERTION_TIMEOUT)
198+
disposePromise.then(
199+
() => clearTimeout(timer),
200+
() => clearTimeout(timer),
190201
)
191-
disposePromise.finally(() => {
192-
clearTimeout(timer)
193-
})
194202
return disposePromise
195203
}
196204
disposables.push(dispose)
@@ -428,11 +436,18 @@ export function test(
428436
let disposables: (() => Promise<void>)[] = []
429437

430438
async function dispose() {
431-
await Promise.all(disposables.map((dispose) => dispose()))
439+
let results = await Promise.allSettled(disposables.map((dispose) => dispose()))
432440

433441
if (!debug) {
434442
await gracefullyRemove(root)
435443
}
444+
445+
let errors = results.flatMap((result) =>
446+
result.status === 'rejected' ? [result.reason] : [],
447+
)
448+
if (errors.length > 0) {
449+
throw new AggregateError(errors, 'Failed to clean up spawned processes')
450+
}
436451
}
437452

438453
options.onTestFinished(dispose)
@@ -461,6 +476,9 @@ test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
461476
test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => {
462477
return test(name, config, testCallback, { skip: true })
463478
}
479+
test.concurrent = (name: string, config: TestConfig, testCallback: TestCallback) => {
480+
return test(name, config, testCallback, { concurrent: true })
481+
}
464482
test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => {
465483
return test(name, config, testCallback, { debug: true })
466484
}
@@ -607,6 +625,65 @@ export async function fetchStyles(base: string, path = '/'): Promise<string> {
607625
}, '')
608626
}
609627

628+
export async function getRandomPort() {
629+
return new Promise<number>((resolve, reject) => {
630+
let server = createServer()
631+
server.unref()
632+
server.on('error', reject)
633+
server.listen(0, '127.0.0.1', () => {
634+
let address = server.address()
635+
server.close(() => {
636+
if (address && typeof address === 'object') {
637+
resolve(address.port)
638+
} else {
639+
reject(new Error('Unable to allocate random port'))
640+
}
641+
})
642+
})
643+
})
644+
}
645+
646+
async function killProcessTree(child: ChildProcess) {
647+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
648+
return
649+
}
650+
651+
if (IS_WINDOWS) {
652+
await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
653+
timeout: ASSERTION_TIMEOUT,
654+
windowsHide: true,
655+
}).catch(() => {})
656+
return
657+
}
658+
659+
try {
660+
process.kill(-child.pid, 'SIGTERM')
661+
} catch (error: any) {
662+
if (error?.code !== 'ESRCH') {
663+
child.kill()
664+
}
665+
}
666+
}
667+
668+
function forceKillProcessTree(child: ChildProcess) {
669+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
670+
return
671+
}
672+
673+
if (IS_WINDOWS) {
674+
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {})
675+
return
676+
}
677+
678+
try {
679+
process.kill(-child.pid, 'SIGKILL')
680+
} catch (error: any) {
681+
if (error?.code !== 'ESRCH') {
682+
child.kill('SIGKILL')
683+
}
684+
}
685+
}
686+
610687
async function gracefullyRemove(dir: string) {
611688
// Skip removing the directory in CI because it can stall on Windows
612689
if (!process.env.CI) {

integrations/vite/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
836836
async ({ root, fs, exec, expect }) => {
837837
await expect(() =>
838838
exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }),
839-
).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist')
839+
).rejects.toThrow('The `source(../i-do-not-exist)` does not exist')
840840

841841
let files = await fs.glob('project-a/dist/**/*.css')
842842
expect(files).toHaveLength(0)

integrations/vite/nuxt.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
1+
import {
2+
candidate,
3+
css,
4+
fetchStyles,
5+
getRandomPort,
6+
html,
7+
json,
8+
retryAssertion,
9+
test,
10+
ts,
11+
} from '../utils'
212

313
const SETUP = {
414
fs: {
@@ -84,7 +94,8 @@ test('build', SETUP, async ({ spawn, exec, expect }) => {
8494
await exec('pnpm nuxt build')
8595
// The Nuxt preview server does not automatically assign a free port if 3000
8696
// is taken, so we use a random port instead.
87-
let process = await spawn(`pnpm nuxt preview --port 8724`, {
97+
let port = await getRandomPort()
98+
let process = await spawn(`pnpm nuxt preview --port ${port}`, {
8899
env: {
89100
TEST: 'false',
90101
NODE_ENV: 'development',

packages/@tailwindcss-cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"@parcel/watcher": "^2.5.1",
3333
"@tailwindcss/node": "workspace:*",
3434
"@tailwindcss/oxide": "workspace:*",
35-
"enhanced-resolve": "^5.20.1",
35+
"enhanced-resolve": "^5.21.0",
3636
"mri": "^1.2.0",
3737
"picocolors": "^1.1.1",
3838
"tailwindcss": "workspace:*"

packages/@tailwindcss-node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
},
3939
"dependencies": {
4040
"@jridgewell/remapping": "^2.3.5",
41-
"enhanced-resolve": "^5.20.1",
41+
"enhanced-resolve": "^5.21.0",
4242
"jiti": "^2.6.1",
4343
"lightningcss": "catalog:",
4444
"magic-string": "^0.30.21",

packages/@tailwindcss-postcss/src/index.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('processing without specifying a base path', () => {
120120
afterEach(() => unlink(filepath))
121121

122122
test('the current working directory is used by default', async () => {
123-
const spy = vi.spyOn(process, 'cwd')
123+
using spy = vi.spyOn(process, 'cwd')
124124
spy.mockReturnValue(dir)
125125

126126
let processor = postcss([tailwindcss({ optimize: { minify: false } })])
@@ -389,7 +389,7 @@ describe('concurrent builds', () => {
389389
afterEach(() => rm(dir, { recursive: true, force: true }))
390390

391391
test('does experience a race-condition when calling the plugin two times for the same change', async () => {
392-
const spy = vi.spyOn(process, 'cwd')
392+
using spy = vi.spyOn(process, 'cwd')
393393
spy.mockReturnValue(dir)
394394

395395
let from = path.join(dir, 'index.css')

packages/@tailwindcss-standalone/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"@tailwindcss/forms": "^0.5.11",
3131
"@tailwindcss/typography": "^0.5.19",
3232
"detect-libc": "1.0.3",
33-
"enhanced-resolve": "^5.20.1",
33+
"enhanced-resolve": "^5.21.0",
3434
"tailwindcss": "workspace:*"
3535
},
3636
"__notes": "These binary packages must be included so Bun can build the CLI for all supported platforms. We also rely on Lightning CSS and Parcel being patched so Bun can statically analyze the executables.",

packages/@tailwindcss-upgrade/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"@tailwindcss/node": "workspace:*",
3131
"@tailwindcss/oxide": "workspace:*",
3232
"dedent": "1.7.2",
33-
"enhanced-resolve": "^5.20.1",
33+
"enhanced-resolve": "^5.21.0",
3434
"globby": "^16.2.0",
3535
"jiti": "^2.6.1",
3636
"mri": "^1.2.0",

0 commit comments

Comments
 (0)