From 99f3a7415a025afa4d2f80eff8f474a1d0f89711 Mon Sep 17 00:00:00 2001 From: Josh Cohenzadeh Date: Tue, 24 Mar 2026 12:13:10 -0700 Subject: [PATCH] fix(nav): Use capture-phase listener so Cmd+K works when inputs have focus Components like the search query builder call stopPropagation() on keydown events, preventing them from reaching the document-level listener in useHotkeys. This adds a useCapture option to useHotkeys that registers the listener on the capture phase, firing before any child component can stop propagation. --- static/app/utils/useHotkeys.spec.tsx | 44 +++++++++++++++++++++++++++ static/app/utils/useHotkeys.tsx | 17 ++++++++++- static/app/views/navigation/index.tsx | 2 ++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/static/app/utils/useHotkeys.spec.tsx b/static/app/utils/useHotkeys.spec.tsx index bb74a213644ee6..903a5787eb06d1 100644 --- a/static/app/utils/useHotkeys.spec.tsx +++ b/static/app/utils/useHotkeys.spec.tsx @@ -157,6 +157,50 @@ describe('useHotkeys', () => { expect(callback).toHaveBeenCalled(); }); + it('registers on capture phase with useCapture', () => { + const callback = jest.fn(); + + renderHook(p => useHotkeys(p), { + initialProps: [{match: 'command+k', callback, useCapture: true}], + }); + + expect(document.addEventListener).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + true + ); + + const captureCall = (document.addEventListener as jest.Mock).mock.calls.find( + call => call[0] === 'keydown' && call[2] === true + ); + expect(captureCall).toBeDefined(); + + const captureHandler = captureCall[1]; + const evt = makeKeyEventFixture('k', {metaKey: true}); + captureHandler(evt); + + expect(callback).toHaveBeenCalled(); + }); + + it('does not fire capture hotkeys on bubble phase', () => { + const callback = jest.fn(); + + renderHook(p => useHotkeys(p), { + initialProps: [{match: 'command+k', callback, useCapture: true}], + }); + + const bubbleCall = (document.addEventListener as jest.Mock).mock.calls.find( + call => call[0] === 'keydown' && call[2] !== true + ); + expect(bubbleCall).toBeDefined(); + + const bubbleHandler = bubbleCall[1]; + const evt = makeKeyEventFixture('k', {metaKey: true}); + bubbleHandler(evt); + + expect(callback).not.toHaveBeenCalled(); + }); + it('skips preventDefault', () => { const callback = jest.fn(); diff --git a/static/app/utils/useHotkeys.tsx b/static/app/utils/useHotkeys.tsx index a29c0eaee25d43..adbf7c4d25d726 100644 --- a/static/app/utils/useHotkeys.tsx +++ b/static/app/utils/useHotkeys.tsx @@ -44,6 +44,12 @@ type Hotkey = { * Do not call preventDefault on the keydown event */ skipPreventDefault?: boolean; + /** + * Register the listener on the capture phase instead of the bubble phase. + * Use this when the shortcut must fire even if a child component calls + * `stopPropagation()` on the keydown event (e.g., the search query builder). + */ + useCapture?: boolean; }; /** @@ -63,8 +69,12 @@ export function useHotkeys(hotkeys: Hotkey[]): void { }); useEffect(() => { - const onKeyDown = (evt: KeyboardEvent) => { + const makeHandler = (capture: boolean) => (evt: KeyboardEvent) => { for (const hotkey of hotkeysRef.current) { + if (!!hotkey.useCapture !== capture) { + continue; + } + const preventDefault = !hotkey.skipPreventDefault; const keysets = toArray(hotkey.match).map(keys => keys.toLowerCase()); @@ -92,10 +102,15 @@ export function useHotkeys(hotkeys: Hotkey[]): void { } }; + const onKeyDown = makeHandler(false); + const onKeyDownCapture = makeHandler(true); + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keydown', onKeyDownCapture, true); return () => { document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keydown', onKeyDownCapture, true); }; }, []); } diff --git a/static/app/views/navigation/index.tsx b/static/app/views/navigation/index.tsx index ba9d8ae15fcced..2ffbaf4969cacb 100644 --- a/static/app/views/navigation/index.tsx +++ b/static/app/views/navigation/index.tsx @@ -42,6 +42,8 @@ function UserAndOrganizationNavigation() { : [ { match: ['command+shift+p', 'command+k', 'ctrl+shift+p', 'ctrl+k'], + includeInputs: true, + useCapture: true, callback: () => { if (organization.features.includes('cmd-k-supercharged')) { openCommandPalette();