Repository: vercel/next.js
Pull request: Turbopack server hmr: Implement `restart` event

This implements the `restart` event, fully evicting all caches. This is needed for when client manifest changes are made.

Test Plan: CI for https://github.com/vercel/next.js/pull/90389

Changed files:
filename: packages/next/src/build/swc/types.ts
patch: @@ -219,7 +219,14 @@ export interface NodeJsPartialHmrUpdate extends BaseUpdate {
   }
 }
 
-export type NodeJsHmrUpdate = IssuesUpdate | NodeJsPartialHmrUpdate
+export interface NodeJsRestartHmrUpdate {
+  type: 'restart'
+}
+
+export type NodeJsHmrUpdate =
+  | IssuesUpdate
+  | NodeJsPartialHmrUpdate
+  | NodeJsRestartHmrUpdate
 
 export interface HmrChunkNames {
   /** Relative paths to output chunks that can receive HMR updates (e.g., "server/chunks/ssr/..._.js") */

filename: packages/next/src/server/app-render/entry-base.ts
patch: @@ -1,5 +1,3 @@
-import type { NodeJsPartialHmrUpdate } from '../../build/swc/types'
-
 // eslint-disable-next-line import/no-extraneous-dependencies
 export {
   createTemporaryReferenceSet,
@@ -49,6 +47,7 @@ export const InstantValidation =
     ? (require('./instant-validation/instant-validation') as typeof import('./instant-validation/instant-validation'))
     : undefined
 
+import type { NodeJsPartialHmrUpdate } from '../../build/swc/types'
 import { workAsyncStorage } from '../app-render/work-async-storage.external'
 import { workUnitAsyncStorage } from './work-unit-async-storage.external'
 import { patchFetch as _patchFetch } from '../lib/patch-fetch'

filename: packages/next/src/server/dev/hot-reloader-turbopack.ts
patch: @@ -158,9 +158,9 @@ type ServerHmrSubscriptions = Map<
 function setupServerHmr(
   project: Project,
   {
-    onUpdateFailed,
+    clear,
   }: {
-    onUpdateFailed: () => void | Promise<void>
+    clear: () => void | Promise<void>
   }
 ) {
   const serverHmrSubscriptions: ServerHmrSubscriptions = new Map()
@@ -184,6 +184,14 @@ function setupServerHmr(
 
       for await (const result of subscription) {
         const update = result as NodeJsHmrUpdate
+
+        // Fully re-evaluate all chunks from disk. Clears the module cache and
+        // notifies browsers to refetch RSC.
+        if (update.type === 'restart') {
+          await clear()
+          continue
+        }
+
         if (update.type !== 'partial') {
           continue
         }
@@ -196,14 +204,14 @@ function setupServerHmr(
         if (typeof __turbopack_server_hmr_apply__ === 'function') {
           const applied = __turbopack_server_hmr_apply__(update)
           if (!applied) {
-            await onUpdateFailed()
+            await clear()
           }
         }
       }
     })().catch(async (err) => {
       console.error('[Server HMR] Subscription error:', err)
       serverHmrSubscriptions.delete(chunkPath)
-      await onUpdateFailed()
+      await clear()
     })
   }
 
@@ -580,33 +588,38 @@ export async function createHotReloaderTurbopack(
       }
     }
 
-    resetFetch()
-
     const serverPaths = writtenEndpoint.serverPaths.map(({ path: p }) =>
       join(distDir, p)
     )
 
     const { type: entryType } = splitEntryKey(key)
-    // Server HMR only applies to App Router Node.js runtime endpoints.
+    // Server HMR only applies to App Router.
     // Pages Router uses Node's require(), root entries (middleware/instrumentation)
-    // use the edge runtime, and App Router edge routes all don't support server HMR.
-    const usesServerHmr = entryType === 'app' && writtenEndpoint.type !== 'edge'
+    // use the edge runtime.
+    const usesServerHmr =
+      experimentalServerFastRefresh &&
+      entryType === 'app' &&
+      writtenEndpoint.type !== 'edge'
 
     for (const file of serverPaths) {
-      const relativePath = relative(distDir, file)
+      clearModuleContext(file)
 
-      if (usesServerHmr && serverHmrSubscriptions?.has(relativePath)) {
-        // Skip deleteCache for server HMR module chunks.
-        // Pages Router entries are excluded by usesServerHmr (always false for
-        // pages), so they always get deleteCache regardless of subscriptions.
-        continue
+      const relativePath = relative(distDir, file)
+      if (
+        // For Pages Router, edge routes, middleware, and manifest files
+        // (e.g., *_client-reference-manifest.js): clear the sharedCache in
+        // evalManifest(), Node.js require.cache, and edge runtime module contexts.
+        force ||
+        !usesServerHmr ||
+        !serverHmrSubscriptions?.has(relativePath)
+      ) {
+        deleteCache(file)
       }
+    }
 
-      clearModuleContext(file)
-      // For Pages Router, edge routes, middleware, and manifest files
-      // (e.g., *_client-reference-manifest.js): clear the sharedCache in
-      // evalManifest(), Node.js require.cache, and edge runtime module contexts.
-      deleteCache(file)
+    // Reset the fetch patch so patchFetch() can re-wrap on the next request.
+    if (serverPaths.length > 0) {
+      resetFetch()
     }
 
     // Clear Turbopack's chunk-loading cache so chunks are re-required from disk on
@@ -1808,14 +1821,22 @@ export async function createHotReloaderTurbopack(
 
   if (experimentalServerFastRefresh) {
     serverHmrSubscriptions = setupServerHmr(project, {
-      onUpdateFailed: async () => {
+      clear: async () => {
+        // Clear Node's require cache of all Turbopack-built modules
+        for (const chunkPath of serverHmrSubscriptions?.keys() ?? []) {
+          deleteCache(join(distDir, chunkPath))
+        }
+
+        // Clear Turbopack's runtime caches
         if (typeof __next__clear_chunk_cache__ === 'function') {
           __next__clear_chunk_cache__()
         }
 
-        // Clear all module contexts so they're re-evaluated on next request
+        // Clear all edge contexts
         await clearAllModuleContexts()
 
+        resetFetch()
+
         // Tell browsers to refetch RSC (soft refresh, not full page reload)
         hotReloader.send({
           type: HMR_MESSAGE_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES,

filename: test/development/app-dir/dev-fetch-hmr/dev-fetch-hmr.test.ts
patch: @@ -22,10 +22,16 @@ describe('dev-fetch-hmr', () => {
     const update = cheerio.load(html2)('#update').text()
     expect(update).toBe('touch to trigger HMR')
 
-    // trigger HMR
     await next.patchFile('app/page.tsx', (content) =>
       content.replace('touch to trigger HMR', 'touch to trigger HMR 2')
     )
+    // For server hmr, we must touch the exact module to trigger re-evaluation
+    await next.patchFile('app/layout.tsx', (content) =>
+      content.replace(
+        'const magicNumber = Math.random()',
+        '// hmr trigger\nconst magicNumber = Math.random()'
+      )
+    )
 
     await retry(async () => {
       const html3 = await next.render('/')

filename: turbopack/crates/turbopack-ecmascript-runtime/js/src/nodejs/hmr-types.d.ts
patch: @@ -18,3 +18,7 @@ interface NodeJsPartialHmrUpdate {
     chunks?: Record<string, { type: 'partial' }>
   }
 }
+
+interface NodeJsRestartHmrUpdate {
+  type: 'restart'
+}