From 8021b80b0e81c846060eaac73eb5165be2e22b44 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 10 Mar 2026 00:52:49 -0700 Subject: [PATCH] fix(cli): use case-insensitive path comparison on Windows in watch mode On Windows (case-insensitive filesystem), file paths reported by the watcher may differ in casing from the resolved input/output paths. This caused watch mode to miss full rebuilds when the input CSS path had different casing, and could also cause infinite rebuild loops when the output file path casing didn't match. Add a `pathsEqual()` helper that normalizes case on Windows and use it for the two path comparisons in the watch handler: - `resolvedFullRebuildPaths` membership check - output file identity check Closes #16784 Co-Authored-By: Claude Opus 4.6 --- .../src/commands/build/index.ts | 5 +-- .../src/utils/paths-equal.test.ts | 36 +++++++++++++++++++ .../@tailwindcss-cli/src/utils/paths-equal.ts | 12 +++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 packages/@tailwindcss-cli/src/utils/paths-equal.test.ts create mode 100644 packages/@tailwindcss-cli/src/utils/paths-equal.ts diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index eabd2a1de022..c7bd85c5c938 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -22,6 +22,7 @@ import { println, relative, } from '../../utils/renderer' +import { pathsEqual } from '../../utils/paths-equal' import { drainStdin, outputFile } from './utils' const css = String.raw @@ -257,7 +258,7 @@ export async function handle(args: Result>) { try { // If the only change happened to the output file, then we don't want to // trigger a rebuild because that will result in an infinite loop. - if (files.length === 1 && files[0] === args['--output']) return + if (files.length === 1 && pathsEqual(files[0], args['--output']!)) return using I = new Instrumentation() DEBUG && I.start('[@tailwindcss/cli] (watcher)') @@ -274,7 +275,7 @@ export async function handle(args: Result>) { // If one of the changed files is related to the input CSS or JS // config/plugin files, then we need to do a full rebuild because // the theme might have changed. - if (resolvedFullRebuildPaths.includes(file)) { + if (resolvedFullRebuildPaths.some((p) => pathsEqual(p, file))) { rebuildStrategy = 'full' // No need to check the rest of the events, because we already know we diff --git a/packages/@tailwindcss-cli/src/utils/paths-equal.test.ts b/packages/@tailwindcss-cli/src/utils/paths-equal.test.ts new file mode 100644 index 000000000000..922a1491010b --- /dev/null +++ b/packages/@tailwindcss-cli/src/utils/paths-equal.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('./paths-equal', async () => { + const actual = await vi.importActual('./paths-equal') + return actual +}) + +// We test the logic directly rather than mocking process.platform, +// because the function captures IS_WINDOWS at module load time. +// Instead we test the underlying behavior: on the current platform, +// paths with matching case should always be equal, and we verify +// the case-insensitive logic separately. + +describe('pathsEqual', () => { + it('returns true for identical paths', async () => { + const { pathsEqual } = await import('./paths-equal') + expect(pathsEqual('/foo/bar/baz.css', '/foo/bar/baz.css')).toBe(true) + }) + + it('returns false for completely different paths', async () => { + const { pathsEqual } = await import('./paths-equal') + expect(pathsEqual('/foo/bar.css', '/foo/baz.css')).toBe(false) + }) + + if (process.platform === 'win32') { + it('returns true for paths differing only in case on Windows', async () => { + const { pathsEqual } = await import('./paths-equal') + expect(pathsEqual('C:\\src\\Input.css', 'C:\\src\\input.css')).toBe(true) + }) + } else { + it('returns false for paths differing in case on non-Windows', async () => { + const { pathsEqual } = await import('./paths-equal') + expect(pathsEqual('/src/Input.css', '/src/input.css')).toBe(false) + }) + } +}) diff --git a/packages/@tailwindcss-cli/src/utils/paths-equal.ts b/packages/@tailwindcss-cli/src/utils/paths-equal.ts new file mode 100644 index 000000000000..aa56fb887deb --- /dev/null +++ b/packages/@tailwindcss-cli/src/utils/paths-equal.ts @@ -0,0 +1,12 @@ +const IS_WINDOWS = process.platform === 'win32' + +/** + * Compare two file paths for equality. On Windows, the comparison is + * case-insensitive because the filesystem is case-insensitive. + */ +export function pathsEqual(a: string, b: string): boolean { + if (IS_WINDOWS) { + return a.toLowerCase() === b.toLowerCase() + } + return a === b +}