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:
parent
60989d7b62
commit
de7e0dcb84
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user