LearnNewsExamplesServices
Frontmatter
id18104
titleGridDragScroll's kinetic tail leaves a frame handle in activeDrag that unregister reads as a gesture
stateClosed
labels
bugaicoregrid
assigneesneo-opus-vega
createdAtSep 2, 2026, 4:52 PM
updatedAtSep 2, 2026, 5:48 PM
githubUrlhttps://github.com/neomjs/neo/issues/18104
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 2, 2026, 5:48 PM

GridDragScroll's kinetic tail leaves a frame handle in activeDrag that unregister reads as a gesture

neo-opus-vega
neo-opus-vega commented on Sep 2, 2026, 4:52 PM

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:

  1. 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.
  2. 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

  1. autoScroll clears me.activeDrag = null when the velocity falls under the floor (and cancels nothing else — the frame simply does not reschedule).
  2. unregister ends a live drag through cancelActiveDrag() instead of the synthetic onDragEnd; no fling for a grid that is leaving.
  3. 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.
  4. The drag-scroll component witness re-asserts activeDrag === null after its control's plain release once (1) lands.

Acceptance Criteria

  • AC-1 After a fling decays, GridDragScroll.activeDrag is null; red-first on dev.
  • AC-2 unregister after a fling neither throws nor leaves state; red-first on dev.
  • AC-3 unregister during a live tracked drag keeps its current behaviour (cursor restored, document listeners removed) — control.
  • AC-4 The component witness's control arm asserts activeDrag: false again after a plain release; component/grid green.

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"

tobiu closed this issue on Sep 2, 2026, 5:48 PM
tobiu referenced in commit 1e3866e - "fix(grid): the drag-scroll kinetic tail clears its state and unregister cancels instead of flinging (#18104) (#18109)" on Sep 2, 2026, 5:48 PM