Repository: vercel/next.js
Pull request: fix(turbopack): Suppress logging for short no-op turbopack HMRs

Turbopack tends to generate a lot of irrelevant `BUILDING` actions, as it reports *any* compilation, including fully no-op/cached compilations and those unrelated to HMR. Fixing this would require significant architectural changes.

This PR works around the problem by deferring any "rebuilding" message by 100ms. If we get a `BUILT` event within that threshold and nothing has changed, just suppress the message entirely.

Tested this with a noisy v0 repro that @henryjeff shared with me a few weeks ago (noise is caused by an API route that's polled):

![Screenshot 2025-03-18 at 3.01.44 PM.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/HAZVitxRNnZz8QMiPn4a/b26d6375-e4cf-47a3-ba91-a9d1542a4941.png)

I also tested this with a pages router site (`pyra-docs` in `front`) to sanity check there.

Closes PACK-4236

Changed files:
filename: packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx
patch: @@ -266,7 +266,9 @@ function processMessage(
           sendMessage,
           [...hmrUpdate.updatedModules],
           hmrUpdate.startMsSinceEpoch,
-          hmrUpdate.endMsSinceEpoch
+          hmrUpdate.endMsSinceEpoch,
+          // suppress the `client-hmr-latency` event if the update was a no-op:
+          hmrUpdate.hasUpdates
         )
       }
       dispatcher.onBuildOk()
@@ -300,8 +302,8 @@ function processMessage(
       } else {
         webpackStartMsSinceEpoch = Date.now()
         setPendingHotUpdateWebpack()
+        console.log('[Fast Refresh] rebuilding')
       }
