fix: useHotkeys multi-key handling with held Meta + global handler audit fixes - #657
Merged
Merged
Conversation
…correctness - Release suppressed keys when switching hotkeys while holding Meta on macOS, since keyup events for regular keys are not emitted while Meta is pressed - Track pressed keys in a ref so quick key sequences are not lost between renders and window listeners are attached once instead of re-attaching on every keystroke - Track only regular keys for the Meta keyup cleanup so held modifiers like Shift are not dropped when Meta is released - Move the modified keys cache from module scope into the provider - Support the mod key in checkHotkeyState - Unbind only the exact hotkey entries added by each hook instead of matching by callback identity - Handle each keyboard event once when multiple providers are rendered - Fix the alt key detection regex character class and remove the dead combination id lookup in the pressed keys map Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQNLkT3jj5aT3KSQ3ULwYn
size-limit report 📦
|
- Nested SingletonHotkeysProvider instances render children as is so only the root provider attaches window listeners, replacing the per-event deduplication in the hotkey store - Remove the macOS-specific keyboard event stories Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KQNLkT3jj5aT3KSQ3ULwYn
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the reported bug where, with
meta + [andmeta + ]hotkeys registered, holding ⌘ and pressing]works repeatedly, but then pressing[(while still holding ⌘) does nothing until both keys are released. Also includes an audit ofuseHotkeys/useSingletonHotkeysglobal behavior, with the bugs found either fixed here or listed below as known limitations.Fixed bugs
keyupfor regular keys while ⌘ is held, so after⌘ ]the]stayed in the pressed map; pressing[then produced the[+]+metacombination which matches nothing. Now, when a regular key is pressed while Meta is held and the full combination doesn't match any registered hotkey, the previously pressed regular keys (whosekeyupwas suppressed) are treated as released and the combination is resolved again. The check is registration-aware, so intentional combinations likemeta+x+cstill work.pressedMapstate) and re-attached in a passive effect, so a secondkeydownarriving before the effect re-ran read a stale map and dropped earlier keys. The pressed map now lives in a ref (synchronous source of truth) mirrored into state for re-renders; all handlers are stable and listeners are attached once. This also removes the per-keystroke listener add/remove churn.shiftfrom the map (itskeyupfires normally and would have cleaned it up), breaking subsequentshift+…combinations until Shift was re-pressed. Only regular keys are tracked now.modifiedKeys. The cache was a module-level array shared across all provider instances and accumulated duplicates on every keystroke. It's now a per-provider ref and is also cleared on window blur.checkHotkeyState("mod+…")never matched.isPressedlooked up the literalmodkey, which never exists in the pressed map. It now resolvesmodto Meta/Control. It also reads the synchronous map, so it's accurate when called from inside event handlers rather than only after a re-render.unbindHotkeysmatched entries by callback identity, so two hook instances sharing one function reference (e.g. a module-level handler) would both be unregistered when either unmounted.bindHotkeysnow returns a disposer that removes exactly the entries it added.Reshapedprovider attached its own window listeners but they all feed the single global store, so nested providers (e.g. scoped theming roots) fired every callback once per provider. NestedSingletonHotkeysProviderinstances now render children as is, so only the root provider attaches window listeners and tracks the pressed keys./^[Key|Digit|Numpad]/is a character class (matches any single character from that set), not the intended alternation, and the follow-upreplacewas unanchored. It happened to behave correctly for the current key codes, but only by accident — fixed to/^(Key|Digit|Numpad)/with an anchored replace.preventDefaultevent lookup.pressedMap[pressedId]indexed the per-key map with a combination id ("k+meta"), which is alwaysundefinedfor combinations and redundant for single keys; removed in favor ofe.preventDefault()on the triggering event.modcleanup on keyup.delete nextPressedMap["mod"]removed a key that can never exist in the map.Known limitations (documented, not fixed here)
disabled/preventDefault, or the user-supplieddepschange, so changing a callback (or toggling it between a function andnull) without passingdepskeeps the old closure registered. A follow-up could bind stable wrappers that read the latest callbacks from a ref, makingdepsunnecessary for callback changes.shift+/must be registered asshift+?, andshift+1asshift+!, becausee.keyreflects the shifted character. Normalizing digits throughe.code(like the alt handling does) would be a breaking change for apps registered with the symbol form.+,,, and space can't appear inside combinations —+is the combination delimiter,,the hotkey list delimiter, and whitespace is stripped (standalone" "works). Supporting them would need an escape syntax.useHotkeysconsumers, because the context value identity must change for render-timecheckHotkeyStateto stay reactive. A subscription-based API could avoid this.hooksCountguard), so a hotkey mounted mid-press won't see already-held keys. Kept as an intentional perf trade-off.Related Issue
—
Screenshots / Recordings
No UI changes. The macOS behavior (keydown events repeating without keyup while ⌘ is held) was verified by dispatching raw keyboard events against the fixed provider.
Notes for Reviewers
meta+x+c-style combinations keep working. When bothmeta+x+candmeta+care registered, pressing⌘xthencprefers the full combination (matches previous behavior; genuinely ambiguous without keyup events).HotkeyStore.bindHotkeyschanged from abind/unbindpair to returning a disposer — the class is only used internally by the provider.ImageonLoad failure is pre-existing and environmental — it fails identically on unmodifiedmainin this sandbox), unit tests (181 passed), typecheck, oxlint, oxfmt.🤖 Generated with Claude Code
https://claude.ai/code/session_01KQNLkT3jj5aT3KSQ3ULwYn