Repository: vercel/next.js
Pull request: [dev-tools] Fix flashing of disabled state on indicator

This PR adds a better heuristic for detecting whether the dev indicator is in a disabled state or not. Right now the only way to disable it is with a `devIndicators: false` in `next.config.js`.

But the flag is read in async, meaning the surface on load always flashes between the two states (the one with the ℹ icon is the disabled state):

https://github.com/user-attachments/assets/847615de-9376-431b-855f-50aa6cf4026c

In this PR, we are deferring rendering the indicator until we can reliably know which state it should be in.

As a bonus, we are also using `state.buildError` to detect whether the indicator should be in a build error state. Relying on `runtimeErrors.length` as we did previously is not as reliable since we also load the runtime errors in async so they will be empty on first render which does not necessarily indicate a build error.


Changed files:
filename: packages/next/src/client/components/react-dev-overlay/shared.ts
patch: @@ -22,6 +22,7 @@ export interface OverlayState {
   versionInfo: VersionInfo
   notFound: boolean
   staticIndicator: boolean
+  showIndicator: boolean
   disableDevIndicator: boolean
   debugInfo: DebugInfo
   routerType: 'pages' | 'app'
@@ -122,8 +123,13 @@ export const INITIAL_OVERLAY_STATE: Omit<OverlayState, 'routerType'> = {
   errors: [],
   notFound: false,
   staticIndicator: false,
-  // To prevent flickering, set the initial state to disabled.
-  disableDevIndicator: true,
+  /* 
+    This is set to `true` when we can reliably know
+    whether the indicator is in disabled state or not.  
+    Otherwise the surface would flicker because the disabled flag loads from the config.
+  */
+  showIndicator: false,
+  disableDevIndicator: false,
   refreshState: { type: 'idle' },
   versionInfo: { installed: '0.0.0', staleness: 'unknown' },
   debugInfo: { devtoolsFrontendUrl: undefined },
@@ -209,6 +215,7 @@ export function useErrorOverlayReducer(routerType: 'pages' | 'app') {
       case ACTION_DEV_INDICATOR: {
         return {
           ...state,
+          showIndicator: true,
           disableDevIndicator:
             shouldDisableDevIndicator || !!action.devIndicator.disabledUntil,
         }

filename: packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/dev-tools-indicator.stories.tsx
patch: @@ -51,6 +51,7 @@ const state: OverlayState = {
   errors: [],
   refreshState: { type: 'idle' },
   disableDevIndicator: false,
+  showIndicator: true,
   versionInfo: mockVersionInfo,
   notFound: false,
   staticIndicator: true,

filename: packages/next/src/client/components/react-dev-overlay/ui/components/errors/dev-tools-indicator/next-logo.tsx
patch: @@ -25,7 +25,7 @@ export const NextLogo = forwardRef(function NextLogo(
     isBuildError,
     onTriggerClick,
     toggleErrorOverlay,
-    scale,
+    scale = 1,
     ...props
   }: Props,
   propRef: React.Ref<HTMLButtonElement>

filename: packages/next/src/client/components/react-dev-overlay/ui/dev-overlay.stories.tsx
patch: @@ -23,6 +23,7 @@ const state: OverlayState = {
   routerType: 'app',
   buildError: null,
   disableDevIndicator: false,
+  showIndicator: true,
   errors: [
     {
       id: 1,

filename: packages/next/src/client/components/react-dev-overlay/ui/dev-overlay.tsx
patch: @@ -33,17 +33,19 @@ export function DevOverlay({
 
       <RenderError state={state} isAppDir={true}>
         {({ runtimeErrors, totalErrorCount }) => {
-          const isBuildError = runtimeErrors.length === 0
+          const isBuildError = state.buildError !== null
           return (
             <>
-              <DevToolsIndicator
-                scale={scale}
-                setScale={setScale}
-                state={state}
-                errorCount={totalErrorCount}
-                isBuildError={isBuildError}
-                setIsErrorOverlayOpen={setIsErrorOverlayOpen}
-              />
+              {state.showIndicator && (
+                <DevToolsIndicator
+                  scale={scale}
+                  setScale={setScale}
+                  state={state}
+                  errorCount={totalErrorCount}
+                  isBuildError={isBuildError}
+                  setIsErrorOverlayOpen={setIsErrorOverlayOpen}
+                />
+              )}
 
               <ErrorOverlay
                 state={state}