refactor(ui): enforce shortcut registration IDs

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent a5d5d07d3e
commit aba10476c6
3 changed files with 29 additions and 4 deletions
+21
View File
@@ -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<typeof validBindings> = validBindings;
// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID.
const rejectedBindings: ShortcutBindings<typeof mixedBindingsWithTypo> = mixedBindingsWithTypo;
void rejectedBindings;
test('accepts bindings whose IDs are declared in the shortcut schema', () => {
expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']);
});
+7 -3
View File
@@ -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<Record<ShortcutActionId, ShortcutHandler>>,
): void {
export type ShortcutBindings<
Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
> = Bindings & Record<Exclude<keyof Bindings, ShortcutActionId>, never>;
export function useKeybinds<
const Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
>(bindings: ShortcutBindings<Bindings>): void {
const handlersRef = React.useRef(bindings);
handlersRef.current = bindings;
const actionIdsKey = Object.keys(bindings).sort().join('\0');
@@ -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.