plans/022-press-end-event-filtering.md
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/gestures/press packages/framer-motion/src/gestures/__tests__/press.test.tsxIf any in-scope file changed since this plan was written, compare the "Current state" excerpts against the live code before proceeding; on a mismatch, treat it as a STOP condition.
press/index.ts; either order, trivial merge)42bfbe3ed, 2026-06-11The press() gesture in motion-dom validates the end event (pointerup/pointercancel) after it has already torn down its window listeners and cleared press state. Two real bugs follow:
Multi-touch kills the press. While a primary touch is pressing an element, lifting any other finger anywhere on the page fires a window pointerup with isPrimary: false. The handler removes both window listeners and deletes the press state, then the validity check rejects the event and returns — so the end callback is never called. When the actual pressing finger lifts, nothing is listening. For React users, whileTap stays visually stuck on and onTap/onTapCancel never fire. For vanilla press() users, the start/end pairing is broken and anything allocated in onPressStart leaks.
A press that turns into a drag never delivers Cancel. When a drag starts mid-press, the pointerup arrives while isDragActive() is true, so the same validity check swallows the callback. The intended behavior (per the React layer's onTapCancel and whileTap) is that the press is cancelled — instead it just evaporates, leaving whileTap active forever. setActive("whileTap", false) is called from exactly one place in the codebase — the press end callback (packages/framer-motion/src/gestures/press.ts:16) — so if that callback is swallowed, nothing else ever resets the state.
packages/motion-dom/src/gestures/press/index.ts — the vanilla press gesture. The bug is in onPointerEnd (lines 71–86):// packages/motion-dom/src/gestures/press/index.ts:71-100
const onPointerEnd = (endEvent: PointerEvent, success: boolean) => {
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onPointerCancel)
if (isPressing.has(target)) {
isPressing.delete(target)
}
if (!isValidPressEvent(endEvent)) {
return
}
if (typeof onPressEnd === "function") {
onPressEnd(endEvent, { success })
}
}
const onPointerUp = (upEvent: PointerEvent) => {
onPointerEnd(
upEvent,
(target as any) === window ||
(target as any) === document ||
options.useGlobalTarget ||
isNodeOrChild(target, upEvent.target as Element)
)
}
const onPointerCancel = (cancelEvent: PointerEvent) => {
onPointerEnd(cancelEvent, false)
}
isValidPressEvent (same file, lines 17–19) is isPrimaryPointer(event) && !isDragActive(). The two halves need different handling at end-time: a non-primary pointer event should be ignored entirely (press continues, listeners stay), while a drag-active end should cancel the press (callback fires with success: false).isPrimaryPointer — packages/motion-dom/src/gestures/utils/is-primary-pointer.ts (mouse: button <= 0; others: isPrimary !== false).isDragActive — packages/motion-dom/src/gestures/drag/state/is-active.ts; reads the module-level isDragging flags, which the test suite can set directly (import { isDragging } from "motion-dom" — see packages/framer-motion/src/gestures/__tests__/hover.test.tsx:1 for precedent).packages/framer-motion/src/gestures/press.ts — success ? "End" : "Cancel" maps to onTap vs onTapCancel, and both reset whileTap.packages/framer-motion/src/gestures/__tests__/press.test.tsx. Relevant baseline: the test "press event listeners doesn't fire if parent is being dragged" (line 288) asserts only that onTap is not called after a drag — it does not assert onTapCancel stays at 0, so delivering Cancel does not break it. Verify this by reading the test before you start.pointerDown/pointerUp from packages/framer-motion/src/jest.setup.tsx, which installs a PointerEventFake passing through isPrimary, pointerType, button.motion-dom to its built dist/ output. After editing motion-dom source, run yarn build from the repo root before running framer-motion tests, or the old code runs.| Purpose | Command | Expected on success |
|---|---|---|
| Build (repo root, REQUIRED after motion-dom edits) | yarn build | exit 0 |
| Press tests | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/__tests__/press" (repo root) | all pass |
| Full gesture tests | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/__tests__" | all pass |
| Lint | yarn lint (repo root) | exit 0 |
Known pre-existing failures to ignore (do not fix): SSR tests failing with TextEncoder is not defined, and the use-velocity test.
In scope (the only files you should modify):
packages/motion-dom/src/gestures/press/index.tspackages/framer-motion/src/gestures/__tests__/press.test.tsxOut of scope (do NOT touch, even though they look related):
packages/motion-dom/src/gestures/press/utils/keyboard.ts — covered by plan 023.packages/framer-motion/src/gestures/press.ts (React layer) — the fix is in the vanilla gesture; the React layer's End/Cancel mapping is already correct.packages/motion-dom/src/gestures/drag/** — drag's own state management is not in question.isValidPressEvent usage at press start (line 60) — start-time filtering is correct as-is.fix/press-end-event-filteringgit log): fix(press): deliver cancel on drag, ignore secondary pointers at press endIn packages/framer-motion/src/gestures/__tests__/press.test.tsx, add two tests (model structure after the existing "press event listeners doesn't fire if parent is being dragged" test and its neighbors; use the nextFrame helper from ./utils already imported in the file):
"press is not ended by a secondary pointer lifting" — render a motion.div with onTapStart, onTap, onTapCancel spies. pointerDown on the element (primary). Then dispatch a non-primary pointerup on window (use fireEvent.pointerUp(window, ...) or construct a PointerEvent with { isPrimary: false, pointerType: "touch", bubbles: true } and window.dispatchEvent — check jest.setup.tsx's PointerEventFake for which properties pass through). Then pointerUp on the element (primary). Assert: onTapStart 1, onTap 1, onTapCancel 0."press delivers cancel when a drag is active at release" — import isDragging from motion-dom. Render with the three spies. pointerDown on the element, then set isDragging.x = true, then pointerUp on the element, then reset isDragging.x = false (use try/finally so a failing assertion can't poison other tests — isDragging is module-level shared state). Assert: onTapStart 1, onTap 0, onTapCancel 1.Verify: npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/__tests__/press" → exactly these 2 new tests fail (test 1: onTap received 0 calls; test 2: onTapCancel received 0 calls). All pre-existing press tests still pass. If they fail for a different reason (e.g. the secondary pointerup never reaches the handler in jsdom), STOP — see STOP conditions.
onPointerEndIn packages/motion-dom/src/gestures/press/index.ts, restructure onPointerEnd so that:
isPressing untouched).success forced to false when a drag is active.Target shape:
const onPointerEnd = (endEvent: PointerEvent, success: boolean) => {
if (!isPrimaryPointer(endEvent)) return
window.removeEventListener("pointerup", onPointerUp)
window.removeEventListener("pointercancel", onPointerCancel)
isPressing.delete(target)
if (typeof onPressEnd === "function") {
onPressEnd(endEvent, { success: success && !isDragActive() })
}
}
Notes: isPressing.delete() is safe without the has() check (matches repo's "prioritise small file size" style). isValidPressEvent remains used at press start — do not delete it. Import isPrimaryPointer is already present in the file.
Verify: yarn build (repo root) → exit 0. Then the press test pattern → all pass, including the 2 new tests.
Verify: npx jest --config packages/framer-motion/jest.config.json --testPathPattern="gestures/__tests__" → all pass (ignore the known pre-existing failures listed above if they appear). yarn lint → exit 0.
Covered by Step 1: secondary-pointer non-teardown, drag-cancel delivery. The existing suite covers: drag suppressing onTap (line 288 — must stay green), keyboard presses, globalTapTarget, propagation. No Cypress test needed: both bugs are pure event-sequencing logic fully reproducible in jsdom with the existing PointerEventFake.
Machine-checkable. ALL must hold:
yarn build exits 0"press event listeners doesn't fire if parent is being dragged" still passes unmodifiedgit status shows no modified files outside the in-scope listplans/README.md status row updatedStop and report back (do not improvise) if:
onPointerEnd excerpt above doesn't match the live code (drift).pointerup through jsdom to press's window listener. Report what you tried; do not land a test that passes pre-fix (repo policy: no repro → no fix).packages/framer-motion/src/gestures/press.ts or any drag file.onTapCancel (previously: nothing). If a consumer complains, that's the intended fix — whileTap previously stuck on.press/index.ts lines 114–124) and keyboard.ts; whichever lands second rebases trivially.success && !isDragActive() ordering (drag must downgrade success, not suppress the callback), and that no pointercancel path can now fire the callback twice (listeners are removed before the callback, so re-entry is impossible).claimedPointerDownEvents / stopPropagation interplay was audited and found correct; not touched here.