Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-buses-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/hotkeys': patch
---

Recover stale held modifier keys from subsequent keyboard and mouse events when their keyup was missed without a window blur.
6 changes: 6 additions & 0 deletions docs/framework/react/guides/key-state-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ On macOS, when a modifier key is held and a non-modifier key is pressed, the OS

When the browser window loses focus, the tracker clears all held keys. Otherwise a key released after tabbing away would appear "stuck" forever.

### Missed modifier releases

System shortcuts and browser tools can consume a modifier's `keyup` event without blurring the page. On the next keyboard event, mouse movement, or mouse button press, the tracker checks that event's modifier flags and removes any tracked Control, Alt, Shift, or Meta keys that are no longer active. This updates `useKeyHold`, `useHeldKeys`, and `useHeldKeyCodes` together. Modifiers still reported as active remain held, including when both left and right modifier keys are pressed.

Recovery requires another event that reports the modifier state or a window blur. If neither arrives, the tracker cannot tell a missed release from a continued hold. It does not expire held keys after a timeout or infer physical key codes from modifier flags alone.

## Under the hood

All three hooks subscribe to the singleton `KeyStateTracker` via `@tanstack/react-store`. The tracker manages its own event listeners on `document` and maintains state in a TanStack Store, which the hooks subscribe to reactively.
Expand Down
14 changes: 7 additions & 7 deletions docs/reference/classes/KeyStateTracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Use this to subscribe to state changes or access current state.
areAllKeysHeld(keys): boolean;
```

Defined in: [key-state-tracker.ts:241](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L241)
Defined in: [key-state-tracker.ts:279](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L279)

Checks if all of the given keys are currently held.

Expand All @@ -86,7 +86,7 @@ True if all of the keys are currently held
destroy(): void;
```

Defined in: [key-state-tracker.ts:248](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L248)
Defined in: [key-state-tracker.ts:286](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L286)

Destroys the tracker and removes all listeners.

Expand All @@ -102,7 +102,7 @@ Destroys the tracker and removes all listeners.
getHeldKeys(): string[];
```

Defined in: [key-state-tracker.ts:208](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L208)
Defined in: [key-state-tracker.ts:246](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L246)

Gets an array of currently held key names.

Expand All @@ -120,7 +120,7 @@ Array of key names currently being pressed
isAnyKeyHeld(keys): boolean;
```

Defined in: [key-state-tracker.ts:231](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L231)
Defined in: [key-state-tracker.ts:269](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L269)

Checks if any of the given keys are currently held.

Expand All @@ -146,7 +146,7 @@ True if any of the keys are currently held
isKeyHeld(key): boolean;
```

Defined in: [key-state-tracker.ts:218](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L218)
Defined in: [key-state-tracker.ts:256](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L256)

Checks if a specific key is currently being held.

Expand All @@ -172,7 +172,7 @@ True if the key is currently held
static getInstance(): KeyStateTracker;
```

Defined in: [key-state-tracker.ts:86](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L86)
Defined in: [key-state-tracker.ts:87](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L87)

Gets the singleton instance of KeyStateTracker.

Expand All @@ -188,7 +188,7 @@ Gets the singleton instance of KeyStateTracker.
static resetInstance(): void;
```

Defined in: [key-state-tracker.ts:96](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L96)
Defined in: [key-state-tracker.ts:97](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L97)

Resets the singleton instance. Useful for testing.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/getKeyStateTracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ title: getKeyStateTracker
function getKeyStateTracker(): KeyStateTracker;
```

Defined in: [key-state-tracker.ts:259](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L259)
Defined in: [key-state-tracker.ts:297](https://github.com/TanStack/hotkeys/blob/main/packages/hotkeys/src/key-state-tracker.ts#L297)

Gets the singleton KeyStateTracker instance.
Convenience function for accessing the tracker.
Expand Down
40 changes: 39 additions & 1 deletion packages/hotkeys/src/key-state-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class KeyStateTracker {
#heldEntries: Map<string, { key: string; code: string }> = new Map()
#keydownListener: ((event: KeyboardEvent) => void) | null = null
#keyupListener: ((event: KeyboardEvent) => void) | null = null
#mouseListener: ((event: MouseEvent) => void) | null = null
#blurListener: (() => void) | null = null

private constructor() {
Expand Down Expand Up @@ -109,12 +110,14 @@ export class KeyStateTracker {
}

this.#keydownListener = (event: KeyboardEvent) => {
let changed = this.#reconcileModifiers(event)
const key = normalizeKeyName(event.key)
const identity = event.code || `key:${key}`
if (!this.#heldEntries.has(identity)) {
this.#heldEntries.set(identity, { key, code: event.code })
this.#syncState()
changed = true
}
if (changed) this.#syncState()
}

this.#keyupListener = (event: KeyboardEvent) => {
Expand All @@ -129,6 +132,8 @@ export class KeyStateTracker {
}
}

this.#reconcileModifiers(event)

// When a modifier key is released, clear any non-modifier keys still
// marked as held. On macOS, the OS intercepts modifier+key combos
// (e.g. Cmd+S) and swallows the keyup event for the non-modifier key,
Expand All @@ -144,6 +149,10 @@ export class KeyStateTracker {
this.#syncState()
}

this.#mouseListener = (event: MouseEvent) => {
if (this.#reconcileModifiers(event)) this.#syncState()
}

// Clear all keys when window loses focus (keys might be released while not focused)
this.#blurListener = () => {
if (this.#heldEntries.size > 0) {
Expand All @@ -154,9 +163,32 @@ export class KeyStateTracker {

document.addEventListener('keydown', this.#keydownListener, true)
document.addEventListener('keyup', this.#keyupListener, true)
document.addEventListener('mousemove', this.#mouseListener, true)
document.addEventListener('mousedown', this.#mouseListener, true)
window.addEventListener('blur', this.#blurListener)
}

/**
* Removes modifiers whose release was missed without a window blur.
* Event modifier state is shared by left/right keys, so a true flag keeps
* both physical entries until their individual keyup events arrive.
*/
#reconcileModifiers(event: KeyboardEvent | MouseEvent): boolean {
let changed = false
for (const [identity, entry] of this.#heldEntries) {
const released =
(entry.key === 'Control' && !event.ctrlKey) ||
(entry.key === 'Alt' && !event.altKey) ||
(entry.key === 'Shift' && !event.shiftKey) ||
(entry.key === 'Meta' && !event.metaKey)
if (released) {
this.#heldEntries.delete(identity)
changed = true
}
}
return changed
}

/**
* Syncs the internal Set to the Store state.
*/
Expand Down Expand Up @@ -194,6 +226,12 @@ export class KeyStateTracker {
this.#keyupListener = null
}

if (this.#mouseListener) {
document.removeEventListener('mousemove', this.#mouseListener, true)
document.removeEventListener('mousedown', this.#mouseListener, true)
this.#mouseListener = null
}

if (this.#blurListener) {
window.removeEventListener('blur', this.#blurListener)
this.#blurListener = null
Expand Down
Loading
Loading