diff --git a/.changeset/tangy-clubs-divide.md b/.changeset/tangy-clubs-divide.md new file mode 100644 index 00000000..358b30e7 --- /dev/null +++ b/.changeset/tangy-clubs-divide.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-hotkeys': patch +--- + +Fixed `useHotkey` updating registration options during the render phase. Options are now synced in an effect after the UI commits, so store subscribers (like `useHotkeyRegistrations`) are no longer notified mid-render, which could trigger React warnings and inconsistent state. Fixes #113. diff --git a/packages/react-hotkeys/src/useHotkey.ts b/packages/react-hotkeys/src/useHotkey.ts index e5d3a749..905f5418 100644 --- a/packages/react-hotkeys/src/useHotkey.ts +++ b/packages/react-hotkeys/src/useHotkey.ts @@ -178,10 +178,17 @@ export function useHotkey( } }, [hotkeyString]) - // Sync callback and options on EVERY render (outside useEffect) + // Sync callback on EVERY render (outside useEffect) // This avoids stale closures - the callback always has access to latest state if (registrationRef.current?.isActive) { registrationRef.current.callback = callback - registrationRef.current.setOptions(optionsWithoutTarget) } + + // Sync options after the UI commits so store subscribers aren't + // notified during the render phase + useEffect(() => { + if (registrationRef.current?.isActive) { + registrationRef.current.setOptions(optionsWithoutTarget) + } + }) } diff --git a/packages/react-hotkeys/tests/useHotkey.test.tsx b/packages/react-hotkeys/tests/useHotkey.test.tsx index 03da08c8..82fb05d6 100644 --- a/packages/react-hotkeys/tests/useHotkey.test.tsx +++ b/packages/react-hotkeys/tests/useHotkey.test.tsx @@ -257,3 +257,31 @@ describe('useHotkey', () => { }) }) }) + +describe('options sync timing', () => { + it('should notify store subscribers after commit, not during render', () => { + const manager = HotkeyManager.getInstance() + const timeline: Array = [] + + const { rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => { + timeline.push('render-start') + useHotkey('Mod+S', () => {}, { platform: 'mac', enabled }) + timeline.push('render-end') + }, + { initialProps: { enabled: true } }, + ) + + const subscribe = manager.registrations.subscribe(() => { + timeline.push('store-notified') + }) + + timeline.length = 0; + rerender({ enabled: false }) + + expect(timeline).toEqual(['render-start', 'render-end', 'store-notified']) + + subscribe.unsubscribe(); + }); + }); +