[BUG] Keyboard press leaves a listener on every focus and on every Enter press (duplicated pointerup)

クローズ 初心者向け
#3,935 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

関連するプルリクエストがすでにマージされています。

  • #3953 @mattgperry による — マージ済み

評価

難易度
2/5
見積もり時間
1〜3時間
初心者へのやさしさ
74/100
issue の種類
バグ
明瞭さ
明確に書かれている
活発さ
活発
技術スタック
javascript
領域
frontend

調査の方向性

motion-dom の gestures/press/utils/keyboard.mjs、enableKeyboardPress から始めてください。ここでは keydown、keyup、blur のリスナーが追加されており、keydown を削除する blur リスナーが一度も削除されていません。issue には、有効なリスナー数と発火した pointerup イベント数を数える自己完結型の Node スクリプトが含まれているので、変更の前後で実行してください。完了条件は、focus/blur を繰り返した後も blur リスナーの数が 1 のままであり、Enter キーを押すたびに pointerup が 1 回だけ発生することです。

索引モデルが issue の本文から書いたものです。

説明

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 a blur listener that removes the keydown listener, but the blur listener itself is never removed. Each focus/blur cycle leaves one more blur listener on the element.
  • On every Enter keydown, it adds a keyup listener and a blur listener, and neither is ever removed. After k Enter presses, one Enter keyup dispatches k synthetic pointerup events, and one blur dispatches k pointercancel events.

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

  1. Render any element with whileTap (or call press() on it).
  2. Focus and blur it several times, or press Enter on it several times.
  3. Count its listeners: blur grows by one per focus, keyup and blur by one per Enter press, and Enter keyup fires duplicated pointerup events.

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

  • motion 13.4 / motion-dom 13.3.0 in our app; the same code is in motion-dom 14.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 no blur listener after the focus/blur cycles and fires 3 pointerup events 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.

主要言語
TypeScript
スター
34.6k
フォーク
1.5k
平均マージ
1日 2時間
マージ済み PR(30日)
118

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

motiondivision/motion のほかの issue

motiondivision/motion の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。