Context
Found while fixing #18099 (PR #18103), where a new cancelActiveDrag had to tolerate two shapes of activeDrag. Neo.main.addon.GridDragScroll#onDragEnd computes a fling from the drag history and hands it to autoScroll, which runs on requestAnimationFrame until the velocity decays.
The Problem
autoScroll (src/main/addon/GridDragScroll.mjs, the method right after construct) replaces me.activeDrag on every frame with {id, animation} — the registration id and the next frame handle — and when the velocity drops under 0.1 it simply returns, leaving that handle in activeDrag for good. Two consequences:
activeDrag is truthy forever after any fling, until the next startDrag overwrites it. Anything that reads it as "a drag is in progress" (the drag-scroll component witness in test/playwright/component/grid/ had to stop asserting activeDrag === null after a plain release for exactly this reason) reads a lie.
unregister({id}) ends a live drag through me.onDragEnd({type: 'mouseup'}). After a fling, onDragEnd finds activeDrag truthy and reads me.activeDrag.listeners.move — listeners does not exist on the post-fling handle, so unregistering a grid whose last gesture was a fling throws a TypeError inside a removeEventListener argument. Grid destroy after a flick is the ordinary path here.
The Architectural Reality
autoScroll (:88-110): the only writer of the {id, animation} shape; the velocity floor is where the tail ends and where the state should be cleared.
onDragEnd (:127-170): fling branch sets up autoScroll from the last 100 ms of history; the else branch nulls activeDrag only when fewer than two recent points exist.
cancelActiveDrag (from #18099): already tolerates both shapes — cancels animation if present, removes listeners if present, restores the cursor, nulls the state. It is the natural end for unregister as well.
unregister (:415-…): me.onDragEnd({type: Neo.config.hasTouchEvents ? 'touchend' : 'mouseup'}) — a synthetic event to reach the listener-removal branch; a fling on an unregistered grid is pointless anyway (its elements are going away).
The Fix
autoScroll clears me.activeDrag = null when the velocity falls under the floor (and cancels nothing else — the frame simply does not reschedule).
unregister ends a live drag through cancelActiveDrag() instead of the synthetic onDragEnd; no fling for a grid that is leaving.
- Unit witness in
test/playwright/unit/ for the addon (a jsdom-free harness: the addon's autoScroll and unregister are plain methods; stub requestAnimationFrame/cancelAnimationFrame and a registration with fake elements): (a) a fling runs autoScroll until the floor and ends with activeDrag === null — red on dev (the handle stays); (b) unregister after a fling does not throw and clears the state — red on dev (TypeError); (c) unregister during a live tracked drag removes the cursor override and listeners as before.
- The drag-scroll component witness re-asserts
activeDrag === null after its control's plain release once (1) lands.
Acceptance Criteria
Out of Scope
- The
dragstart handling itself (#18099).
- Touch-specific paths beyond what
cancelActiveDrag already removes.
Avoided Traps
- Clearing
activeDrag at the fling's start instead of its end. onDragMove/onDragEnd do not run during a fling, but a new press over the same grid does: startDrag overwrites the state and the old frame keeps scrolling the registration under the new gesture unless cancelled. Clear at the floor AND keep startDrag's overwrite honest by cancelling a running animation there if one exists.
- Reaching the listener branch with a fake event.
unregister already fakes {type} to reach the right removeEventListener calls; extend that and the handle shape breaks it again. cancelActiveDrag removes by presence, not by event type.
Related
- #18099 / PR #18103 — origin of
cancelActiveDrag, and the witness whose control arm this ticket lets tighten.
- #18065 / PR #18066 — the addon's mousedown default.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T14:52Z; the only GridDragScroll ticket is #18099 (different defect). A2A: no claim on this addon beyond mine.
Origin Session ID: 87c91db1-7fcd-4987-9932-2bf78d3dda25
Retrieval Hint: "GridDragScroll autoScroll fling leaves activeDrag handle unregister TypeError listeners"
Context
Found while fixing #18099 (PR #18103), where a new
cancelActiveDraghad to tolerate two shapes ofactiveDrag.Neo.main.addon.GridDragScroll#onDragEndcomputes a fling from the drag history and hands it toautoScroll, which runs onrequestAnimationFrameuntil the velocity decays.The Problem
autoScroll(src/main/addon/GridDragScroll.mjs, the method right afterconstruct) replacesme.activeDragon every frame with{id, animation}— the registration id and the next frame handle — and when the velocity drops under0.1it simplyreturns, leaving that handle inactiveDragfor good. Two consequences:activeDragis truthy forever after any fling, until the nextstartDragoverwrites it. Anything that reads it as "a drag is in progress" (the drag-scroll component witness intest/playwright/component/grid/had to stop assertingactiveDrag === nullafter a plain release for exactly this reason) reads a lie.unregister({id})ends a live drag throughme.onDragEnd({type: 'mouseup'}). After a fling,onDragEndfindsactiveDragtruthy and readsme.activeDrag.listeners.move—listenersdoes not exist on the post-fling handle, so unregistering a grid whose last gesture was a fling throws aTypeErrorinside aremoveEventListenerargument. Grid destroy after a flick is the ordinary path here.The Architectural Reality
autoScroll(:88-110): the only writer of the{id, animation}shape; the velocity floor is where the tail ends and where the state should be cleared.onDragEnd(:127-170): fling branch sets upautoScrollfrom the last 100 ms of history; theelsebranch nullsactiveDragonly when fewer than two recent points exist.cancelActiveDrag(from #18099): already tolerates both shapes — cancelsanimationif present, removeslistenersif present, restores the cursor, nulls the state. It is the natural end forunregisteras well.unregister(:415-…):me.onDragEnd({type: Neo.config.hasTouchEvents ? 'touchend' : 'mouseup'})— a synthetic event to reach the listener-removal branch; a fling on an unregistered grid is pointless anyway (its elements are going away).The Fix
autoScrollclearsme.activeDrag = nullwhen the velocity falls under the floor (and cancels nothing else — the frame simply does not reschedule).unregisterends a live drag throughcancelActiveDrag()instead of the syntheticonDragEnd; no fling for a grid that is leaving.test/playwright/unit/for the addon (a jsdom-free harness: the addon'sautoScrollandunregisterare plain methods; stubrequestAnimationFrame/cancelAnimationFrameand a registration with fake elements): (a) a fling runsautoScrolluntil the floor and ends withactiveDrag === null— red ondev(the handle stays); (b)unregisterafter a fling does not throw and clears the state — red ondev(TypeError); (c)unregisterduring a live tracked drag removes the cursor override and listeners as before.activeDrag === nullafter its control's plain release once (1) lands.Acceptance Criteria
GridDragScroll.activeDragisnull; red-first ondev.unregisterafter a fling neither throws nor leaves state; red-first ondev.unregisterduring a live tracked drag keeps its current behaviour (cursor restored, document listeners removed) — control.activeDrag: falseagain after a plain release;component/gridgreen.Out of Scope
dragstarthandling itself (#18099).cancelActiveDragalready removes.Avoided Traps
activeDragat the fling's start instead of its end.onDragMove/onDragEnddo not run during a fling, but a new press over the same grid does:startDragoverwrites the state and the old frame keeps scrolling the registration under the new gesture unless cancelled. Clear at the floor AND keepstartDrag's overwrite honest by cancelling a runninganimationthere if one exists.unregisteralready fakes{type}to reach the rightremoveEventListenercalls; extend that and the handle shape breaks it again.cancelActiveDragremoves by presence, not by event type.Related
cancelActiveDrag, and the witness whose control arm this ticket lets tighten.Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T14:52Z; the only
GridDragScrollticket is #18099 (different defect). A2A: no claim on this addon beyond mine.Origin Session ID: 87c91db1-7fcd-4987-9932-2bf78d3dda25
Retrieval Hint: "GridDragScroll autoScroll fling leaves activeDrag handle unregister TypeError listeners"