fix(command-palette): guard keydown with undefined key (no crash)

Some keydown events (autofill, IME/composition, certain extensions) arrive with
e.key === undefined, crashing the hotkey handler at e.key.toLowerCase(). Guard
e.key (and hotkey.key) before comparing. +1 test (undefined-key keydown is
ignored without throwing). 27 pass.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
saravanakumardb1 2026-05-31 04:47:40 -07:00
parent 60989d7b62
commit de7e0dcb84
2 changed files with 38 additions and 31 deletions

View File

@ -85,7 +85,7 @@ describe('CommandRegistryProvider', () => {
]); ]);
return useCommands(); return useCommands();
}, },
{ wrapper: wrapper() }, { wrapper: wrapper() }
); );
expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']); expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']);
unmount(); unmount();
@ -96,9 +96,7 @@ describe('CommandRegistryProvider', () => {
}); });
it('throws when used outside provider', () => { it('throws when used outside provider', () => {
expect(() => renderHook(() => useCommands())).toThrow( expect(() => renderHook(() => useCommands())).toThrow(/CommandRegistryProvider/);
/CommandRegistryProvider/,
);
}); });
}); });
@ -128,7 +126,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open={false} onClose={() => {}} /> <CommandPalette open={false} onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
expect(screen.queryByTestId('bl-cmdk')).toBeNull(); expect(screen.queryByTestId('bl-cmdk')).toBeNull();
}); });
@ -137,7 +135,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined(); expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined();
expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined(); expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
@ -149,7 +147,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull(); expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
}); });
@ -158,7 +156,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
const dialog = screen.getByTestId('bl-cmdk'); const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'Tab' }); fireEvent.keyDown(dialog, { key: 'Tab' });
@ -171,7 +169,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
fireEvent.change(screen.getByTestId('bl-cmdk-input'), { fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
target: { value: 'task' }, target: { value: 'task' },
@ -184,10 +182,10 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} /> <CommandPalette open onClose={onClose} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' }); fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
expect((seed[0].run as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce(); expect(seed[0].run as ReturnType<typeof vi.fn>).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
}); });
@ -197,7 +195,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} onNavigate={onNavigate} /> <CommandPalette open onClose={onClose} onNavigate={onNavigate} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' }); fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' });
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' }); fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
@ -209,7 +207,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
const dialog = screen.getByTestId('bl-cmdk'); const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'ArrowDown' }); fireEvent.keyDown(dialog, { key: 'ArrowDown' });
@ -225,7 +223,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} /> <CommandPalette open onClose={onClose} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
fireEvent.keyDown(document, { key: 'Escape' }); fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce();
@ -235,7 +233,7 @@ describe('CommandPalette', () => {
const { rerender } = render( const { rerender } = render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} initialMode="ask-ai" /> <CommandPalette open onClose={() => {}} initialMode="ask-ai" />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined(); expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined();
@ -247,7 +245,7 @@ describe('CommandPalette', () => {
initialMode="ask-ai" initialMode="ask-ai"
askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>} askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>}
/> />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
expect(screen.getByTestId('custom-ai').textContent).toBe('idle'); expect(screen.getByTestId('custom-ai').textContent).toBe('idle');
}); });
@ -256,12 +254,12 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
const row = screen.getByTestId('bl-cmdk-item-archive'); const row = screen.getByTestId('bl-cmdk-item-archive');
expect(row.getAttribute('aria-disabled')).toBe('true'); expect(row.getAttribute('aria-disabled')).toBe('true');
fireEvent.click(row); fireEvent.click(row);
expect((seed[3].run as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled(); expect(seed[3].run as ReturnType<typeof vi.fn>).not.toHaveBeenCalled();
}); });
it('recents persist to localStorage when a command is run', () => { it('recents persist to localStorage when a command is run', () => {
@ -294,7 +292,7 @@ describe('CommandPalette', () => {
render( render(
<CommandRegistryProvider initial={seed}> <CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} /> <CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>, </CommandRegistryProvider>
); );
fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task')); fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task'));
expect(mem['bl-cmdk-recents']).toContain('new-task'); expect(mem['bl-cmdk-recents']).toContain('new-task');
@ -315,16 +313,12 @@ describe('useCommandPalette', () => {
expect(result.current.open).toBe(false); expect(result.current.open).toBe(false);
act(() => { act(() => {
window.dispatchEvent( window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
}); });
expect(result.current.open).toBe(true); expect(result.current.open).toBe(true);
act(() => { act(() => {
window.dispatchEvent( window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
}); });
expect(result.current.open).toBe(false); expect(result.current.open).toBe(false);
@ -335,6 +329,19 @@ describe('useCommandPalette', () => {
expect(result.current.open).toBe(false); expect(result.current.open).toBe(false);
}); });
it('ignores keydown events with an undefined key (autofill/IME) without throwing', () => {
const { result } = renderHook(() => useCommandPalette());
expect(() =>
act(() => {
// Some events (autofill, IME) dispatch keydown with no `key` set.
const ev = new KeyboardEvent('keydown', { metaKey: true });
Object.defineProperty(ev, 'key', { value: undefined });
window.dispatchEvent(ev);
})
).not.toThrow();
expect(result.current.open).toBe(false);
});
it('show/hide/toggle imperative helpers', () => { it('show/hide/toggle imperative helpers', () => {
const { result } = renderHook(() => useCommandPalette({ hotkey: null })); const { result } = renderHook(() => useCommandPalette({ hotkey: null }));
act(() => result.current.show()); act(() => result.current.show());
@ -348,9 +355,7 @@ describe('useCommandPalette', () => {
}); });
it('respects defaultOpen', () => { it('respects defaultOpen', () => {
const { result } = renderHook(() => const { result } = renderHook(() => useCommandPalette({ defaultOpen: true, hotkey: null }));
useCommandPalette({ defaultOpen: true, hotkey: null }),
);
expect(result.current.open).toBe(true); expect(result.current.open).toBe(true);
}); });
}); });

View File

@ -32,10 +32,9 @@ export interface UseCommandPaletteHelpers {
* inputs unless the user explicitly holds the modifier. * inputs unless the user explicitly holds the modifier.
*/ */
export function useCommandPalette( export function useCommandPalette(
options: UseCommandPaletteOptions = {}, options: UseCommandPaletteOptions = {}
): UseCommandPaletteHelpers { ): UseCommandPaletteHelpers {
const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } = const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } = options;
options;
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
const show = useCallback(() => setOpen(true), []); const show = useCallback(() => setOpen(true), []);
@ -45,6 +44,9 @@ export function useCommandPalette(
useEffect(() => { useEffect(() => {
if (!hotkey) return; if (!hotkey) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
// Some keydown events (autofill, IME/composition, certain extensions) arrive
// with an undefined `key`; guard so we never crash on `.toLowerCase()`.
if (!e.key || !hotkey.key) return;
if (e.key.toLowerCase() !== hotkey.key.toLowerCase()) return; if (e.key.toLowerCase() !== hotkey.key.toLowerCase()) return;
const wantMeta = hotkey.meta ?? false; const wantMeta = hotkey.meta ?? false;
const wantCtrl = hotkey.ctrl ?? false; const wantCtrl = hotkey.ctrl ?? false;