diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index fc2b2f79b..8346719a8 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -7,7 +7,7 @@ import type { FSWatcher } from 'node:fs' import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http' import EventEmitter from 'node:events' -import { existsSync, statSync, watch } from 'node:fs' +import { existsSync, readdirSync, statSync, watch } from 'node:fs' import { mkdir } from 'node:fs/promises' import process from 'node:process' import { pathToFileURL } from 'node:url' @@ -70,22 +70,45 @@ export class FileChangeTracker { private mtimes = new Map() shouldEmitChange(filePath: string): boolean { + const resolved = resolve(filePath) try { - const stats = statSync(filePath) + const stats = statSync(resolved) const currentMtime = stats.mtimeMs - const lastMtime = this.mtimes.get(filePath) + const lastMtime = this.mtimes.get(resolved) - this.mtimes.set(filePath, currentMtime) + this.mtimes.set(resolved, 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) + this.mtimes.delete(resolved) return true } } + + prime(filePath: string, recursive: boolean = false): void { + const resolved = resolve(filePath) + const stat = statSync(resolved) + this.mtimes.set(resolved, stat.mtimeMs) + if (stat.isDirectory()) { + const entries = readdirSync(resolved) + for (const entry of entries) { + const fullPath = resolve(resolved, entry) + try { + const stats = statSync(fullPath) + this.mtimes.set(fullPath, stats.mtimeMs) + if (recursive && stats.isDirectory()) { + this.prime(fullPath, recursive) + } + } + catch { + // ignore + } + } + } + } } type NuxtWithServer = Omit & { server?: NitroDevServer | ReturnType } @@ -414,6 +437,7 @@ export class NuxtDevServer extends EventEmitter { // Watch dist directory const distDir = resolve(this.#currentNuxt.options.buildDir, 'dist') await mkdir(distDir, { recursive: true }) + this.#fileChangeTracker.prime(distDir) this.#distWatcher = watch(distDir) this.#distWatcher.on('change', (_event, file: string) => { if (!this.#fileChangeTracker.shouldEmitChange(resolve(distDir, file || ''))) { @@ -510,10 +534,11 @@ function resolveDevServerDefaults(listenOptions: Partial void, onReload: (file: string) => void) { + const fileWatcher = new FileChangeTracker() + fileWatcher.prime(cwd) 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))) { @@ -543,6 +568,7 @@ function createConfigDirWatcher(cwd: string, onReload: (file: string) => void) { const configDir = resolve(cwd, '.config') const fileWatcher = new FileChangeTracker() + fileWatcher.prime(configDir) const configDirWatcher = watch(configDir) configDirWatcher.on('change', (_event, file: string) => { if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) { diff --git a/packages/nuxi/test/unit/file-watcher.spec.ts b/packages/nuxi/test/unit/file-watcher.spec.ts index e42101871..4b7787a7e 100644 --- a/packages/nuxi/test/unit/file-watcher.spec.ts +++ b/packages/nuxi/test/unit/file-watcher.spec.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdir, 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' @@ -8,12 +8,17 @@ import { FileChangeTracker } from '../../src/dev/utils' describe('fileWatcher', () => { let tempDir: string + let tempSubdir: string let testFile: string + let testSubdirFile: string let fileWatcher: FileChangeTracker beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'nuxt-cli-test-')) + tempSubdir = join(tempDir, 'subdir') + await mkdir(tempSubdir) testFile = join(tempDir, 'test-config.js') + testSubdirFile = join(tempSubdir, 'test-subdir-config.js') fileWatcher = new FileChangeTracker() }) @@ -30,6 +35,49 @@ describe('fileWatcher', () => { expect(shouldEmit).toBe(true) }) + it('should return false for first check of a file if primed', async () => { + await writeFile(testFile, 'initial content') + + fileWatcher.prime(testFile) + const shouldEmit = fileWatcher.shouldEmitChange(testFile) + expect(shouldEmit).toBe(false) + }) + + it('should return false for first check of nested files if primed in recursive mode', async () => { + await writeFile(testFile, 'initial content') + await writeFile(testSubdirFile, 'initial content in subdir') + + // Prime the directory in recursive mode + fileWatcher.prime(tempDir, true) + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + expect(fileWatcher.shouldEmitChange(testSubdirFile)).toBe(false) + expect(fileWatcher.shouldEmitChange(tempDir)).toBe(false) + expect(fileWatcher.shouldEmitChange(tempSubdir)).toBe(false) + }) + + it('should return true for first check of nested files if primed in non-recursive mode', async () => { + await writeFile(testFile, 'initial content') + await writeFile(testSubdirFile, 'initial content in subdir') + + // Prime the directory in recursive mode + fileWatcher.prime(tempDir) + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + expect(fileWatcher.shouldEmitChange(testSubdirFile)).toBe(true) + expect(fileWatcher.shouldEmitChange(tempDir)).toBe(false) + expect(fileWatcher.shouldEmitChange(tempSubdir)).toBe(false) + }) + + it('should return false for first check of a file if directory is primed', async () => { + await writeFile(testFile, 'initial content') + + fileWatcher.prime(tempDir) + const shouldEmit = fileWatcher.shouldEmitChange(testFile) + expect(shouldEmit).toBe(false) + // Also test the directory itself + const dirShouldEmit = fileWatcher.shouldEmitChange(tempDir) + expect(dirShouldEmit).toBe(false) + }) + it('should return false when file has not been modified', async () => { await writeFile(testFile, 'initial content') @@ -63,6 +111,27 @@ describe('fileWatcher', () => { expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) }) + it('should return true only when file has been modified, if primed', async () => { + await writeFile(testFile, 'initial content') + fileWatcher.prime(testFile) + + // First check + expect(fileWatcher.shouldEmitChange(testFile)).toBe(false) + + // 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') diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index 74567374d..996104d6b 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -42,8 +42,8 @@ function createIt(runtimeName: typeof runtimes[number]) { const supportMatrix: Record = { node: true, bun: { - start: !platform.windows, - fetching: !platform.windows, + start: true, + fetching: true, // https://github.com/nitrojs/nitro/issues/2721 websockets: false, },