Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @keybindy/core

## [2.0.2] - 2026-09-15

### Improvements & Fixes

- `register()` now logs a development warning when a registration overwrites an existing binding for the same keys and scope, instead of replacing it silently.


## [2.0.1] - 2026-08-30

### Improvements & Fixes
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@keybindy/core",
"version": "2.0.1",
"version": "2.0.2",
"description": "A lightweight and framework-agnostic keyboard shortcut manager for web apps. Define, register, and handle keybindings with ease.",
"author": {
"name": "PRASSamin",
Expand Down
58 changes: 51 additions & 7 deletions packages/core/src/ShortcutManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,14 +632,20 @@ describe('ShortcutManager', () => {
manager.register(['B'], globalHandler, { scope: 'global' });

// Scoped hook only for canvas
manager.beforeEach(() => {
canvasOrder.push('canvas-before');
}, { scope: 'canvas' });
manager.beforeEach(
() => {
canvasOrder.push('canvas-before');
},
{ scope: 'canvas' }
);

// Key-filtered hook only for 'B'
manager.beforeEach(() => {
globalOrder.push('b-before');
}, { keys: ['B'] });
manager.beforeEach(
() => {
globalOrder.push('b-before');
},
{ keys: ['B'] }
);

// Trigger 'B' (global)
manager.setActiveScope('global');
Expand Down Expand Up @@ -697,12 +703,50 @@ describe('ShortcutManager', () => {
expect(dgBeforeHook).not.toHaveBeenCalled();
});
});
});

describe('duplicate registration', () => {
it('should warn when a binding is overwritten in the same scope', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

manager.register(['A'], vi.fn());
manager.register(['A'], vi.fn());

const warnings = warnSpy.mock.calls.filter(([msg]) =>
String(msg).includes('was overwritten')
);
expect(warnings).toHaveLength(1);

warnSpy.mockRestore();
});

it('should not warn when the same combo is registered in different scopes', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

manager.register(['A'], vi.fn(), { scope: 'global' });
manager.register(['A'], vi.fn(), { scope: 'editor' });

const warnings = warnSpy.mock.calls.filter(([msg]) =>
String(msg).includes('was overwritten')
);
expect(warnings).toHaveLength(0);

warnSpy.mockRestore();
});

it('should only fire the most recently registered handler', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const first = vi.fn();
const second = vi.fn();

manager.register(['A'], first);
manager.register(['A'], second);

dispatchKeyEvent('keydown', 'KeyA');

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();

warnSpy.mockRestore();
});
});
});
36 changes: 36 additions & 0 deletions packages/core/src/ShortcutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ import { ScopeManager } from './ScopeManager';
import { EventEmitter } from './utils/eventemitter';
import { Logger } from './utils/log';

/**
* Whether the current build is a production bundle.
* Written so bundlers (Next.js, Vite, webpack) can statically replace `process.env.NODE_ENV`,
* while plain browser environments without a `process` shim simply fall back to `false`.
*/
const isProductionBuild = (() => {
try {
return process.env.NODE_ENV === 'production';
} catch {
return false;
Comment on lines +25 to +29
}
})();

/**
* Manages keyboard shortcuts with support for scopes, enabling/disabling,
* dynamic registration, and cheat sheet generation.
Expand All @@ -25,6 +38,7 @@ export class ShortcutManager extends ScopeManager {
private shortcuts: Shortcut[] = [];
private pressedKeys = new Set<string>();
private activeHoldShortcuts = new Set<string>();
private warnedDuplicates = new Set<string>();
private typingEmitter = new EventEmitter<{
key: string;
event: KeyboardEvent;
Expand Down Expand Up @@ -513,6 +527,27 @@ export class ShortcutManager extends ScopeManager {
for (const combo of expandedCombos) {
const normalized = combo.map(k => k.toLowerCase() as Keys);

const replaced = this.shortcuts.filter(
s =>
JSON.stringify(s.keys) === JSON.stringify(normalized) &&
(s.options?.scope || 'global') === targetScope &&
s.id !== id
);
Comment on lines +532 to +535

const duplicateSignature = `${targetScope}::${JSON.stringify(normalized)}`;
if (
replaced.length > 0 &&
!this.warnedDuplicates.has(duplicateSignature) &&
!isProductionBuild
) {
this.warnedDuplicates.add(duplicateSignature);
console.warn(
`[Keybindy] Duplicate binding ${JSON.stringify(normalized)} in scope "${targetScope}" was overwritten. ` +
`The previous registration has been replaced and will never fire. ` +
`If this is a repeated component instance, give it its own scope or avoid registering while inactive.`
);
}

this.shortcuts = this.shortcuts.filter(
s =>
JSON.stringify(s.keys) !== JSON.stringify(normalized) ||
Expand Down Expand Up @@ -683,6 +718,7 @@ export class ShortcutManager extends ScopeManager {
this.resetScope();
this.activeSequences = [];
this.activeHoldShortcuts.clear();
this.warnedDuplicates.clear();
this.logger.log('Instance destroyed');
}

Expand Down
7 changes: 7 additions & 0 deletions packages/react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @keybindy/react

## [2.0.2] - 2026-09-15

### Improvements & Fixes

- **`disabled` no longer disables sibling shortcuts.** When a hook is disabled it now registers nothing and never claims the active scope, so `{ disabled: !isOpen }` is the recommended way to gate dialogs, pickers, and other parallel component instances — an inactive copy can no longer steal keys from the active one, and unmounting it leaves other bindings intact.
- **Development warning for duplicate registrations** — when two hooks register the same keys in the same scope, a single actionable warning is printed instead of failing silently. The warning re-arms once all colliding registrations unmount.

## [2.0.1] - 2026-08-30

### Improvements & Fixes
Expand Down
Loading
Loading