Repository: vercel/next.js
Pull request: [devtools] Use `useEffectEvent` instead of ignoring exhaustive deps

Closes https://linear.app/vercel/issue/NXT-81

Found no issues clicking and hovering around in `DevtoolMenu`. `DevtoolMenu` is memoized now.

If `useEffectEvent` ends up not landing (or we need to opt NDT out of experimental), we just revert these changes. Shouldn't be too hard given how little usage we have. 

Changed files:
filename: packages/next/next-devtools.webpack-config.js
patch: @@ -17,7 +17,7 @@ function shouldIgnorePath(modulePath) {
  * @returns {webpack.Configuration}
  */
 module.exports = ({ dev, ...rest }) => {
-  const experimental = false
+  const experimental = true
 
   const bundledReactChannel = experimental ? '-experimental' : ''
 

filename: packages/next/src/next-devtools/dev-overlay/components/devtools-panel/resize/resize-provider.tsx
patch: @@ -2,6 +2,7 @@ import {
   createContext,
   useCallback,
   useContext,
+  experimental_useEffectEvent as useEffectEvent,
   useLayoutEffect,
   useState,
   type RefObject,
@@ -71,8 +72,9 @@ export const ResizeProvider = ({ value, children }: ResizeProviderProps) => {
 
   const storageKey = value.storageKey ?? STORE_KEY_SHARED_PANEL_SIZE
 
+  const { resizeRef } = value
   const applyConstrainedDimensions = useCallback(() => {
-    if (!value.resizeRef.current) return
+    if (!resizeRef.current) return
 
     // this feels weird to read local storage on resize, but we don't
     // track the dimensions of the container, and this is better than
@@ -96,23 +98,23 @@ export const ResizeProvider = ({ value, children }: ResizeProviderProps) => {
       minHeight: minHeight ?? 80,
     })
 
-    value.resizeRef.current.style.width = `${width}px`
-    value.resizeRef.current.style.height = `${height}px`
+    resizeRef.current.style.width = `${width}px`
+    resizeRef.current.style.height = `${height}px`
     return true
   }, [
-    value.resizeRef,
+    resizeRef,
     draggingDirection,
     storageKey,
     minWidth,
     minHeight,
     value.devToolsPanelSize,
   ])
 
-  useLayoutEffect(() => {
+  const fireInitialConstrainDimensions = useEffectEvent(() => {
     const applied = applyConstrainedDimensions()
     if (
       !applied &&
-      value.resizeRef.current &&
+      resizeRef.current &&
       value.initialSize?.height &&
       value.initialSize.width
     ) {
@@ -122,10 +124,14 @@ export const ResizeProvider = ({ value, children }: ResizeProviderProps) => {
         minWidth: minWidth ?? 100,
         minHeight: minHeight ?? 80,
       })
-      value.resizeRef.current.style.width = `${width}px`
-      value.resizeRef.current.style.height = `${height}px`
+      resizeRef.current.style.width = `${width}px`
+      resizeRef.current.style.height = `${height}px`
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+  })
+
+  useLayoutEffect(() => {
+    // eslint-disable-next-line react-hooks/rules-of-hooks -- eprh bug
+    fireInitialConstrainDimensions()
   }, [])
 
   useLayoutEffect(() => {

filename: packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/utils.ts
patch: @@ -1,11 +1,18 @@
-import { useEffect } from 'react'
+import { useEffect, experimental_useEffectEvent as useEffectEvent } from 'react'
 
 export function useFocusTrap(
   rootRef: React.RefObject<HTMLElement | null>,
   triggerRef: React.RefObject<HTMLButtonElement | null> | null,
   active: boolean,
   onOpenFocus?: () => void
 ) {
+  const fireOpenFocus = useEffectEvent((rootNode: HTMLElement | null) => {
+    if (onOpenFocus) {
+      onOpenFocus()
+    } else {
+      rootNode?.focus()
+    }
+  })
   useEffect(() => {
     let rootNode: HTMLElement | null = null
 
@@ -35,11 +42,7 @@ export function useFocusTrap(
       // Grab this on next tick to ensure the content is mounted
       rootNode = rootRef.current
       if (active) {
-        if (onOpenFocus) {
-          onOpenFocus()
-        } else {
-          rootNode?.focus()
-        }
+        fireOpenFocus(rootNode)
         rootNode?.addEventListener('keydown', onTab)
       } else {
         const activeElement = getActiveElement(rootNode)
@@ -56,8 +59,7 @@ export function useFocusTrap(
       clearTimeout(id)
       rootNode?.removeEventListener('keydown', onTab)
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [active])
+  }, [active, rootRef, triggerRef])
 }
 
 export function getActiveElement(node: HTMLElement | null) {

filename: packages/next/src/next-devtools/dev-overlay/menu/dev-overlay-menu.tsx
patch: @@ -1,6 +1,7 @@
 import { useDevOverlayContext } from '../../dev-overlay.browser'
 import { useClickOutsideAndEscape } from '../components/errors/dev-tools-indicator/utils'
 import {
+  experimental_useEffectEvent as useEffectEvent,
   useLayoutEffect,
   useRef,
   createContext,
@@ -134,14 +135,18 @@ export const DevtoolMenu = ({
       }
     }
   )
-  useLayoutEffect(() => {
-    menuRef.current?.focus() // allows keydown to be captured
+  const fireInitialSelectMenuItem = useEffectEvent(() => {
     selectMenuItem({
       index: selectedIndex === -1 ? 'first' : selectedIndex,
       menuRef,
       setSelectedIndex,
     })
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+  })
+
+  useLayoutEffect(() => {
+    menuRef.current?.focus() // allows keydown to be captured
+    // eslint-disable-next-line react-hooks/rules-of-hooks -- eprh bug
+    fireInitialSelectMenuItem()
   }, [])
 
   const indicatorOffset = getIndicatorOffset(state)