diff --git a/.changeset/key-state-tracker-capture-phase.md b/.changeset/key-state-tracker-capture-phase.md new file mode 100644 index 00000000..e5958195 --- /dev/null +++ b/.changeset/key-state-tracker-capture-phase.md @@ -0,0 +1,5 @@ +--- +'@tanstack/hotkeys': patch +--- + +Register KeyStateTracker listeners in capture phase so held-key state stays accurate while a HotkeyRecorder / HotkeySequenceRecorder is active. diff --git a/packages/hotkeys/src/key-state-tracker.ts b/packages/hotkeys/src/key-state-tracker.ts index bb1e666c..950b99b2 100644 --- a/packages/hotkeys/src/key-state-tracker.ts +++ b/packages/hotkeys/src/key-state-tracker.ts @@ -150,8 +150,8 @@ export class KeyStateTracker { } } - document.addEventListener('keydown', this.#keydownListener) - document.addEventListener('keyup', this.#keyupListener) + document.addEventListener('keydown', this.#keydownListener, true) + document.addEventListener('keyup', this.#keyupListener, true) window.addEventListener('blur', this.#blurListener) } @@ -174,12 +174,12 @@ export class KeyStateTracker { } if (this.#keydownListener) { - document.removeEventListener('keydown', this.#keydownListener) + document.removeEventListener('keydown', this.#keydownListener, true) this.#keydownListener = null } if (this.#keyupListener) { - document.removeEventListener('keyup', this.#keyupListener) + document.removeEventListener('keyup', this.#keyupListener, true) this.#keyupListener = null } diff --git a/packages/hotkeys/tests/key-state-tracker.test.ts b/packages/hotkeys/tests/key-state-tracker.test.ts index 118e8589..895545a6 100644 --- a/packages/hotkeys/tests/key-state-tracker.test.ts +++ b/packages/hotkeys/tests/key-state-tracker.test.ts @@ -1,5 +1,6 @@ /// import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HotkeySequenceRecorder } from '../src/hotkey-sequence-recorder' import { KeyStateTracker } from '../src/key-state-tracker' /** @@ -275,4 +276,35 @@ describe('KeyStateTracker', () => { expect(tracker.isKeyHeld('A')).toBe(true) }) }) + + describe('capture phase registration', () => { + // Regression: a HotkeySequenceRecorder registers a capture-phase keydown + // listener that calls stopPropagation. If the tracker is on the bubble + // phase, propagation halts before its listener runs and held-key state + // goes stale. Capture-phase registration ensures the tracker observes + // every key, even when a recorder is active. + it('tracks held keys while a HotkeySequenceRecorder is active', () => { + const tracker = KeyStateTracker.getInstance() + const recorder = new HotkeySequenceRecorder({ onRecord: () => {} }) + recorder.start() + + const child = document.createElement('div') + document.body.appendChild(child) + + try { + child.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Control', + code: 'ControlLeft', + bubbles: true, + }), + ) + + expect(tracker.getHeldKeys()).toContain('Control') + } finally { + recorder.destroy() + child.remove() + } + }) + }) })