From 4341699ee8d989609524674dcbc48583d89bd1ba Mon Sep 17 00:00:00 2001 From: Abbas Date: Sun, 26 Oct 2025 05:07:28 -0700 Subject: [PATCH] fix: normalize file paths in ensureWatchedFile for cross-platform compatibility Fixes path comparison issue where Windows paths with backslashes were not properly compared against POSIX-formatted root paths, causing unnecessary files to be added to the watcher. The root path is always normalized to POSIX format in Vite's config, but file paths passed to ensureWatchedFile may come in Windows format. This change normalizes the file path before comparison to ensure consistent behavior across platforms. --- .../vite/src/node/__tests__/utils.spec.ts | 117 +++++++++++++++++- packages/vite/src/node/utils.ts | 3 +- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/node/__tests__/utils.spec.ts b/packages/vite/src/node/__tests__/utils.spec.ts index bd52e6b13d65e4..90b50c51756e0b 100644 --- a/packages/vite/src/node/__tests__/utils.spec.ts +++ b/packages/vite/src/node/__tests__/utils.spec.ts @@ -1,12 +1,13 @@ import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { fileURLToPath } from 'mlly' import { asyncFlatten, bareImportRE, combineSourcemaps, + ensureWatchedFile, extractHostnamesFromCerts, extractHostnamesFromSubjectAltName, flattenId, @@ -1059,3 +1060,117 @@ describe('resolveServerUrls', () => { expect(result.local).toContain('https://localhost:3000/') }) }) + +describe('ensureWatchedFile', () => { + test('should normalize Windows paths before comparison', () => { + const mockWatcher = { + add: vi.fn(), + } as any + + // POSIX-normalized root (how Vite stores it internally) + const root = '/users/test/project' + + // Simulate a Windows path (backslashes) for a file outside root + const windowsFilePath = isWindows + ? 'C:\\external\\module\\index.js' + : '/external/module/index.js' + + // Mock fs.existsSync to return true for this test file + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + + ensureWatchedFile(mockWatcher, windowsFilePath, root) + + // File should be added to watcher since it's outside root + expect(mockWatcher.add).toHaveBeenCalledWith( + expect.stringContaining('index.js'), + ) + + // Restore original fs.existsSync + vi.mocked(fs.existsSync).mockRestore() + }) + + test('should not watch files inside root with mixed path separators', () => { + const mockWatcher = { + add: vi.fn(), + } as any + + const root = isWindows ? 'C:/users/test/project' : '/users/test/project' + + // File inside root but with Windows-style backslashes + const fileInRoot = isWindows + ? 'C:\\users\\test\\project\\src\\main.js' + : '/users/test/project/src/main.js' + + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + + ensureWatchedFile(mockWatcher, fileInRoot, root) + + // File should NOT be added since it's inside root + expect(mockWatcher.add).not.toHaveBeenCalled() + + vi.mocked(fs.existsSync).mockRestore() + }) + + test('should handle POSIX paths correctly', () => { + const mockWatcher = { + add: vi.fn(), + } as any + + const root = '/users/test/project' + const posixFile = '/external/module/index.js' + + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + + ensureWatchedFile(mockWatcher, posixFile, root) + + expect(mockWatcher.add).toHaveBeenCalled() + + vi.mocked(fs.existsSync).mockRestore() + }) + + test('should not watch files containing null bytes', () => { + const mockWatcher = { + add: vi.fn(), + } as any + + const root = '/users/test/project' + const fileWithNullByte = '/external/\0virtual-module' + + vi.spyOn(fs, 'existsSync').mockReturnValue(true) + + ensureWatchedFile(mockWatcher, fileWithNullByte, root) + + expect(mockWatcher.add).not.toHaveBeenCalled() + + vi.mocked(fs.existsSync).mockRestore() + }) + + test('should handle null file parameter', () => { + const mockWatcher = { + add: vi.fn(), + } as any + + const root = '/users/test/project' + + ensureWatchedFile(mockWatcher, null, root) + + expect(mockWatcher.add).not.toHaveBeenCalled() + }) + + test('should not watch non-existent files', () => { + const mockWatcher = { + add: vi.fn(), + } as any + + const root = '/users/test/project' + const nonExistentFile = '/external/does-not-exist.js' + + vi.spyOn(fs, 'existsSync').mockReturnValue(false) + + ensureWatchedFile(mockWatcher, nonExistentFile, root) + + expect(mockWatcher.add).not.toHaveBeenCalled() + + vi.mocked(fs.existsSync).mockRestore() + }) +}) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 68c1883fdcca56..dbae3fa3065089 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -742,7 +742,8 @@ export function ensureWatchedFile( if ( file && // only need to watch if out of root - !file.startsWith(withTrailingSlash(root)) && + // Normalize file path for cross-platform compatibility (Windows vs POSIX) + !normalizePath(file).startsWith(withTrailingSlash(root)) && // some rollup plugins use null bytes for private resolved Ids !file.includes('\0') && fs.existsSync(file)