Skip to content

Commit 06473f5

Browse files
authored
fix(dev): check mtime before restarting (#1060)
1 parent fcb58e0 commit 06473f5

File tree

2 files changed

+134
-2
lines changed

2 files changed

+134
-2
lines changed

packages/nuxi/src/dev/utils.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http
77
import type { AddressInfo } from 'node:net'
88

99
import EventEmitter from 'node:events'
10-
import { existsSync, watch } from 'node:fs'
10+
import { existsSync, statSync, watch } from 'node:fs'
1111
import { mkdir } from 'node:fs/promises'
1212
import process from 'node:process'
1313
import { pathToFileURL } from 'node:url'
@@ -73,6 +73,28 @@ interface NuxtDevServerOptions {
7373
// https://regex101.com/r/7HkR5c/1
7474
const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/
7575

76+
export class FileChangeTracker {
77+
private mtimes = new Map<string, number>()
78+
79+
shouldEmitChange(filePath: string): boolean {
80+
try {
81+
const stats = statSync(filePath)
82+
const currentMtime = stats.mtimeMs
83+
const lastMtime = this.mtimes.get(filePath)
84+
85+
this.mtimes.set(filePath, currentMtime)
86+
87+
// emit change for new file or mtime has changed
88+
return lastMtime === undefined || currentMtime !== lastMtime
89+
}
90+
catch {
91+
// remove from cache if it has been deleted or is inaccessible
92+
this.mtimes.delete(filePath)
93+
return true
94+
}
95+
}
96+
}
97+
7698
type NuxtWithServer = Omit<Nuxt, 'server'> & { server?: NitroDevServer }
7799

78100
interface DevServerEventMap {
@@ -89,6 +111,7 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
89111
private _currentNuxt?: NuxtWithServer
90112
private _loadingMessage?: string
91113
private _loadingError?: Error
114+
private _fileChangeTracker = new FileChangeTracker()
92115
private cwd: string
93116

94117
loadDebounced: (reload?: boolean, reason?: string) => void
@@ -310,7 +333,11 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
310333
const distDir = resolve(this._currentNuxt.options.buildDir, 'dist')
311334
await mkdir(distDir, { recursive: true })
312335
this._distWatcher = watch(distDir)
313-
this._distWatcher.on('change', () => {
336+
this._distWatcher.on('change', (_event, file: string) => {
337+
if (!this._fileChangeTracker.shouldEmitChange(resolve(distDir, file || ''))) {
338+
return
339+
}
340+
314341
this.loadDebounced(true, '.nuxt/dist directory has been removed')
315342
})
316343

@@ -374,8 +401,13 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '.
374401
const configWatcher = watch(cwd)
375402
let configDirWatcher = existsSync(resolve(cwd, '.config')) ? createConfigDirWatcher(cwd, onReload) : undefined
376403
const dotenvFileNames = new Set(Array.isArray(dotenvFileName) ? dotenvFileName : [dotenvFileName])
404+
const fileWatcher = new FileChangeTracker()
377405

378406
configWatcher.on('change', (_event, file: string) => {
407+
if (!fileWatcher.shouldEmitChange(resolve(cwd, file))) {
408+
return
409+
}
410+
379411
if (dotenvFileNames.has(file)) {
380412
onRestart()
381413
}
@@ -397,9 +429,14 @@ function createConfigWatcher(cwd: string, dotenvFileName: string | string[] = '.
397429

398430
function createConfigDirWatcher(cwd: string, onReload: (file: string) => void) {
399431
const configDir = resolve(cwd, '.config')
432+
const fileWatcher = new FileChangeTracker()
400433

401434
const configDirWatcher = watch(configDir)
402435
configDirWatcher.on('change', (_event, file: string) => {
436+
if (!fileWatcher.shouldEmitChange(resolve(configDir, file))) {
437+
return
438+
}
439+
403440
if (RESTART_RE.test(file)) {
404441
onReload(file)
405442
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { existsSync } from 'node:fs'
2+
import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+
7+
import { FileChangeTracker } from '../../src/dev/utils'
8+
9+
describe('fileWatcher', () => {
10+
let tempDir: string
11+
let testFile: string
12+
let fileWatcher: FileChangeTracker
13+
14+
beforeEach(async () => {
15+
tempDir = await mkdtemp(join(tmpdir(), 'nuxt-cli-test-'))
16+
testFile = join(tempDir, 'test-config.js')
17+
fileWatcher = new FileChangeTracker()
18+
})
19+
20+
afterEach(async () => {
21+
if (existsSync(tempDir)) {
22+
await rm(tempDir, { recursive: true, force: true })
23+
}
24+
})
25+
26+
it('should return true for first check of a file', async () => {
27+
await writeFile(testFile, 'initial content')
28+
29+
const shouldEmit = fileWatcher.shouldEmitChange(testFile)
30+
expect(shouldEmit).toBe(true)
31+
})
32+
33+
it('should return false when file has not been modified', async () => {
34+
await writeFile(testFile, 'initial content')
35+
36+
// First call should return true (new file)
37+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
38+
39+
// Second call without modification should return false
40+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
41+
42+
// Third call still should return false
43+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
44+
})
45+
46+
it('should return true when file has been modified', async () => {
47+
await writeFile(testFile, 'initial content')
48+
49+
// First check
50+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
51+
52+
// No modification - should return false
53+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
54+
55+
// Wait a bit and modify the file
56+
await new Promise(resolve => setTimeout(resolve, 10))
57+
await writeFile(testFile, 'modified content')
58+
59+
// Should return true because file was modified
60+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
61+
62+
// Subsequent check without modification should return false
63+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
64+
})
65+
66+
it('should handle file deletion gracefully', async () => {
67+
await writeFile(testFile, 'content')
68+
69+
// First check
70+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
71+
72+
// Delete the file
73+
await rm(testFile)
74+
75+
// Should return true when file is deleted (indicates change)
76+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
77+
})
78+
79+
it('should detect mtime changes even with same content', async () => {
80+
await writeFile(testFile, 'same content')
81+
82+
// First check
83+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
84+
85+
// No change
86+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(false)
87+
88+
// Manually update mtime to simulate file modification
89+
const now = Date.now()
90+
await utimes(testFile, new Date(now), new Date(now + 1000))
91+
92+
// Should detect the mtime change
93+
expect(fileWatcher.shouldEmitChange(testFile)).toBe(true)
94+
})
95+
})

0 commit comments

Comments
 (0)