Repository navigation
[BUG] Keyboard press leaves a listener on every focus and on every Enter press (duplicated pointerup) #3935
Description
2. Describe the bug
The keyboard side of the press gesture (motion-dom, gestures/press/utils/keyboard.mjs, enableKeyboardPress) adds event listeners that are never removed while the element stays mounted:
- On every
focus, it adds ablurlistener that removes thekeydownlistener, but theblurlistener itself is never removed. Each focus/blur cycle leaves one moreblurlistener on the element. - On every Enter
keydown, it adds akeyuplistener and ablurlistener, and neither is ever removed. After k Enter presses, one Enterkeyupdispatches k syntheticpointerupevents, and oneblurdispatches kpointercancelevents.
They are only released when the gesture's AbortSignal fires, which happens on unmount. So any long-lived focusable element with whileTap or press(), such as a toolbar button, accumulates listeners for as long as the page is open.
Present in motion-dom 14.2.0 (latest) and in 13.x:
const enableKeyboardPress = (focusEvent, eventOptions) => {
const element = focusEvent.currentTarget;
if (!element)
return;
const handleKeydown = filterEvents(() => {
if (isPressing.has(element))
return;
firePointerEvent(element, "down");
const handleKeyup = filterEvents(() => {
firePointerEvent(element, "up");
});
const handleBlur = () => firePointerEvent(element, "cancel");
element.addEventListener("keyup", handleKeyup, eventOptions); // never removed
element.addEventListener("blur", handleBlur, eventOptions); // never removed
});
element.addEventListener("keydown", handleKeydown, eventOptions);
element.addEventListener("blur", () => element.removeEventListener("keydown", handleKeydown), eventOptions); // never removed
};3. Reproduction
The bug is in the module itself, so this self-contained script reproduces it with nothing but motion-dom. It runs as an .mjs file in Node 18+, and the body also runs in any browser sandbox.
import { enableKeyboardPress } from "motion-dom/dist/es/gestures/press/utils/keyboard.mjs";
globalThis.PointerEvent ??= class extends Event {};
const element = new EventTarget();
const live = new Map();
const add = element.addEventListener.bind(element), remove = element.removeEventListener.bind(element);
element.addEventListener = (type, fn, o) => { live.set(fn, type); add(type, fn, o); };
element.removeEventListener = (type, fn, o) => { live.delete(fn); remove(type, fn, o); };
const count = () => [...live.values()].reduce((out, t) => ({ ...out, [t]: (out[t] ?? 0) + 1 }), {});
let ups = 0;
element.addEventListener("pointerup", () => ups++);
for (let i = 0; i < 3; i++) { // three focus/blur cycles, as the gesture does on "focus"
enableKeyboardPress({ currentTarget: element }, {});
element.dispatchEvent(new Event("blur"));
}
console.log(count()); // { pointerup: 1, blur: 3 }: three blur listeners left
enableKeyboardPress({ currentTarget: element }, {});
for (let i = 0; i < 3; i++) { // three Enter presses while focused
const down = new Event("keydown"); down.key = "Enter"; element.dispatchEvent(down);
const up = new Event("keyup"); up.key = "Enter"; element.dispatchEvent(up);
}
console.log(count(), ups); // keyup: 3, blur: 7, and 6 pointerup events where 3 were pressedThe same behaviour shows in a React app. Give a <motion.button whileTap={{ scale: 0.95 }}> focus with Tab and move away a few times, then look at getEventListeners(button) in Chrome DevTools: the blur listeners grow by one per focus.
4. Steps to reproduce
- Render any element with
whileTap(or callpress()on it). - Focus and blur it several times, or press Enter on it several times.
- Count its listeners:
blurgrows by one per focus,keyupandblurby one per Enter press, and Enterkeyupfires duplicatedpointerupevents.
5. Expected behavior
Each focus's listeners are released at its blur, and each press's keyup and blur listeners are released when that press ends. A suggested fix:
const handleKeydown = filterEvents(() => {
if (isPressing.has(element))
return;
firePointerEvent(element, "down");
const end = () => {
element.removeEventListener("keyup", handleKeyup);
element.removeEventListener("blur", handleBlur);
};
const handleKeyup = filterEvents(() => { end(); firePointerEvent(element, "up"); });
const handleBlur = () => { end(); firePointerEvent(element, "cancel"); };
element.addEventListener("keyup", handleKeyup, eventOptions);
element.addEventListener("blur", handleBlur, eventOptions);
});
element.addEventListener("keydown", handleKeydown, eventOptions);
element.addEventListener("blur", () => element.removeEventListener("keydown", handleKeydown), { ...eventOptions, once: true });7. Environment details
motion13.4 /motion-dom13.3.0 in our app; the same code is inmotion-dom14.2.0.- Seen in our app with headless Edge (Chromium) on Windows 11. The script above runs in Node 24. With the suggested fix, the same script
leaves noblurlistener after the focus/blur cycles and fires 3pointerupevents for 3 presses.
It does not affect CPU in practice, and everything is released on unmount. But a long-lived button gains a listener on every focus, and keyboard users get duplicated pointerup/pointercancel events.
Activity
jeevan6996 commentedon Oct 18, 2026
I’m interested in helping with #3935 and noticed plan 023 already covers the keyboard-listener lifecycle. Is anyone implementing that plan, or would a focused contribution be welcome?