Skip to content

fix: useHotkeys multi-key handling with held Meta + global handler audit fixes - #657

Merged
blvdmitry merged 2 commits into
mainfrom
claude/usehotkeys-multikey-bugs-i6bd5q
Aug 17, 2026
Merged

fix: useHotkeys multi-key handling with held Meta + global handler audit fixes#657
blvdmitry merged 2 commits into
mainfrom
claude/usehotkeys-multikey-bugs-i6bd5q

Conversation

@blvdmitry

@blvdmitry blvdmitry commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the reported bug where, with meta + [ and meta + ] hotkeys registered, holding ⌘ and pressing ] works repeatedly, but then pressing [ (while still holding ⌘) does nothing until both keys are released. Also includes an audit of useHotkeys / useSingletonHotkeys global behavior, with the bugs found either fixed here or listed below as known limitations.

Fixed bugs

  1. Stale keys while holding Meta (the reported bug). macOS doesn't emit keyup for regular keys while ⌘ is held, so after ⌘ ] the ] stayed in the pressed map; pressing [ then produced the [+]+meta combination 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 (whose keyup was suppressed) are treated as released and the combination is resolved again. The check is registration-aware, so intentional combinations like meta+x+c still work.
  2. Keys lost during fast key sequences. The window listeners were recreated on every keystroke (handlers depended on pressedMap state) and re-attached in a passive effect, so a second keydown arriving 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.
  3. Releasing ⌘ dropped still-held modifiers. The suppressed-keys cache captured every pressed key including modifiers, so releasing Meta while still holding Shift deleted shift from the map (its keyup fires normally and would have cleaned it up), breaking subsequent shift+… combinations until Shift was re-pressed. Only regular keys are tracked now.
  4. Module-scoped mutable 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.
  5. checkHotkeyState("mod+…") never matched. isPressed looked up the literal mod key, which never exists in the pressed map. It now resolves mod to 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.
  6. Unbinding could remove other components' handlers. unbindHotkeys matched 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. bindHotkeys now returns a disposer that removes exactly the entries it added.
  7. Duplicate hotkey calls with nested providers. Each Reshaped provider 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. Nested SingletonHotkeysProvider instances now render children as is, so only the root provider attaches window listeners and tracks the pressed keys.
  8. Broken alt-key regex. /^[Key|Digit|Numpad]/ is a character class (matches any single character from that set), not the intended alternation, and the follow-up replace was unanchored. It happened to behave correctly for the current key codes, but only by accident — fixed to /^(Key|Digit|Numpad)/ with an anchored replace.
  9. Wrong preventDefault event lookup. pressedMap[pressedId] indexed the per-key map with a combination id ("k+meta"), which is always undefined for combinations and redundant for single keys; removed in favor of e.preventDefault() on the triggering event.
  10. Dead mod cleanup on keyup. delete nextPressedMap["mod"] removed a key that can never exist in the map.

Known limitations (documented, not fixed here)

  • Callbacks are captured at bind time. The registration effect only re-runs when hotkey names, disabled/preventDefault, or the user-supplied deps change, so changing a callback (or toggling it between a function and null) without passing deps keeps the old closure registered. A follow-up could bind stable wrappers that read the latest callbacks from a ref, making deps unnecessary for callback changes.
  • Shifted symbols match by produced character. shift+/ must be registered as shift+?, and shift+1 as shift+!, because e.key reflects the shifted character. Normalizing digits through e.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.
  • Every keydown/keyup re-renders all useHotkeys consumers, because the context value identity must change for render-time checkHotkeyState to stay reactive. A subscription-based API could avoid this.
  • Keys pressed while no hook is mounted aren't tracked (the hooksCount guard), 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

  • The stale-key eviction is registration-aware: stale keys are only dropped when the full pressed combination doesn't match any registered hotkey, so meta+x+c-style combinations keep working. When both meta+x+c and meta+c are registered, pressing ⌘x then c prefers the full combination (matches previous behavior; genuinely ambiguous without keyup events).
  • HotkeyStore.bindHotkeys changed from a bind/unbind pair to returning a disposer — the class is only used internally by the provider.
  • Verified: full storybook browser suite (585 passed; the one Image onLoad failure is pre-existing and environmental — it fails identically on unmodified main in this sandbox), unit tests (181 passed), typecheck, oxlint, oxfmt.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KQNLkT3jj5aT3KSQ3ULwYn

…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
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size Loading time (3g) Running time (snapdragon) Total time
Library / JS 49.65 KB (+0.25% 🔺) 994 ms (+0.25% 🔺) 2.2 s (-25.7% 🔽) 3.2 s
Library / CSS 23.49 KB (0%) 470 ms (0%) 0 ms (+100% 🔺) 470 ms
Theming / JS 7.78 KB (0%) 156 ms (0%) 96 ms (-45.27% 🔽) 251 ms
Theming with a default theme definition / JS 8.69 KB (0%) 174 ms (0%) 224 ms (+8.02% 🔺) 398 ms

- 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
@blvdmitry
blvdmitry marked this pull request as ready for review August 17, 2026 21:29
@blvdmitry
blvdmitry merged commit 7561531 into main Aug 17, 2026
12 checks passed
@blvdmitry
blvdmitry deleted the claude/usehotkeys-multikey-bugs-i6bd5q branch August 17, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants