Context
A consumer grid declares a nativeDragZone (Neo.component.Base#nativeDragZone_, filled by Neo.main.addon.NativeDragSource) so a row can be dragged as an HTML5 drag into content the synthetic pipeline cannot reach — here an editor iframe. The same grid keeps the default drag-to-scroll (Neo.grid.ScrollManager#dragScroll → Neo.main.addon.GridDragScroll). The operator reported (2026-09-02): drag a row onto the editor, drop, move the pointer back over the grid — the grid still shows the grabbing cursor and scrolls under a plain mouse move, no button pressed.
Reproduced in real Chromium against that grid, read off Neo.main.addon.GridDragScroll and a document-level event witness:
| moment |
monitorDrag |
activeDrag |
body cursor |
events seen |
| after the press + first moves |
true |
false |
'' |
dragstart on the row cell |
| after the drop |
true |
false |
'' |
dragend — no mouseup |
| pointer moved back over the grid, no button |
false |
true |
grabbing |
— |
The Problem
GridDragScroll#onDragStart (its mousedown handler, src/main/addon/GridDragScroll.mjs:257-283) arms a monitor on every left press over a registered body: monitorDrag, a delay timer, and document-level mousemove / mouseup listeners that wait for minDistance to start a drag-scroll. NativeDragSource arms the row draggable on the same press and the browser starts a native drag on the next moves. From dragstart on, the browser delivers no mousemove and no mouseup to the page — only drag* events — so the monitor never sees its release. dragend fires; the addon does not listen to it. The monitor survives the whole drag and the drop.
The next ordinary pointer movement over the document then reaches the stale monitor listener: elapsed time and distance from the original press both qualify, onMonitorMove calls startDrag, and a drag-scroll runs with no button held — document.body.style.cursor = grabbing !important, document mousemove/mouseup listeners attached, the view scrolling under the pointer until some later mouseup arrives. The neo-draggable exclusion at :239-243 does not help: NativeDragSource arms draggable lazily on the first move, so at press time the row is neither neo-draggable nor draggable.
The Architectural Reality
src/main/addon/GridDragScroll.mjs — construct binds onDragStart/onMonitorEnd/onMonitorMove (:80-83); onDragStart arms the monitor (:257-283); onMonitorMove starts the drag when delay + distance are met (:289-300); onMonitorEnd clears the timer, the monitor and its listeners (:307-316); onDragEnd retires an active drag, restores the cursor and starts the kinetic tail (:127-…); unregister already ends an active drag by calling onDragEnd({type: 'mouseup'}) (:410) — the precedent for ending without a real event. The addon has no dragstart / dragend awareness at all.
src/main/addon/NativeDragSource.mjs — document-level capture listeners for mousedown (arm), dragstart (fill DataTransfer), dragend + mouseup (disarm) (:110-118); its class doc states the browser suppresses mouseup during a native drag (:174). Since PR #18091 it stamps neo-native-drag-active on body for the drag's lifetime.
src/grid/ScrollManager.mjs — registers the view with the addon on mount (updateDragScrollAddon); dragScroll_ defaults to true, so every grid has the monitor.
- Component fixture with a real native drag:
test/playwright/component/apps/empty-viewport lists NativeDragSource in mainThreadAddons; test/playwright/component/component/NativeDragZone.spec.mjs drives dragstart; test/playwright/component/apps/grid-focus (the grid click-focus fixture) has a long scrollable grid and an outside button.
The Fix
GridDragScroll listens for dragstart on the document (capture, bound once in construct) and treats it as the end of any pointer gesture it is tracking: a live monitorDrag → onMonitorEnd(); a live activeDrag → onDragEnd({type: 'mouseup'}) (the unregister precedent — cursor restored, listeners removed, no kinetic tail from a drag the user did not release). The browser guarantees no further mouse events for that gesture; the addon must not wait for one.
- The
dragstart listener lives on the document, not on registered bodies: the native drag may start on a row inside a registered body or on a foreign draggable — either way the monitor is dead. Registration count does not gate it; a page without grids pays one no-op listener.
- Witness in
test/playwright/component/grid/: the grid-focus fixture gains NativeDragSource and a nativeDragZone on the long grid (delegate: '[data-record-id]', one text/plain type). Arms: (a) press on a row, move past minDistance so dragstart fires, continue outside, release → monitorDrag and activeDrag are null, body.style.cursor is empty — red on dev (monitorDrag survives); (b) then move the pointer over the long grid with no button → activeDrag stays null, the view's scrollTop does not change — red on dev (a phantom drag-scroll starts); (c) control: a plain press-move-release on the same grid without a native drag still scrolls it (the drag-scroll itself keeps working).
Acceptance Criteria
Out of Scope
- Whether the drop into the consumer's editor iframe resolves — that is the frame's drop handler and the payload protocol (
cnetDragZone in the consumer), not the engine's gesture state; this ticket ends where the engine's own stuck state ends.
- Touch gestures: a
touchstart press never turns into a native drag.
- #17889 (pointer-event sensors for
DragDrop) — a different addon and pipeline.
Avoided Traps
- Listening to
dragend instead of dragstart. dragend arrives only after the drop, so the monitor would still be armed during the whole drag; and a source node removed mid-drag never fires dragend (Mnemosyne's finding on #18091). dragstart is the moment the browser stops talking to us; end the gesture there.
- Excluding
draggable nodes at mousedown. NativeDragSource arms draggable after the press, so the row is not draggable when the monitor is armed; a path check cannot see a drag that has not been declared yet.
- Running the kinetic tail on the phantom end.
onDragEnd computes a fling from the drag history; ending an activeDrag on dragstart must not fling the grid — pass through the same {type: 'mouseup'} path unregister uses and let the empty recent history yield zero velocity, or cancel the animation explicitly.
Related
- #18065 / PR #18066 — the other
GridDragScroll mousedown finding of the day (the cancelled default).
- PR #18091 (#18087) —
NativeDragSource stamps neo-native-drag-active for the drag's lifetime; a second signal the addon could read, but dragstart is the primary one.
- #17889 — drag sensors to Pointer Events (adjacent, different pipeline).
Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T14:31Z; no equivalent found. A2A claim sweep (last messages, all read-states): no overlapping [lane-claim]. Local corpus: no GridDragScroll ticket mentions a native drag.
Origin Session ID: 87c91db1-7fcd-4987-9932-2bf78d3dda25
Retrieval Hint: "GridDragScroll monitorDrag survives native dragstart grabbing cursor phantom scroll"
Context
A consumer grid declares a
nativeDragZone(Neo.component.Base#nativeDragZone_, filled byNeo.main.addon.NativeDragSource) so a row can be dragged as an HTML5 drag into content the synthetic pipeline cannot reach — here an editor iframe. The same grid keeps the default drag-to-scroll (Neo.grid.ScrollManager#dragScroll→Neo.main.addon.GridDragScroll). The operator reported (2026-09-02): drag a row onto the editor, drop, move the pointer back over the grid — the grid still shows thegrabbingcursor and scrolls under a plain mouse move, no button pressed.Reproduced in real Chromium against that grid, read off
Neo.main.addon.GridDragScrolland a document-level event witness:monitorDragactiveDrag''dragstarton the row cell''dragend— nomouseupgrabbingThe Problem
GridDragScroll#onDragStart(itsmousedownhandler,src/main/addon/GridDragScroll.mjs:257-283) arms a monitor on every left press over a registered body:monitorDrag, adelaytimer, and document-levelmousemove/mouseuplisteners that wait forminDistanceto start a drag-scroll.NativeDragSourcearms the rowdraggableon the same press and the browser starts a native drag on the next moves. Fromdragstarton, the browser delivers nomousemoveand nomouseupto the page — onlydrag*events — so the monitor never sees its release.dragendfires; the addon does not listen to it. The monitor survives the whole drag and the drop.The next ordinary pointer movement over the document then reaches the stale monitor listener: elapsed time and distance from the original press both qualify,
onMonitorMovecallsstartDrag, and a drag-scroll runs with no button held —document.body.style.cursor = grabbing !important, documentmousemove/mouseuplisteners attached, the view scrolling under the pointer until some latermouseuparrives. Theneo-draggableexclusion at:239-243does not help:NativeDragSourcearmsdraggablelazily on the first move, so at press time the row is neitherneo-draggablenordraggable.The Architectural Reality
src/main/addon/GridDragScroll.mjs—constructbindsonDragStart/onMonitorEnd/onMonitorMove(:80-83);onDragStartarms the monitor (:257-283);onMonitorMovestarts the drag when delay + distance are met (:289-300);onMonitorEndclears the timer, the monitor and its listeners (:307-316);onDragEndretires an active drag, restores the cursor and starts the kinetic tail (:127-…);unregisteralready ends an active drag by callingonDragEnd({type: 'mouseup'})(:410) — the precedent for ending without a real event. The addon has nodragstart/dragendawareness at all.src/main/addon/NativeDragSource.mjs— document-level capture listeners formousedown(arm),dragstart(fillDataTransfer),dragend+mouseup(disarm) (:110-118); its class doc states the browser suppressesmouseupduring a native drag (:174). Since PR #18091 it stampsneo-native-drag-activeonbodyfor the drag's lifetime.src/grid/ScrollManager.mjs— registers the view with the addon on mount (updateDragScrollAddon);dragScroll_defaults totrue, so every grid has the monitor.test/playwright/component/apps/empty-viewportlistsNativeDragSourceinmainThreadAddons;test/playwright/component/component/NativeDragZone.spec.mjsdrivesdragstart;test/playwright/component/apps/grid-focus(the grid click-focus fixture) has a long scrollable grid and an outside button.The Fix
GridDragScrolllistens fordragstarton the document (capture, bound once inconstruct) and treats it as the end of any pointer gesture it is tracking: a livemonitorDrag→onMonitorEnd(); a liveactiveDrag→onDragEnd({type: 'mouseup'})(theunregisterprecedent — cursor restored, listeners removed, no kinetic tail from a drag the user did not release). The browser guarantees no further mouse events for that gesture; the addon must not wait for one.dragstartlistener lives on the document, not on registered bodies: the native drag may start on a row inside a registered body or on a foreigndraggable— either way the monitor is dead. Registration count does not gate it; a page without grids pays one no-op listener.test/playwright/component/grid/: thegrid-focusfixture gainsNativeDragSourceand anativeDragZoneon the long grid (delegate: '[data-record-id]', onetext/plaintype). Arms: (a) press on a row, move pastminDistancesodragstartfires, continue outside, release →monitorDragandactiveDragarenull,body.style.cursoris empty — red ondev(monitorDragsurvives); (b) then move the pointer over the long grid with no button →activeDragstaysnull, the view'sscrollTopdoes not change — red ondev(a phantom drag-scroll starts); (c) control: a plain press-move-release on the same grid without a native drag still scrolls it (the drag-scroll itself keeps working).Acceptance Criteria
GridDragScrollends a live monitor and a live active drag on a documentdragstart; no other behaviour of the addon changes.dev.dev.components+unit/manager/unit/gridsuites green at head;NativeDragZonecomponent spec unchanged and green.Out of Scope
cnetDragZonein the consumer), not the engine's gesture state; this ticket ends where the engine's own stuck state ends.touchstartpress never turns into a native drag.DragDrop) — a different addon and pipeline.Avoided Traps
dragendinstead ofdragstart.dragendarrives only after the drop, so the monitor would still be armed during the whole drag; and a source node removed mid-drag never firesdragend(Mnemosyne's finding on #18091).dragstartis the moment the browser stops talking to us; end the gesture there.draggablenodes atmousedown.NativeDragSourcearmsdraggableafter the press, so the row is not draggable when the monitor is armed; a path check cannot see a drag that has not been declared yet.onDragEndcomputes a fling from the drag history; ending anactiveDragondragstartmust not fling the grid — pass through the same{type: 'mouseup'}pathunregisteruses and let the empty recent history yield zero velocity, or cancel the animation explicitly.Related
GridDragScrollmousedown finding of the day (the cancelled default).NativeDragSourcestampsneo-native-drag-activefor the drag's lifetime; a second signal the addon could read, butdragstartis the primary one.Live latest-open sweep: checked the latest 20 open issues at 2026-09-02T14:31Z; no equivalent found. A2A claim sweep (last messages, all read-states): no overlapping
[lane-claim]. Local corpus: noGridDragScrollticket mentions a native drag.Origin Session ID: 87c91db1-7fcd-4987-9932-2bf78d3dda25
Retrieval Hint: "GridDragScroll monitorDrag survives native dragstart grabbing cursor phantom scroll"