-      console.log('[Fast Refresh] rebuilding')
       break
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.BUILT:
@@ -384,21 +386,22 @@ function processMessage(
       break
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.TURBOPACK_MESSAGE: {
+      turbopackHmr!.onTurbopackMessage(obj)
       dispatcher.onBeforeRefresh()
       processTurbopackMessage({
         type: HMR_ACTIONS_SENT_TO_BROWSER.TURBOPACK_MESSAGE,
         data: obj.data,
       })
-      dispatcher.onRefresh()
       if (RuntimeErrorHandler.hadRuntimeError) {
         console.warn(REACT_REFRESH_FULL_RELOAD_FROM_ERROR)
         performFullReload(null, sendMessage)
       }
-      turbopackHmr!.onTurbopackMessage(obj)
+      dispatcher.onRefresh()
       break
     }
     // TODO-APP: make server component change more granular
     case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES: {
+      turbopackHmr?.onServerComponentChanges()
       sendMessage(
         JSON.stringify({
           event: 'server-component-reload-page',
@@ -432,6 +435,7 @@ function processMessage(
       return
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.RELOAD_PAGE: {
+      turbopackHmr?.onReloadPage()
       sendMessage(
         JSON.stringify({
           event: 'client-reload-page',
@@ -444,6 +448,7 @@ function processMessage(
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE:
     case HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE: {
+      turbopackHmr?.onPageAddRemove()
       // TODO-APP: potentially only refresh if the currently viewed page was added/removed.
       return router.hmrRefresh()
     }

filename: packages/next/src/client/components/react-dev-overlay/pages/hot-reloader-client.ts
patch: @@ -138,7 +138,8 @@ function handleSuccess() {
         sendMessage,
         [...hmrUpdate.updatedModules],
         hmrUpdate.startMsSinceEpoch,
-        hmrUpdate.endMsSinceEpoch
+        hmrUpdate.endMsSinceEpoch,
+        hmrUpdate.hasUpdates
       )
     }
     onBuildOk()
@@ -275,8 +276,8 @@ function processMessage(obj: HMR_ACTION_TYPES) {
         turbopackHmr!.onBuilding()
       } else {
         webpackStartMsSinceEpoch = Date.now()
+        console.log('[Fast Refresh] rebuilding')
       }
-      console.log('[Fast Refresh] rebuilding')
       break
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.BUILT:
@@ -323,6 +324,7 @@ function processMessage(obj: HMR_ACTION_TYPES) {
       return handleSuccess()
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_COMPONENT_CHANGES: {
+      turbopackHmr?.onServerComponentChanges()
       if (hasCompileErrors || RuntimeErrorHandler.hadRuntimeError) {
         window.location.reload()
       }
@@ -348,6 +350,7 @@ function processMessage(obj: HMR_ACTION_TYPES) {
       break
     }
     case HMR_ACTIONS_SENT_TO_BROWSER.TURBOPACK_MESSAGE: {
+      turbopackHmr!.onTurbopackMessage(obj)
       onBeforeRefresh()
       for (const listener of turbopackMessageListeners) {
         listener({
@@ -360,7 +363,6 @@ function processMessage(obj: HMR_ACTION_TYPES) {
         performFullReload(null)
       }
       onRefresh()
-      turbopackHmr!.onTurbopackMessage(obj)
       break
     }
     default: {

filename: packages/next/src/client/components/react-dev-overlay/utils/report-hmr-latency.ts
patch: @@ -4,14 +4,29 @@ declare global {
   }
 }
 
+/**
+ * Logs information about a completed HMR to the console, the server (via a
+ * `client-hmr-latency` event), and to `self.__NEXT_HMR_LATENCY_CB` (a debugging
+ * hook).
+ *
+ * @param hasUpdate Set this to `false` to avoid reporting the HMR event via a
+ *   `client-hmr-latency` event or to `self.__NEXT_HMR_LATENCY_CB`. Used by
+ *   turbopack when we must report a message to the browser console (because we
+ *   already logged a "rebuilding" message), but it's not a real HMR, so we
+ *   don't want to impact our telemetry.
+ */
 export default function reportHmrLatency(
   sendMessage: (message: string) => void,
   updatedModules: ReadonlyArray<string | number>,
   startMsSinceEpoch: number,
-  endMsSinceEpoch: number
+  endMsSinceEpoch: number,
+  hasUpdate: boolean = true
 ) {
   const latencyMs = endMsSinceEpoch - startMsSinceEpoch
   console.log(`[Fast Refresh] done in ${latencyMs}ms`)
+  if (!hasUpdate) {
+    return
+  }
   sendMessage(
     JSON.stringify({
       event: 'client-hmr-latency',

filename: packages/next/src/client/components/react-dev-overlay/utils/turbopack-hot-reloader-common.ts
patch: @@ -1,7 +1,18 @@
 import type { TurbopackMessageAction } from '../../../../server/dev/hot-reloader-types'
 import type { Update as TurbopackUpdate } from '../../../../build/swc/types'
 
+declare global {
+  interface Window {
+    __NEXT_HMR_TURBOPACK_REPORT_NOISY_NOOP_EVENTS: boolean | undefined
+  }
+}
+
+// How long to wait before reporting the HMR start, used to suppress irrelevant
+// `BUILDING` events. Does not impact reported latency.
+const TURBOPACK_HMR_START_DELAY_MS = 100
+
 interface HmrUpdate {
+  hasUpdates: boolean
   updatedModules: Set<string>
   startMsSinceEpoch: number
   endMsSinceEpoch: number
@@ -11,36 +22,97 @@ export class TurbopackHmr {
   #updatedModules: Set<string>
   #startMsSinceEpoch: number | undefined
   #lastUpdateMsSinceEpoch: number | undefined
+  #deferredReportHmrStartId: ReturnType<typeof setTimeout> | undefined
 
   constructor() {
     this.#updatedModules = new Set()
   }
 
+  // HACK: Turbopack tends to generate a lot of irrelevant "BUILDING" actions,
+  // as it reports *any* compilation, including fully no-op/cached compilations
+  // and those unrelated to HMR. Fixing this would require significant
+  // architectural changes.
+  //
+  // Work around this by deferring any "rebuilding" message by 100ms. If we get
+  // a BUILT event within that threshold and nothing has changed, just suppress
+  // the message entirely.
+  #runDeferredReportHmrStart() {
+    if (this.#deferredReportHmrStartId != null) {
+      console.log('[Fast Refresh] rebuilding')
+      this.#cancelDeferredReportHmrStart()
+    }
+  }
+
+  #cancelDeferredReportHmrStart() {
+    clearTimeout(this.#deferredReportHmrStartId)
+    this.#deferredReportHmrStartId = undefined
+  }
+
   onBuilding() {
     this.#lastUpdateMsSinceEpoch = undefined
+    this.#cancelDeferredReportHmrStart()
     this.#startMsSinceEpoch = Date.now()
+
+    // report the HMR start after a short delay
+    this.#deferredReportHmrStartId = setTimeout(
+      () => this.#runDeferredReportHmrStart(),
+      // debugging feature: don't defer/suppress noisy no-op HMR update messages
+      self.__NEXT_HMR_TURBOPACK_REPORT_NOISY_NOOP_EVENTS
+        ? 0
+        : TURBOPACK_HMR_START_DELAY_MS
+    )
   }
 
-  onTurbopackMessage(msg: TurbopackMessageAction) {
+  /** Helper for other `onEvent` methods. */
+  #onUpdate() {
+    this.#runDeferredReportHmrStart()
     this.#lastUpdateMsSinceEpoch = Date.now()
+  }
+
+  onTurbopackMessage(msg: TurbopackMessageAction) {
+    this.#onUpdate()
     const updatedModules = extractModulesFromTurbopackMessage(msg.data)
     for (const module of updatedModules) {
       this.#updatedModules.add(module)
     }
   }
 
+  onServerComponentChanges() {
+    this.#onUpdate()
+  }
+
+  onReloadPage() {
+    this.#onUpdate()
+  }
+
+  onPageAddRemove() {
+    this.#onUpdate()
+  }
+
+  /**
+   * @returns `null` if the caller should ignore the update entirely. Returns an
+   *   object with `hasUpdates: false` if the caller should report the end of
+   *   the HMR in the browser console, but the HMR was a no-op.
+   */
   onBuilt(): HmrUpdate | null {
-    // it's possible for `this.#startMsSinceEpoch` to not be set if this was the initial
-    // computation, just return null in this case.
-    if (this.#startMsSinceEpoch == null) {
+    // Check that we got *any* `TurbopackMessageAction`, even if
+    // `updatedModules` is empty (not everything gets recorded there).
+    //
+    // There's also a case where `onBuilt` gets called before `onBuilding`,
+    // which can happen during initial page load. Ignore that too!
+    const hasUpdates =
+      this.#lastUpdateMsSinceEpoch != null && this.#startMsSinceEpoch != null
+    if (!hasUpdates && this.#deferredReportHmrStartId != null) {
+      // suppress the update entirely
+      this.#cancelDeferredReportHmrStart()
       return null
     }
+    this.#runDeferredReportHmrStart()
+
     const result = {
+      hasUpdates,
       updatedModules: this.#updatedModules,
       startMsSinceEpoch: this.#startMsSinceEpoch!,
-      // Turbopack has a debounce which causes every BUILT message to appear
-      // 30ms late. We don't want to include this latency in our reporting, so
-      // prefer to use the last TURBOPACK_MESSAGE time.
       endMsSinceEpoch: this.#lastUpdateMsSinceEpoch ?? Date.now(),
     }
     this.#updatedModules = new Set()