Restart the next dev server if .next is deleted (#92135)

This PR is AI generated.

If this directory is deleted while the dev server is running, we can't reasonably recover, so we should restart the process.

Addresses https://vercel.slack.com/archives/C03KAR5DCKC/p1774757692640999
This commit is contained in:
Benjamin Woodruff
2026-04-03 17:20:55 -07:00
committed by GitHub
parent cb0d88f6e3
commit 35f26a2fac
5 changed files with 101 additions and 15 deletions
+40 -15
View File
@@ -524,25 +524,40 @@ export async function startServer(
server.listen(port, hostname)
})
// Watch config files for changes and distDir ancestors for deletion.
if (isDev) {
function watchConfigFiles(
dirToWatch: string,
onChange: (filename: string) => void
) {
const wp = new Watchpack()
wp.watch({
files: CONFIG_FILES.map((file) => path.join(dirToWatch, file)),
})
wp.on('change', onChange)
// Note: dir is absolute and normalized (`..` segments removed), `absDistDir`
// is also normalized because `path.join()` performs normalization. `distDir`
// does not have to be inside of `dir`!
const absDistDir = path.join(dir, distDir)
// always watch dir and absDistDir
const dirWatchPaths: string[] = [dir, absDistDir]
// also watch ancestors of absDistDir that are inside of dir.
let prevAncestor = absDistDir
while (true) {
const nextAncestor = path.dirname(prevAncestor)
// note: `dirname('/') === '/'` if we happen to reach the FS root
if (
!nextAncestor.startsWith(dir + path.sep) ||
nextAncestor === prevAncestor
) {
break
}
dirWatchPaths.push(nextAncestor)
prevAncestor = nextAncestor
}
watchConfigFiles(dir, async (filename) => {
if (process.env.__NEXT_DISABLE_MEMORY_WATCHER) {
Log.info(
`Detected change, manual restart required due to '__NEXT_DISABLE_MEMORY_WATCHER' usage`
)
const configFiles = CONFIG_FILES.map((file) => path.join(dir, file))
const wp = new Watchpack()
wp.watch({
files: configFiles,
missing: dirWatchPaths,
})
wp.on('change', async (filename) => {
if (!configFiles.includes(filename)) {
return
}
Log.warn(
`Found a change in ${path.basename(
filename
@@ -550,6 +565,16 @@ export async function startServer(
)
process.exit(RESTART_EXIT_CODE)
})
wp.on('remove', (removedPath: string) => {
if (dirWatchPaths.includes(removedPath)) {
Log.error(
`The directory at "${removedPath}" was deleted.\n\n` +
'Deleting this directory while Next.js is running can lead to ' +
'undefined behavior. Restarting the server to recover...'
)
process.exit(RESTART_EXIT_CODE)
}
})
}
return { distDir }
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}
@@ -0,0 +1,5 @@
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
@@ -0,0 +1,46 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import fs from 'fs'
import path from 'path'
describe('app-dir watch-distdir-deletion', () => {
const { next } = nextTestSetup({
files: path.join(__dirname, 'fixture'),
})
it('should restart the server when distDir is deleted', async () => {
// Wait for the dev server to be ready
await retry(async () => {
expect(next.cliOutput).toMatch(/ready/i)
})
// Make a request first to ensure the dev server has compiled
const warmupRes = await next.fetch('/')
expect(warmupRes.status).toBe(200)
// Delete .next (which also removes .next/dev, the watched distDir)
await fs.promises.rm(path.join(next.testDir, '.next'), {
recursive: true,
force: true,
})
// Wait for restart message and server to come back up
await retry(
async () => {
expect(next.cliOutput).toMatch(/The directory at .* was deleted/)
},
30_000,
1000
)
// Server should come back up and serve pages
await retry(
async () => {
const res = await next.fetch('/')
expect(res.status).toBe(200)
},
30_000,
1000
)
})
})