Repository: vercel/next.js
Pull request: [devtools] restart server pending state

This PR adds a pending state to the clear bundler cache & server restart.

When the user clicks the restart server button, Next.js fetches `/__nextjs_server_status` for the current server's execution ID. Then it queries `/__nextjs_restart_dev` middleware to trigger the server restart, then polls the `/__nextjs_server_status` path to confirm the server execution ID differs, which means the new server is online.

When the server has restarted, reload the window.

Closes NEXT-4562
Closes NEXT-4501

Changed files:
filename: packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/dev-tools-info/user-preferences.tsx
patch: @@ -17,6 +17,7 @@ import {
   type DevToolsScale,
 } from './preferences'
 import { ShortcutRecorder } from './shortcut-recorder'
+import { useRestartServer } from '../../error-overlay-toolbar/use-restart-server'
 
 export function UserPreferences({
   hide,
@@ -69,6 +70,7 @@ export function UserPreferencesBody({
   scale: DevToolsScale
   setScale: (value: DevToolsScale) => void
 }) {
+  const { restartServer, isPending } = useRestartServer()
   const [theme, setTheme] = useState(getInitialTheme())
 
   const handleThemeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -105,21 +107,6 @@ export function UserPreferencesBody({
     setScale(value)
   }
 
-  function handleRestartDevServer(invalidatePersistentCache: boolean) {
-    let endpoint = '/__nextjs_restart_dev'
-
-    if (invalidatePersistentCache) {
-      endpoint = '/__nextjs_restart_dev?invalidatePersistentCache'
-    }
-
-    fetch(endpoint, {
-      method: 'POST',
-    }).then(() => {
-      // TODO: poll server status and reload when the server is back up.
-      // https://github.com/vercel/next.js/pull/80005
-    })
-  }
-
   return (
     <>
       <h2 className="dev-tools-info-section-title">General</h2>
@@ -253,8 +240,9 @@ export function UserPreferencesBody({
               data-restart-dev-server
               className="action-button"
               onClick={() =>
-                handleRestartDevServer(/*invalidatePersistentCache*/ false)
+                restartServer({ invalidatePersistentCache: false })
               }
+              disabled={isPending}
             >
               <span>Restart</span>
             </button>
@@ -279,8 +267,9 @@ export function UserPreferencesBody({
                 data-reset-bundler-cache
                 className="action-button"
                 onClick={() =>
-                  handleRestartDevServer(/*invalidatePersistentCache*/ true)
+                  restartServer({ invalidatePersistentCache: true })
                 }
+                disabled={isPending}
               >
                 <span>Reset Cache</span>
               </button>
@@ -408,6 +397,11 @@ export const DEV_TOOLS_INFO_USER_PREFERENCES_STYLES = css`
     }
   }
 
+  .preference-section button:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+
   :global(.icon) {
     width: 18px;
     height: 18px;

filename: packages/next/src/next-devtools/dev-overlay/components/errors/error-overlay-toolbar/restart-server-button.tsx
patch: @@ -5,6 +5,8 @@ import {
   type OverlayDispatch,
 } from '../../../shared'
 import type { SupportedErrorEvent } from '../../../container/runtime-error/render-error'
+import { useRestartServer } from './use-restart-server'
+import { css } from '../../../utils/css'
 
 /**
  * When the Turbopack persistent cache is enabled, and the user reloads on a
@@ -14,28 +16,29 @@ import type { SupportedErrorEvent } from '../../../container/runtime-error/rende
  * telemetry on how often this is used.
  */
 export function RestartServerButton({ showButton }: { showButton: boolean }) {
+  const { restartServer, isPending } = useRestartServer()
+
   if (!showButton) {
     return null
   }
 
-  function handleClick() {
-    // TODO: Use Client Action for transition indicator when DevTools is isolated.
-    fetch('/__nextjs_restart_dev?invalidatePersistentCache', {
-      method: 'POST',
-    }).then(() => {
-      // TODO: poll server status and reload when the server is back up.
-      // https://github.com/vercel/next.js/pull/80005
-    })
-  }
-
   return (
     <button
       className="restart-dev-server-button"
-      onClick={handleClick}
+      onClick={() => restartServer({ invalidatePersistentCache: true })}
+      disabled={isPending}
       title="Clears the bundler cache and restarts the dev server. Helpful if you are seeing stale errors or changes are not appearing."
     >
-      <RefreshClockWise width={14} height={14} />
-      Clear Bundler Cache &amp; Restart
+      <RefreshClockWise
+        width={14}
+        height={14}
+        style={{
+          animation: isPending
+            ? 'refresh-clock-wise-spin 1s linear infinite'
+            : undefined,
+        }}
+      />
+      Reset Bundler Cache
     </button>
   )
 }
@@ -84,7 +87,7 @@ export function usePersistentCacheErrorDetection({
   }, [errors, dispatch])
 }
 
-export const RESTART_SERVER_BUTTON_STYLES = `
+export const RESTART_SERVER_BUTTON_STYLES = css`
   .restart-dev-server-button {
     display: flex;
     justify-content: center;
@@ -105,4 +108,18 @@ export const RESTART_SERVER_BUTTON_STYLES = `
     font-weight: 500;
     line-height: var(--size-16);
   }
+
+  .restart-dev-server-button:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+
+  @keyframes refresh-clock-wise-spin {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
+  }
 `

filename: packages/next/src/next-devtools/dev-overlay/components/errors/error-overlay-toolbar/use-restart-server.ts
patch: @@ -0,0 +1,92 @@
+import { useState } from 'react'
+
+export function useRestartServer() {
+  const [isPending, setIsPending] = useState(false)
+
+  const restartServer = async ({
+    invalidatePersistentCache,
+  }: {
+    invalidatePersistentCache: boolean
+  }): Promise<void> => {
+    setIsPending(true)
+
+    const url = invalidatePersistentCache
+      ? '/__nextjs_restart_dev?invalidatePersistentCache=1'
+      : '/__nextjs_restart_dev'
+
+    let serverRestarted = false
+
+    try {
+      const curId = await fetch('/__nextjs_server_status')
+        .then((res) => res.json())
+        .then((data) => data.executionId as number)
+        .catch((error) => {
+          console.log(
+            '[Next.js DevTools] Failed to fetch server status while restarting dev server.',
+            error
+          )
+          return null
+        })
+
+      if (!curId) {
+        console.log(
+          '[Next.js DevTools] Failed to get the current server execution ID while restarting dev server.'
+        )
+        return
+      }
+
+      const restartRes = await fetch(url, {
+        method: 'POST',
+      })
+
+      if (!restartRes.ok) {
+        // Use console log to avoid spamming the error overlay which users can't control.
+        console.log(
+          '[Next.js DevTools] Failed to fetch restart server endpoint. Status:',
+          restartRes.status
+        )
+        return
+      }
+
+      // Poll for server restart confirmation.
+      for (let i = 0; i < 10; i++) {
+        // generous 1 second delay for large apps.
+        await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 1_000))
+
+        try {
+          const nextId = await fetch('/__nextjs_server_status')
+            .then((res) => res.json())
+            .then((data) => data.executionId as number)
+
+          // If the execution ID has changed, the server has restarted successfully.
+          if (curId !== nextId) {
+            serverRestarted = true
+            // Reload the page to ensure the connection to the new server.
+            window.location.reload()
+            return
+          }
+        } catch (e) {
+          continue
+        }
+      }
+
+      console.log(
+        '[Next.js DevTools] Failed to restart server. Exhausted all polling attempts.'
+      )
+      return
+    } catch (error) {
+      console.log('[Next.js DevTools] Failed to restart server.', error)
+      return
+    } finally {
+      // If server restarted, don't reset isPending since the page will reload.
+      if (!serverRestarted) {
+        setIsPending(false)
+      }
+    }
+  }
+
+  return {
+    restartServer,
+    isPending,
+  }
+}

filename: packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx
patch: @@ -25,6 +25,7 @@ import { CALL_STACK_STYLES } from '../components/call-stack/call-stack'
 import { ISSUE_FEEDBACK_BUTTON_STYLES } from '../components/errors/error-overlay-toolbar/issue-feedback-button'
 import { ERROR_CONTENT_SKELETON_STYLES } from '../container/runtime-error/error-content-skeleton'
 import { SHORTCUT_RECORDER_STYLES } from '../components/errors/dev-tools-indicator/dev-tools-info/shortcut-recorder'
+
 export function ComponentStyles() {
   return (
     <style>