Repository: vercel/next.js
Pull request: [dev-tools] Robust shortcut detection

This PR resolves NEXT-4610 by using `e.code` over `e.key` in both storing and detecting keyboard shortcuts. 

Consider this problematic keybind: Alt + L

If we capture `e.key` here then it will correspond to a glyph (¬) because pressing Alt + L creates this character. While `e.code` will give us `KeyL` as the value which we also later use in `useShortcuts()` to match the keybind correctly without having to detect glyphs.

---

After this PR:


https://github.com/user-attachments/assets/222fc65f-7320-4487-b387-ea263acfe387





Changed files:
filename: packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/dev-tools-info/shortcut-recorder.tsx
patch: @@ -1,3 +1,4 @@
+import type { JSX } from 'react'
 import { useState, useRef, useEffect } from 'react'
 import { css } from '../../../../utils/css'
 
@@ -13,6 +14,7 @@ export function ShortcutRecorder({
   value: string[] | null
   onChange: (value: string | null) => void
 }) {
+  const [pristine, setPristine] = useState(true)
   const [show, setShow] = useState(false)
   const [keys, setKeys] = useState<string[]>(value ?? [])
   const [success, setSuccess] = useState<boolean>(false)
@@ -30,6 +32,13 @@ export function ShortcutRecorder({
       setShow(true)
     }
 
+    // Reset current shortcut on first key press
+    // if this is a fresh recording session
+    if (pristine) {
+      setKeys([])
+      setPristine(false)
+    }
+
     function handleValidation(next: string[]) {
       timeoutRef.current = window.setTimeout(() => {
         setSuccess(true)
@@ -40,15 +49,24 @@ export function ShortcutRecorder({
       }, SUCCESS_SHOW_DELAY_MS)
     }
 
-    if (keys.length === 3) return
-
     e.preventDefault()
     e.stopPropagation()
 
     setKeys((prev) => {
       // Don't add duplicate keys
-      if (prev.includes(e.key)) return prev
-
+      if (prev.includes(e.code) || prev.includes(e.key)) return prev
+
+      /**
+       * Why are we using `e.code` for non-modifier keys?
+       *
+       * Consider this keybind: Alt + L
+       *
+       * If we capture `e.key` here then it will correspond to an awkward symbol (¬)
+       * because pressing Alt + L creates this symbol.
+       *
+       * While `e.code` will give us `KeyL` as the value which we also later use in
+       * `useShortcuts()` to match the keybind correctly without relying on modifier symbols.
+       */
       // Handle non-modifier keys (action keys)
       if (!modifierKeys.includes(e.key)) {
         // Replace existing non-modifier key if present
@@ -57,12 +75,12 @@ export function ShortcutRecorder({
         )
         if (existingNonModifierIndex !== -1) {
           const next = [...prev]
-          next[existingNonModifierIndex] = e.key
+          next[existingNonModifierIndex] = e.code
           handleValidation(next)
           return next
         }
         // Add new non-modifier key at the end
-        const next = [...prev, e.key]
+        const next = [...prev, e.code]
         handleValidation(next)
         return next
       }
@@ -108,6 +126,7 @@ export function ShortcutRecorder({
   function onBlur() {
     setSuccess(false)
     setShow(false)
+    setPristine(true)
   }
 
   function onStart() {
@@ -240,7 +259,52 @@ function Kbd({ children }: { children: string }) {
   }
   const key = renderKey(children)
   const isSymbol = typeof key === 'string' ? key.length === 1 : false
-  return <kbd data-symbol={isSymbol}>{key}</kbd>
+  return <kbd data-symbol={isSymbol}>{parseKeyCode(key)}</kbd>
+}
+
+function parseKeyCode(code: string | JSX.Element) {
+  if (typeof code !== 'string') return code
+
+  // Map common KeyboardEvent.code values to their corresponding key values
+  const codeToKeyMap: Record<string, string> = {
+    Minus: '-',
+    Equal: '=',
+    BracketLeft: '[',
+    BracketRight: ']',
+    Backslash: '\\',
+    Semicolon: ';',
+    Quote: "'",
+    Comma: ',',
+    Period: '.',
+    Backquote: '`',
+    Space: ' ',
+    Slash: '/',
+    IntlBackslash: '\\',
+    // Add more as needed
+  }
+
+  if (codeToKeyMap[code]) {
+    return codeToKeyMap[code]
+  }
+
+  // Handle KeyA-Z, Digit0-9, Numpad0-9, NumpadAdd, etc.
+  if (/^Key([A-Z])$/.test(code)) {
+    return code.replace(/^Key/, '')
+  }
+  if (/^Digit([0-9])$/.test(code)) {
+    return code.replace(/^Digit/, '')
+  }
+  if (/^Numpad([0-9])$/.test(code)) {
+    return code.replace(/^Numpad/, '')
+  }
+  if (code === 'NumpadAdd') return '+'
+  if (code === 'NumpadSubtract') return '-'
+  if (code === 'NumpadMultiply') return '*'
+  if (code === 'NumpadDivide') return '/'
+  if (code === 'NumpadDecimal') return '.'
+  if (code === 'NumpadEnter') return 'Enter'
+
+  return code
 }
 
 function MetaKey() {

filename: packages/next/src/next-devtools/dev-overlay/hooks/use-shortcuts.ts
patch: @@ -22,7 +22,7 @@ export function useShortcuts(
         e.key !== 'Alt' &&
         e.key !== 'Shift'
       ) {
-        keys.push(e.key)
+        keys.push(e.code)
       }
 
       const shortcut = keys.join('+')

filename: packages/next/src/next-devtools/dev-overlay/menu/panel-router.tsx
patch: @@ -138,7 +138,7 @@ export const PanelRouter = () => {
   const { triggerRef } = usePanelRouterContext()
   const toggleDevtools = useToggleDevtoolsVisibility()
 
-  const [hideShortcut] = useHideShortcutStorage()
+  const [hideShortcut, setHideShortcut] = useHideShortcutStorage()
   useShortcuts(
     hideShortcut ? { [hideShortcut]: toggleDevtools } : {},
     triggerRef
@@ -160,7 +160,10 @@ export const PanelRouter = () => {
           closeOnClickOutside
           header={<DevToolsHeader title="Preferences" />}
         >
-          <UserPreferencesWrapper />
+          <UserPreferencesWrapper
+            hideShortcut={hideShortcut}
+            setHideShortcut={setHideShortcut}
+          />
         </DynamicPanel>
       </PanelRoute>
 
@@ -272,13 +275,17 @@ const InfoFooter = ({ href }: { href: string }) => {
   )
 }
 
-const UserPreferencesWrapper = () => {
+const UserPreferencesWrapper = ({
+  hideShortcut,
+  setHideShortcut,
+}: {
+  hideShortcut: string | null
+  setHideShortcut: (value: string | null) => void
+}) => {
   const { dispatch, state } = useDevOverlayContext()
   const { setPanel, setSelectedIndex } = usePanelRouterContext()
   const updateAllPanelPositions = useUpdateAllPanelPositions()
 
-  const [hideShortcut, setHideShortcut] = useHideShortcutStorage()
-
   return (
     <div
       style={{