[BUG] Keyboard press leaves a listener on every focus and on every Enter press (duplicated pointerup)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 74/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- javascript
- Domain
- frontend
Research direction
Start in motion-dom's gestures/press/utils/keyboard.mjs, in enableKeyboardPress, where the keydown, keyup and blur listeners are added and the blur listener that removes keydown is never removed. The issue includes a self-contained Node script that counts live listeners and dispatched pointerup events; run it before and after the change. Done when the blur count stays at one after repeated focus/blur cycles and each Enter press yields one pointerup.
Written by the indexing model from the issue text.
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 pressed
The 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.
- Dominant language
- TypeScript
- Stars
- 34k
- Forks
- 1.4k
- Avg merge
- 22h 49m
- Merged PRs (30d)
- 81
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from motiondivision/motion
-
[FEATURE] AnimateSuspense for transitions between suspense fallbacks and streamed contentMay be free again A pull request for this issue was closed without being merged. Openfeature
Difficulty 5/5 Over a week Newbie friendliness 35/100
motiondivision/motion#3173 · 9 reactions ·
Maintainers usually reply within 1 day
-
bug
Difficulty 4/5 3-5 days Newbie friendliness 35/100
motiondivision/motion#2662 · 2 comments · 4 reactions ·
Maintainers usually reply within 1 day
-
feature
Difficulty 4/5 3-5 days Newbie friendliness 35/100
motiondivision/motion#2658 · 1 reaction ·
Maintainers usually reply within 1 day
-
bug
Difficulty 4/5 3-5 days Newbie friendliness 45/100
motiondivision/motion#2616 · 2 comments · 4 reactions ·
Maintainers usually reply within 1 day
-
[BUG] RotateZ not working in Shared Layout AnimationPossibly taken A pull request linked to this issue is open or already merged. Openbug
Difficulty 4/5 3-5 days Newbie friendliness 55/100
motiondivision/motion#2611 · 4 comments · 1 assignee ·
Maintainers usually reply within 1 day
All issues in motiondivision/motion
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
xdjs/MusicNerdWeb#1532 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
sebastian-software/ardo#352 ·
Maintainers usually reply within 1 day
-
@react95/core good first issue
Difficulty 1/5 Under an hour Newbie friendliness 88/100
Maintainers usually reply within 3 days
-
@shikijs/stream: `ShikiStreamTokenizer.clone()` shares its `tokensStable` array with the originalPossibly taken @xyrolle claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 6 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day