diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 944454f41..560643c79 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -7,7 +7,7 @@ import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http import type { AddressInfo } from 'node:net' import EventEmitter from 'node:events' -import { existsSync, watch } from 'node:fs' +import { existsSync, statSync, watch } from 'node:fs' import { mkdir } from 'node:fs/promises' import process from 'node:process' import { pathToFileURL } from 'node:url' @@ -73,6 +73,28 @@ interface NuxtDevServerOptions { // https://regex101.com/r/7HkR5c/1 const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ +export class FileChangeTracker { + private mtimes = new Map() + + shouldEmitChange(filePath: string): boolean { + try { + const stats = statSync(filePath) + const currentMtime = stats.mtimeMs + const lastMtime = this.mtimes.get(filePath) + + this.mtimes.set(filePath, currentMtime) + + // emit change for new file or mtime has changed + return lastMtime === undefined || currentMtime !== lastMtime + } + catch { + // remove from cache if it has been deleted or is inaccessible + this.mtimes.delete(filePath) + return true + } + } +} + type NuxtWithServer = Omit & { server?: NitroDevServer } interface DevServerEventMap { @@ -89,6 +111,7 @@ export class NuxtDevServer extends EventEmitter { private _currentNuxt?: NuxtWithServer private _loadingMessage?: string private _loadingError?: Error + private _fileChangeTracker = new FileChangeTracker() private cwd: string loadDebounced: (reload?: boolean, reason?: string) => void @@ -310,7 +333,11 @@ export class NuxtDevServer extends EventEmitter { const distDir = resolve(this._currentNuxt.options.buildDir, 'dist') await mkdir(distDir, { recursive: true }) this._distWatcher = watch(distDir) - this._distWatcher.on('change', () => { + this._distWatcher.on('change', (_event, file: string) => { + if (!this._fileChangeTracker.shouldEmitChange(resolve(distDir, file || ''))) { + return + } + this.loadDebounced(true, '.nuxt/dist directory has been removed') }) @@ -374,8 +401,13 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '. const configWatcher = watch(cwd) let configDirWatcher = existsSync(resolve(cwd, '.config')) ? createConfigDirWatcher(cwd, onReload) : undefined const dotenvFileNames = new Set(Array.isArray(dotenvFileName) ? dotenvFileName : [dotenvFileName]) + const fileWatcher = new FileChangeTracker() configWatcher.on('change', (_event, file: string) => { + if (!fileWatcher.shouldEmitChange(resolve(cwd, file))) { + return + } + if (dotenvFileNames.has(file)) { onRestart() } @@ -397,9 +429,14 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '. function createConfigDirWatcher(cwd: string, onReload: (file: string) => void) { const configDir = resolve(cwd, '.config') + const fileWatcher = new FileChangeTracker() const configDirWatcher = watch(configDir) configDirWatcher.on('change', (_event, file: string) => { + if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) { + return + } + if (RESTART_RE.test(file)) { onReload(file) } diff --git a/packages/nuxi/test/unit/file-watcher.spec.ts b/packages/nuxi/test/unit/file-watcher.spec.ts new file mode 100644 index 000000000..e42101871 --- /dev/null +++ b/packages/nuxi/test/unit/file-watcher.spec.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { FileChangeTracker } from '../../src/dev/utils' + +describe('fileWatcher', () => { + let tempDir: string + let testFile: string + let fileWatcher: FileChangeTracker + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'nuxt-cli-test-')) + testFile = join(tempDir, 'test-config.js') + fileWatcher = new FileChangeTracker() + }) + + afterEach(async () => { + if (existsSync(tempDir)) { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + it('should return true for first check of a file', async () => { + await writeFile(testFile, 'initial content') + + const shouldEmit = fileWatcher.shouldEmitChange(testFile) + expect(shouldEmit).toBe(true) + }) + + it('should return false when file has not been modified', async () => { + await writeFile(testFile, 'initial content') + + // First call should return true (new file) + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + + // Second call without modification should return false + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + + // Third call still should return false + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + }) + + it('should return true when file has been modified', async () => { + await writeFile(testFile, 'initial content') + + // First check + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + + // No modification - should return false + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + + // Wait a bit and modify the file + await new Promise(resolve => setTimeout(resolve, 10)) + await writeFile(testFile, 'modified content') + + // Should return true because file was modified + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + + // Subsequent check without modification should return false + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + }) + + it('should handle file deletion gracefully', async () => { + await writeFile(testFile, 'content') + + // First check + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + + // Delete the file + await rm(testFile) + + // Should return true when file is deleted (indicates change) + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + }) + + it('should detect mtime changes even with same content', async () => { + await writeFile(testFile, 'same content') + + // First check + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + + // No change + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + + // Manually update mtime to simulate file modification + const now = Date.now() + await utimes(testFile, new Date(now), new Date(now + 1000)) + + // Should detect the mtime change + expect(fileWatcher.shouldEmitChange(testFile)).toBe(true) + }) +})