Repository: vercel/next.js
Pull request: Restart the `next dev` server if `.next` is deleted

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

Changed files:
filename: packages/next/src/server/lib/start-server.ts
patch: @@ -524,32 +524,57 @@ 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
         )}. Restarting the server to apply the changes...`
       )
       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 }

filename: test/development/app-dir/watch-distdir-deletion/fixture/app/layout.js
patch: @@ -0,0 +1,7 @@
+export default function RootLayout({ children }) {
+  return (
+    <html>
+      <body>{children}</body>
+    </html>
+  )
+}

filename: test/development/app-dir/watch-distdir-deletion/fixture/app/page.js
patch: @@ -0,0 +1,3 @@
+export default function Page() {
+  return <p>hello world</p>
+}

filename: test/development/app-dir/watch-distdir-deletion/fixture/next.config.js
patch: @@ -0,0 +1,5 @@
+const nextConfig = {
+  reactStrictMode: true,
+}
+
+module.exports = nextConfig

filename: test/development/app-dir/watch-distdir-deletion/index.test.ts
patch: @@ -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
+    )
+  })
+})