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();
},
{ wrapper: wrapper() },
{ wrapper: wrapper() }
);
expect(result.current.map(c => c.id).sort()).toEqual(['temp', 'temp2']);
unmount();
@ -96,9 +96,7 @@ describe('CommandRegistryProvider', () => {
});
it('throws when used outside provider', () => {
expect(() => renderHook(() => useCommands())).toThrow(
/CommandRegistryProvider/,
);
expect(() => renderHook(() => useCommands())).toThrow(/CommandRegistryProvider/);
});
});
@ -128,7 +126,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open={false} onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
expect(screen.queryByTestId('bl-cmdk')).toBeNull();
});
@ -137,7 +135,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
expect(screen.getByTestId('bl-cmdk-panel')).toBeDefined();
expect(screen.getByTestId('bl-cmdk-item-new-task')).toBeDefined();
@ -149,7 +147,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
expect(screen.queryByTestId('bl-cmdk-item-gated')).toBeNull();
});
@ -158,7 +156,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'Tab' });
@ -171,7 +169,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
fireEvent.change(screen.getByTestId('bl-cmdk-input'), {
target: { value: 'task' },
@ -184,10 +182,10 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
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();
});
@ -197,7 +195,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} onNavigate={onNavigate} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Tab' });
fireEvent.keyDown(screen.getByTestId('bl-cmdk'), { key: 'Enter' });
@ -209,7 +207,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
const dialog = screen.getByTestId('bl-cmdk');
fireEvent.keyDown(dialog, { key: 'ArrowDown' });
@ -225,7 +223,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={onClose} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
@ -235,7 +233,7 @@ describe('CommandPalette', () => {
const { rerender } = render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} initialMode="ask-ai" />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
expect(screen.getByTestId('bl-cmdk-ask-ai')).toBeDefined();
@ -247,7 +245,7 @@ describe('CommandPalette', () => {
initialMode="ask-ai"
askAiPanel={q => <div data-testid="custom-ai">{q || 'idle'}</div>}
/>
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
expect(screen.getByTestId('custom-ai').textContent).toBe('idle');
});
@ -256,12 +254,12 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
const row = screen.getByTestId('bl-cmdk-item-archive');
expect(row.getAttribute('aria-disabled')).toBe('true');
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', () => {
@ -294,7 +292,7 @@ describe('CommandPalette', () => {
render(
<CommandRegistryProvider initial={seed}>
<CommandPalette open onClose={() => {}} />
</CommandRegistryProvider>,
</CommandRegistryProvider>
);
fireEvent.click(screen.getByTestId('bl-cmdk-item-new-task'));
expect(mem['bl-cmdk-recents']).toContain('new-task');
@ -315,16 +313,12 @@ describe('useCommandPalette', () => {
expect(result.current.open).toBe(false);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
});
expect(result.current.open).toBe(true);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
});
expect(result.current.open).toBe(false);
@ -335,6 +329,19 @@ describe('useCommandPalette', () => {
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', () => {
const { result } = renderHook(() => useCommandPalette({ hotkey: null }));
act(() => result.current.show());
@ -348,9 +355,7 @@ describe('useCommandPalette', () => {
});
it('respects defaultOpen', () => {
const { result } = renderHook(() =>
useCommandPalette({ defaultOpen: true, hotkey: null }),
);
const { result } = renderHook(() => useCommandPalette({ defaultOpen: true, hotkey: null }));
expect(result.current.open).toBe(true);
});
});

View File

@ -32,10 +32,9 @@ export interface UseCommandPaletteHelpers {
* inputs unless the user explicitly holds the modifier.
*/
export function useCommandPalette(
options: UseCommandPaletteOptions = {},
options: UseCommandPaletteOptions = {}
): UseCommandPaletteHelpers {
const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } =
options;
const { hotkey = { key: 'k', meta: true, ctrl: true }, defaultOpen = false } = options;
const [open, setOpen] = useState(defaultOpen);
const show = useCallback(() => setOpen(true), []);
@ -45,6 +44,9 @@ export function useCommandPalette(
useEffect(() => {
if (!hotkey) return;
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;
const wantMeta = hotkey.meta ?? false;
const wantCtrl = hotkey.ctrl ?? false;