From 8c0195d4b9bde0e90792ed04bbcca39638ad2e39 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:52:23 +0000 Subject: [PATCH 1/3] ci: apply automated fixes --- .../react/reference/functions/useHotkeyRecorder.md | 2 +- .../reference/interfaces/ReactHotkeyRecorder.md | 12 ++++++------ docs/reference/classes/HotkeyManager.md | 12 ++++++------ docs/reference/classes/HotkeyRecorder.md | 6 +++--- docs/reference/functions/getHotkeyManager.md | 2 +- packages/keys/src/recorder.ts | 4 +++- packages/react-keys/src/useHotkeyRecorder.ts | 8 ++------ 7 files changed, 22 insertions(+), 24 deletions(-) diff --git a/docs/framework/react/reference/functions/useHotkeyRecorder.md b/docs/framework/react/reference/functions/useHotkeyRecorder.md index ec63d2a..28a9734 100644 --- a/docs/framework/react/reference/functions/useHotkeyRecorder.md +++ b/docs/framework/react/reference/functions/useHotkeyRecorder.md @@ -9,7 +9,7 @@ title: useHotkeyRecorder function useHotkeyRecorder(options): ReactHotkeyRecorder; ``` -Defined in: [useHotkeyRecorder.ts:60](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L60) +Defined in: [useHotkeyRecorder.ts:57](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L57) React hook for recording keyboard shortcuts. diff --git a/docs/framework/react/reference/interfaces/ReactHotkeyRecorder.md b/docs/framework/react/reference/interfaces/ReactHotkeyRecorder.md index 98fd1a1..9c6d2c2 100644 --- a/docs/framework/react/reference/interfaces/ReactHotkeyRecorder.md +++ b/docs/framework/react/reference/interfaces/ReactHotkeyRecorder.md @@ -5,7 +5,7 @@ title: ReactHotkeyRecorder # Interface: ReactHotkeyRecorder -Defined in: [useHotkeyRecorder.ts:9](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L9) +Defined in: [useHotkeyRecorder.ts:6](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L6) ## Properties @@ -15,7 +15,7 @@ Defined in: [useHotkeyRecorder.ts:9](https://github.com/TanStack/keys/blob/main/ cancelRecording: () => void; ``` -Defined in: [useHotkeyRecorder.ts:19](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L19) +Defined in: [useHotkeyRecorder.ts:16](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L16) Cancel recording without saving @@ -31,7 +31,7 @@ Cancel recording without saving isRecording: boolean; ``` -Defined in: [useHotkeyRecorder.ts:11](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L11) +Defined in: [useHotkeyRecorder.ts:8](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L8) Whether recording is currently active @@ -43,7 +43,7 @@ Whether recording is currently active recordedHotkey: Hotkey | null; ``` -Defined in: [useHotkeyRecorder.ts:13](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L13) +Defined in: [useHotkeyRecorder.ts:10](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L10) The currently recorded hotkey (for live preview) @@ -55,7 +55,7 @@ The currently recorded hotkey (for live preview) startRecording: () => void; ``` -Defined in: [useHotkeyRecorder.ts:15](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L15) +Defined in: [useHotkeyRecorder.ts:12](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L12) Start recording a new hotkey @@ -71,7 +71,7 @@ Start recording a new hotkey stopRecording: () => void; ``` -Defined in: [useHotkeyRecorder.ts:17](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L17) +Defined in: [useHotkeyRecorder.ts:14](https://github.com/TanStack/keys/blob/main/packages/react-keys/src/useHotkeyRecorder.ts#L14) Stop recording (same as cancel) diff --git a/docs/reference/classes/HotkeyManager.md b/docs/reference/classes/HotkeyManager.md index 1c774d4..d2c0da4 100644 --- a/docs/reference/classes/HotkeyManager.md +++ b/docs/reference/classes/HotkeyManager.md @@ -34,7 +34,7 @@ unregister() destroy(): void; ``` -Defined in: [manager.ts:526](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L526) +Defined in: [manager.ts:520](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L520) Destroys the manager and removes all listeners. @@ -50,7 +50,7 @@ Destroys the manager and removes all listeners. getRegistrationCount(): number; ``` -Defined in: [manager.ts:497](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L497) +Defined in: [manager.ts:491](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L491) Gets the number of registered hotkeys. @@ -66,7 +66,7 @@ Gets the number of registered hotkeys. isRegistered(hotkey, target?): boolean; ``` -Defined in: [manager.ts:508](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L508) +Defined in: [manager.ts:502](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L502) Checks if a specific hotkey is registered. @@ -101,7 +101,7 @@ register( options): HotkeyRegistrationHandle; ``` -Defined in: [manager.ts:122](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L122) +Defined in: [manager.ts:120](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L120) Registers a hotkey handler and returns a handle for updating the registration. @@ -157,7 +157,7 @@ handle.unregister() static getInstance(): HotkeyManager; ``` -Defined in: [manager.ts:80](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L80) +Defined in: [manager.ts:78](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L78) Gets the singleton instance of HotkeyManager. @@ -173,7 +173,7 @@ Gets the singleton instance of HotkeyManager. static resetInstance(): void; ``` -Defined in: [manager.ts:90](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L90) +Defined in: [manager.ts:88](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L88) Resets the singleton instance. Useful for testing. diff --git a/docs/reference/classes/HotkeyRecorder.md b/docs/reference/classes/HotkeyRecorder.md index 46447a3..cd2b406 100644 --- a/docs/reference/classes/HotkeyRecorder.md +++ b/docs/reference/classes/HotkeyRecorder.md @@ -87,7 +87,7 @@ Use this to subscribe to state changes or access current state. cancel(): void; ``` -Defined in: [recorder.ts:212](https://github.com/TanStack/keys/blob/main/packages/keys/src/recorder.ts#L212) +Defined in: [recorder.ts:214](https://github.com/TanStack/keys/blob/main/packages/keys/src/recorder.ts#L214) Cancel recording without saving. @@ -106,7 +106,7 @@ the onCancel callback if provided. destroy(): void; ``` -Defined in: [recorder.ts:257](https://github.com/TanStack/keys/blob/main/packages/keys/src/recorder.ts#L257) +Defined in: [recorder.ts:259](https://github.com/TanStack/keys/blob/main/packages/keys/src/recorder.ts#L259) Clean up event listeners and reset state. @@ -168,7 +168,7 @@ a valid hotkey is recorded, Escape is pressed, or stop/cancel is called. stop(): void; ``` -Defined in: [recorder.ts:192](https://github.com/TanStack/keys/blob/main/packages/keys/src/recorder.ts#L192) +Defined in: [recorder.ts:194](https://github.com/TanStack/keys/blob/main/packages/keys/src/recorder.ts#L194) Stop recording (same as cancel, but doesn't call onCancel). diff --git a/docs/reference/functions/getHotkeyManager.md b/docs/reference/functions/getHotkeyManager.md index 9feff2a..c830f7a 100644 --- a/docs/reference/functions/getHotkeyManager.md +++ b/docs/reference/functions/getHotkeyManager.md @@ -9,7 +9,7 @@ title: getHotkeyManager function getHotkeyManager(): HotkeyManager; ``` -Defined in: [manager.ts:542](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L542) +Defined in: [manager.ts:536](https://github.com/TanStack/keys/blob/main/packages/keys/src/manager.ts#L536) Gets the singleton HotkeyManager instance. Convenience function for accessing the manager. diff --git a/packages/keys/src/recorder.ts b/packages/keys/src/recorder.ts index 99ee1f0..06762ce 100644 --- a/packages/keys/src/recorder.ts +++ b/packages/keys/src/recorder.ts @@ -163,7 +163,9 @@ export class HotkeyRecorder { // Validate: must have at least one non-modifier key if (hasNonModifierKey(finalHotkey, this.#platform)) { // Remove listener FIRST to prevent any additional events - const handlerToRemove = this.#keydownHandler as ((event: KeyboardEvent) => void) | null + const handlerToRemove = this.#keydownHandler as + | ((event: KeyboardEvent) => void) + | null if (handlerToRemove) { this.#removeListener(handlerToRemove) this.#keydownHandler = null diff --git a/packages/react-keys/src/useHotkeyRecorder.ts b/packages/react-keys/src/useHotkeyRecorder.ts index 4307f1d..4beaaf9 100644 --- a/packages/react-keys/src/useHotkeyRecorder.ts +++ b/packages/react-keys/src/useHotkeyRecorder.ts @@ -1,11 +1,7 @@ import { useEffect, useRef } from 'react' import { useStore } from '@tanstack/react-store' -import { - - HotkeyRecorder - -} from '@tanstack/keys' -import type {Hotkey, HotkeyRecorderOptions} from '@tanstack/keys'; +import { HotkeyRecorder } from '@tanstack/keys' +import type { Hotkey, HotkeyRecorderOptions } from '@tanstack/keys' export interface ReactHotkeyRecorder { /** Whether recording is currently active */ From 33f8a4bc45506bbf3f447ab0ed7ec7b180f5c45e Mon Sep 17 00:00:00 2001 From: HyunJun CHOI Date: Fri, 6 Feb 2026 12:07:12 +0900 Subject: [PATCH 2/3] feat: Add hotkey conflict detection with configurable behavior --- .changeset/conflict-detection.md | 14 +++++ packages/keys/src/index.ts | 1 + packages/keys/src/manager.ts | 63 +++++++++++++++++++ packages/keys/src/types.ts | 12 ++++ packages/keys/tests/manager.test.ts | 97 +++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+) create mode 100644 .changeset/conflict-detection.md diff --git a/.changeset/conflict-detection.md b/.changeset/conflict-detection.md new file mode 100644 index 0000000..1e9d614 --- /dev/null +++ b/.changeset/conflict-detection.md @@ -0,0 +1,14 @@ +--- +'@tanstack/keys': minor +--- + +Add hotkey conflict detection with configurable behavior + +Implements conflict detection when registering hotkeys with the same combination on the same target. Adds a new `conflictBehavior` option to `HotkeyOptions`: + +- `'warn'` (default) - Log a warning to console but allow both registrations +- `'error'` - Throw an error and prevent the new registration +- `'replace'` - Unregister the existing hotkey and register the new one +- `'allow'` - Allow multiple registrations without warning + +This addresses the "Warn/error on conflicting shortcuts (TBD)" item from the README. diff --git a/packages/keys/src/index.ts b/packages/keys/src/index.ts index 3fa2e44..69325c5 100644 --- a/packages/keys/src/index.ts +++ b/packages/keys/src/index.ts @@ -22,6 +22,7 @@ export type { HotkeyCallback, HotkeyCallbackContext, // Option types + ConflictBehavior, HotkeyOptions, FormatDisplayOptions, ValidationResult, diff --git a/packages/keys/src/manager.ts b/packages/keys/src/manager.ts index ccdbb7a..790cff2 100644 --- a/packages/keys/src/manager.ts +++ b/packages/keys/src/manager.ts @@ -23,6 +23,7 @@ const defaultHotkeyOptions: Omit< requireReset: false, enabled: true, ignoreInputs: true, + conflictBehavior: 'warn', } let registrationIdCounter = 0 @@ -131,6 +132,19 @@ export class HotkeyManager { options.target ?? (typeof document !== 'undefined' ? document : ({} as Document)) + // Resolve conflict behavior + const conflictBehavior = options.conflictBehavior ?? 'warn' + + // Check for existing registrations with the same hotkey and target + const conflictingRegistration = this.#findConflictingRegistration( + hotkey, + target, + ) + + if (conflictingRegistration) { + this.#handleConflict(conflictingRegistration, hotkey, conflictBehavior) + } + const registration: HotkeyRegistration = { id, hotkey, @@ -419,6 +433,55 @@ export class HotkeyManager { return false } + /** + * Finds an existing registration with the same hotkey and target. + */ + #findConflictingRegistration( + hotkey: Hotkey, + target: HTMLElement | Document | Window, + ): HotkeyRegistration | null { + for (const registration of this.#registrations.values()) { + if (registration.hotkey === hotkey && registration.target === target) { + return registration + } + } + return null + } + + /** + * Handles conflicts between hotkey registrations based on conflict behavior. + */ + #handleConflict( + conflictingRegistration: HotkeyRegistration, + hotkey: Hotkey, + conflictBehavior: 'warn' | 'error' | 'replace' | 'allow', + ): void { + if (conflictBehavior === 'allow') { + return + } + + if (conflictBehavior === 'warn') { + console.warn( + `Hotkey '${hotkey}' is already registered. Multiple handlers will be triggered. ` + + `Use conflictBehavior: 'replace' to replace the existing handler, ` + + `or conflictBehavior: 'allow' to suppress this warning.`, + ) + return + } + + if (conflictBehavior === 'error') { + throw new Error( + `Hotkey '${hotkey}' is already registered. ` + + `Use conflictBehavior: 'replace' to replace the existing handler, ` + + `or conflictBehavior: 'allow' to allow multiple registrations.`, + ) + } + + if (conflictBehavior === 'replace') { + this.#unregister(conflictingRegistration.id) + } + } + /** * Checks if an element is an input-like element that should be ignored. * diff --git a/packages/keys/src/types.ts b/packages/keys/src/types.ts index 2336ff1..50e784a 100644 --- a/packages/keys/src/types.ts +++ b/packages/keys/src/types.ts @@ -393,6 +393,16 @@ export type HotkeyCallback = ( // Options Types // ============================================================================= +/** + * Behavior when registering a hotkey that conflicts with an existing registration. + * + * - `'warn'` - Log a warning to the console but allow both registrations (default) + * - `'error'` - Throw an error and prevent the new registration + * - `'replace'` - Unregister the existing hotkey and register the new one + * - `'allow'` - Allow multiple registrations of the same hotkey without warning + */ +export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow' + /** * Options for registering a hotkey. */ @@ -413,6 +423,8 @@ export interface HotkeyOptions { ignoreInputs?: boolean /** The DOM element to attach the event listener to. Defaults to document. */ target?: HTMLElement | Document | Window | null + /** Behavior when this hotkey conflicts with an existing registration on the same target. Defaults to 'warn' */ + conflictBehavior?: ConflictBehavior } // ============================================================================= diff --git a/packages/keys/tests/manager.test.ts b/packages/keys/tests/manager.test.ts index eb0842a..4dfa9a1 100644 --- a/packages/keys/tests/manager.test.ts +++ b/packages/keys/tests/manager.test.ts @@ -717,4 +717,101 @@ describe('HotkeyManager', () => { } }) }) + + describe('conflict detection', () => { + it('should warn by default when registering a conflicting hotkey', () => { + const manager = HotkeyManager.getInstance() + const callback1 = vi.fn() + const callback2 = vi.fn() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + manager.register('Mod+S', callback1) + manager.register('Mod+S', callback2) + + expect(warnSpy).toHaveBeenCalled() + expect(warnSpy.mock.calls[0]?.[0]).toContain('already registered') + expect(manager.getRegistrationCount()).toBe(2) + + warnSpy.mockRestore() + }) + + it('should throw error when conflictBehavior is "error"', () => { + const manager = HotkeyManager.getInstance() + const callback1 = vi.fn() + const callback2 = vi.fn() + + manager.register('Mod+S', callback1) + + expect(() => { + manager.register('Mod+S', callback2, { conflictBehavior: 'error' }) + }).toThrow('already registered') + + expect(manager.getRegistrationCount()).toBe(1) + }) + + it('should replace existing registration when conflictBehavior is "replace"', () => { + const manager = HotkeyManager.getInstance() + const callback1 = vi.fn() + const callback2 = vi.fn() + + manager.register('Mod+S', callback1, { platform: 'mac' }) + expect(manager.getRegistrationCount()).toBe(1) + + manager.register('Mod+S', callback2, { + conflictBehavior: 'replace', + platform: 'mac', + }) + expect(manager.getRegistrationCount()).toBe(1) + + document.dispatchEvent( + createKeyboardEvent('keydown', 's', { metaKey: true }), + ) + + expect(callback1).not.toHaveBeenCalled() + expect(callback2).toHaveBeenCalledOnce() + }) + + it('should allow multiple registrations when conflictBehavior is "allow"', () => { + const manager = HotkeyManager.getInstance() + const callback1 = vi.fn() + const callback2 = vi.fn() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + manager.register('Mod+S', callback1, { platform: 'mac' }) + manager.register('Mod+S', callback2, { + conflictBehavior: 'allow', + platform: 'mac', + }) + + expect(warnSpy).not.toHaveBeenCalled() + expect(manager.getRegistrationCount()).toBe(2) + + document.dispatchEvent( + createKeyboardEvent('keydown', 's', { metaKey: true }), + ) + + expect(callback1).toHaveBeenCalledOnce() + expect(callback2).toHaveBeenCalledOnce() + + warnSpy.mockRestore() + }) + + it('should not conflict when same hotkey is registered on different targets', () => { + const manager = HotkeyManager.getInstance() + const callback1 = vi.fn() + const callback2 = vi.fn() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const div1 = document.createElement('div') + const div2 = document.createElement('div') + + manager.register('Mod+S', callback1, { target: div1 }) + manager.register('Mod+S', callback2, { target: div2 }) + + expect(warnSpy).not.toHaveBeenCalled() + expect(manager.getRegistrationCount()).toBe(2) + + warnSpy.mockRestore() + }) + }) }) From 541bd90435875a75197d825ee77310c830f7058d Mon Sep 17 00:00:00 2001 From: HyunJun CHOI Date: Fri, 6 Feb 2026 13:27:13 +0900 Subject: [PATCH 3/3] fix: Remove unnecessary conditional in conflict handler ESLint was correctly identifying that the final if statement was unnecessary since all other cases (allow, warn, error) return early. At this point in the code, conflictBehavior must be 'replace'. --- packages/keys/src/manager.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/keys/src/manager.ts b/packages/keys/src/manager.ts index 790cff2..0113f2b 100644 --- a/packages/keys/src/manager.ts +++ b/packages/keys/src/manager.ts @@ -477,9 +477,8 @@ export class HotkeyManager { ) } - if (conflictBehavior === 'replace') { - this.#unregister(conflictingRegistration.id) - } + // At this point, conflictBehavior must be 'replace' + this.#unregister(conflictingRegistration.id) } /**