From aba10476c65c289fdde2262d4abfe8135a075258 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Fri, 31 Jul 2026 01:24:00 +0800 Subject: [PATCH] refactor(ui): enforce shortcut registration IDs --- packages/ui/src/hooks/useKeybind.test.ts | 21 +++++++++++++++++++ packages/ui/src/hooks/useKeybind.ts | 10 ++++++--- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/hooks/useKeybind.test.ts diff --git a/packages/ui/src/hooks/useKeybind.test.ts b/packages/ui/src/hooks/useKeybind.test.ts new file mode 100644 index 00000000..498ab7bb --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test'; +import type { ShortcutHandler } from '@/lib/shortcuts'; +import type { ShortcutBindings } from './useKeybind'; + +const handler: ShortcutHandler = () => {}; +const validBindings = { + open_session_list: handler, +}; +const mixedBindingsWithTypo = { + open_session_list: handler, + open_session_lsit: handler, +}; + +const acceptedBindings: ShortcutBindings = validBindings; +// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID. +const rejectedBindings: ShortcutBindings = mixedBindingsWithTypo; +void rejectedBindings; + +test('accepts bindings whose IDs are declared in the shortcut schema', () => { + expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']); +}); diff --git a/packages/ui/src/hooks/useKeybind.ts b/packages/ui/src/hooks/useKeybind.ts index 8b4b0397..8076a26c 100644 --- a/packages/ui/src/hooks/useKeybind.ts +++ b/packages/ui/src/hooks/useKeybind.ts @@ -8,9 +8,13 @@ export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler) React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]); } -export function useKeybinds( - bindings: Partial>, -): void { +export type ShortcutBindings< + Bindings extends Partial>, +> = Bindings & Record, never>; + +export function useKeybinds< + const Bindings extends Partial>, +>(bindings: ShortcutBindings): void { const handlersRef = React.useRef(bindings); handlersRef.current = bindings; const actionIdsKey = Object.keys(bindings).sort().join('\0'); diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index a44150c4..3bb74106 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -1,6 +1,6 @@ # Registration boundary -Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. +Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both accept only action IDs derived from `SHORTCUT_SCHEMA`. Batch registration also rejects undeclared keys in prebuilt objects, including objects that mix valid and misspelled IDs. Both hooks use the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `config.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components.