Lazy load @parcel/watcher (#20325)

This PR ensure that we lazy load the `@parcel/watcher` in the
`@tailwindcss/cli`. This means that we don't have to load it at all when
using a normal build or when using `--watch --poll` combination.

The bigger reason is that on some platforms `@parcel/watcher` might not
work, and therefore the build will fail even if you don't use `--watch`
at all.

This PR fixes that by lazy loading it. Then, if we can't load it when
using `--watch`, a useful workaround is shown by using `--watch --poll`
instead.

This PR also improves showing errors such that the `error.cause`
property can be rendered as well.

Maybe in the future we can make use of deferred imports
(https://github.com/tc39/proposal-defer-import-eval)

Fixes: #20322

## Test plan

1. All tests still pass [ci-all]
2. Fabricated a fake error locally to prove that we can still use a
normal build and `--watch --poll` as a workaround

<img width="1122" height="1376"
alt="file-2f91d99cfb9eb0e24a196c2dd358c1f3"
src="https://github.com/user-attachments/assets/e37658ee-0827-422e-bd50-8ea5a85e1b7c"
/>
This commit is contained in:
Robin Malfait 2026-07-13 15:53:42 +02:00 • committed by GitHub
parent 35a3e9c515
commit b03e5e7731
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 62 additions and 22 deletions

View file

@ -17,8 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix parsing selectors like `[data-foo]div` as one selector instead of two ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303))
- Fix stale `@tailwindcss/postcss` output when changed input CSS is passed from a preprocessor like Sass without updating the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310))
- Ensure CSS nesting is handled even when Lightning CSS isn't run, like in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124))
- Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#19830](https://github.com/tailwindlabs/tailwindcss/issues/19830))
- Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/issues/20314))
- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `<length>` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319))
- Lazily load `@parcel/watcher` when using the `--watch` flag in `@tailwindcss/cli`, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/issues/20325))
## [4.3.2] - 2026-06-26

View file

@ -1,4 +1,3 @@
import watcher from '@parcel/watcher'
import {
compile,
env,
@ -21,6 +20,7 @@ import {
highlight,
println,
relative,
wordWrap,
} from '../../utils/renderer'
import { drainStdin, outputFile } from './utils'
@ -80,17 +80,44 @@ export function options() {
} satisfies Arg
}
function formatError(error: unknown): string {
let seen = new Set() // Track seen errors to prevent circular errors
function render(err: unknown, depth: number): string[] {
let indent = ' '.repeat(depth)
let width = (process.stderr.columns ?? Infinity) - 2 * (depth + 1)
let output = [
`${indent}${red(depth === 0 ? 'Error:' : 'Caused by:')}`,
`${indent}${dim('\u250C')}`,
]
for (let line of `${err}`.split('\n')) {
let wrapped = wordWrap(line, width)
if (wrapped.length === 0) wrapped = ['']
for (let chunk of wrapped) {
output.push(`${indent}${dim('\u2502')} ${chunk}`)
}
}
output.push(`${indent}${dim('\u2514')}`)
if (typeof err === 'object' && err !== null && 'cause' in err && err.cause != null) {
if (!seen.add(err).has(err.cause)) {
output.push(...render(err.cause, depth + 1))
}
}
return output
}
return render(error, 0).join('\n')
}
async function handleError<T>(fn: () => T): Promise<T> {
try {
return await fn()
} catch (err) {
eprintln(
[red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n'),
)
eprintln(formatError(err))
process.exit(1)
}
}
@ -302,6 +329,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Watch for changes
if (args['--watch'] && pollInterval === false) {
// Ensure the file watcher can be loaded before setting up any watchers,
// such that we can present a helpful error message if needed.
await handleError(() => loadWatcher())
cleanupWatchers.push(
await createWatchers(await watchDirectories(scanner), async function handle(files) {
try {
@ -445,12 +476,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Catch any errors and print them to stderr, but don't exit the process
// and keep watching.
eprintln(
[red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n'),
)
eprintln(formatError(err))
let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
@ -599,12 +625,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} catch (err) {
fullRebuildPaths = backupRebuildPaths
let message = [red('Error:'), dim('\u250C')]
.concat(`${err}`.split('\n').map((line) => `${dim('\u2502')} ${line}`))
.concat(dim('\u2514'))
.join('\n')
logPollingMessage(message)
logPollingMessage(formatError(err))
}
if (!args['--silent']) {
@ -658,7 +679,23 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
}
}
// Load `@parcel/watcher` lazily so a missing or broken native binding only
// affects `--watch` (without `--poll`), instead of crashing one-off builds and
// polling mode as well.
async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
try {
return (await import('@parcel/watcher')).default
} catch (err) {
throw new Error(
`Failed to load the file watcher. Your platform may not be supported by \`@parcel/watcher\`. As a workaround, you can use polling instead by passing the \`--watch --poll\` flags.`,
{ cause: err },
)
}
}
async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
let watcher = await loadWatcher()
// Remove any directories that are children of an already watched directory.
// If we don't we may not get notified of certain filesystem events regardless
// of whether or not they are for the directory that is duplicated.

View file

@ -49,7 +49,9 @@ export function wordWrap(text: string, width: number) {
for (let word of words) {
let wordLength = stripVTControlCharacters(word).length
if (lineLength + wordLength + 1 > width) {
// A word longer than `width` is kept on its own line rather than
// introducing an empty line before it.
if (lineLength > 0 && lineLength + wordLength + 1 > width) {
lines.push(line)
line = ''
lineLength = 0