From 25012a1bbb2da16b5206341c6406cad0ca5fe410 Mon Sep 17 00:00:00 2001 From: Aron Nopanen Date: Fri, 21 Nov 2025 10:42:31 -0800 Subject: [PATCH 1/6] fix(dev): Prime mtimes cache When watching for file/directory changes, prime the FileChangeTracker with initial mtimes of the tracked files, such that if the first notification received for a file is spurious (does not represent an actual change), shouldEmitChange will return 'false'. Without this, the 'priming' happened via several reloads/restarts, but with the introduction of the ForkPool, the FileChangeTracker cache no longer perists across restarts, never allowing it to become populated. Result: unpredictable restarts. --- packages/nuxi/src/dev/utils.ts | 28 +++++++++++++- packages/nuxi/test/unit/file-watcher.spec.ts | 40 ++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index fc2b2f79b..374430661 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' @@ -86,6 +86,27 @@ export class FileChangeTracker { return true } } + + prime(directory: string, recursive: boolean = false): void { + const stat = statSync(directory) + this.mtimes.set(directory, stat.mtimeMs) + if (stat.isDirectory()) { + const entries = readdirSync(directory) + for (const entry of entries) { + const fullPath = resolve(directory, 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 +435,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 +532,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 +566,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..d4d435454 100644 --- a/packages/nuxi/test/unit/file-watcher.spec.ts +++ b/packages/nuxi/test/unit/file-watcher.spec.ts @@ -30,6 +30,25 @@ 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 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 +82,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') From 6a579c4847b33ebf7bb84779e2d9e2bc6a21e686 Mon Sep 17 00:00:00 2001 From: Aron Nopanen Date: Fri, 21 Nov 2025 11:55:17 -0800 Subject: [PATCH 2/6] Improve unit tests --- packages/nuxi/test/unit/file-watcher.spec.ts | 31 +++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/nuxi/test/unit/file-watcher.spec.ts b/packages/nuxi/test/unit/file-watcher.spec.ts index d4d435454..11d13efce 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 { mkdtemp, mkdir, 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() }) @@ -38,6 +43,30 @@ describe('fileWatcher', () => { 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') From 4e5c06dd0515688c63c8b426272ff8592a00b1d8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 19:55:57 +0000 Subject: [PATCH 3/6] [autofix.ci] apply automated fixes --- packages/nuxi/test/unit/file-watcher.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxi/test/unit/file-watcher.spec.ts b/packages/nuxi/test/unit/file-watcher.spec.ts index 11d13efce..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, mkdir, 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' From cc046e1e6677020fc344cda790806fc6937db7f6 Mon Sep 17 00:00:00 2001 From: Aron Nopanen Date: Fri, 21 Nov 2025 12:46:40 -0800 Subject: [PATCH 4/6] Normalize path separators for Windows Pass all paths through `resolve` to ensure consistent path separators on Windows. --- packages/nuxi/src/dev/utils.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 374430661..8346719a8 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -70,30 +70,32 @@ 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(directory: string, recursive: boolean = false): void { - const stat = statSync(directory) - this.mtimes.set(directory, stat.mtimeMs) + 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(directory) + const entries = readdirSync(resolved) for (const entry of entries) { - const fullPath = resolve(directory, entry) + const fullPath = resolve(resolved, entry) try { const stats = statSync(fullPath) this.mtimes.set(fullPath, stats.mtimeMs) From 0ec1a80adf6bdf6f2d57867e8e7c27bde3b55c20 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 4 Dec 2025 15:12:31 +0000 Subject: [PATCH 5/6] test: specify bun dev server starts on windows --- packages/nuxt-cli/test/e2e/runtimes.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index 74567374d..19413a440 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -42,7 +42,7 @@ function createIt(runtimeName: typeof runtimes[number]) { const supportMatrix: Record = { node: true, bun: { - start: !platform.windows, + start: true, fetching: !platform.windows, // https://github.com/nitrojs/nitro/issues/2721 websockets: false, From b91779f8f0c98c7ea1a06877808ed715d183e2a2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 4 Dec 2025 15:22:31 +0000 Subject: [PATCH 6/6] test: mark bun fetching tests as passing --- packages/nuxt-cli/test/e2e/runtimes.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-cli/test/e2e/runtimes.spec.ts b/packages/nuxt-cli/test/e2e/runtimes.spec.ts index 19413a440..996104d6b 100644 --- a/packages/nuxt-cli/test/e2e/runtimes.spec.ts +++ b/packages/nuxt-cli/test/e2e/runtimes.spec.ts @@ -43,7 +43,7 @@ function createIt(runtimeName: typeof runtimes[number]) { node: true, bun: { start: true, - fetching: !platform.windows, + fetching: true, // https://github.com/nitrojs/nitro/issues/2721 websockets: false, },