fix(sidebar): reset drag state on dragend so edge drop zones can't strand over first/last rows (#5774)

This commit is contained in:
Waleed
2026-07-20 13:50:42 -07:00
committed by GitHub
parent 031a29546f
commit c6e8aac317
2 changed files with 134 additions and 0 deletions
@@ -0,0 +1,124 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'ws-1' }),
}))
vi.mock('@/hooks/queries/folders', () => ({
useReorderFolders: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/hooks/queries/workflows', () => ({
useReorderWorkflows: () => ({ mutateAsync: vi.fn() }),
}))
vi.mock('@/hooks/queries/utils/folder-cache', () => ({
getFolderMap: () => ({}),
}))
vi.mock('@/hooks/queries/utils/workflow-cache', () => ({
getWorkflows: () => [],
}))
vi.mock('@/lib/folders/tree', () => ({
getFolderPath: () => [],
}))
const { mockUseFolderStore } = vi.hoisted(() => {
const folderState = { setExpanded: () => {}, expandedFolders: new Set<string>() }
const store = Object.assign(
(selector: (state: typeof folderState) => unknown) => selector(folderState),
{ getState: () => folderState }
)
return { mockUseFolderStore: store }
})
vi.mock('@/stores/folders/store', () => ({ useFolderStore: mockUseFolderStore }))
import { useDragDrop } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop'
type DragDropApi = ReturnType<typeof useDragDrop>
let latest: DragDropApi
function Harness() {
latest = useDragDrop()
return null
}
/** Minimal stand-in for the dragOver event `initDragOver` consumes. */
function fakeDragOverEvent(): unknown {
const node = {}
return {
preventDefault: () => {},
stopPropagation: () => {},
clientY: 0,
// target !== currentTarget so the root drop zone skips indicator math (getBoundingClientRect)
target: node,
currentTarget: {},
}
}
let container: HTMLDivElement
let root: Root
describe('useDragDrop stranded-drag reset', () => {
beforeEach(() => {
// Prevent the auto-scroll rAF loop from spinning in jsdom.
vi.stubGlobal(
'requestAnimationFrame',
() => 0 as unknown as ReturnType<typeof requestAnimationFrame>
)
vi.stubGlobal('cancelAnimationFrame', () => {})
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root.render(<Harness />)
})
// The reset listeners only attach once a scroll container is registered.
act(() => {
latest.setScrollContainer(document.createElement('div'))
})
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('clears isDragging on a window dragend when no drop fired', () => {
// A drag entering the list flips isDragging on via initDragOver.
act(() => {
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
})
expect(latest.isDragging).toBe(true)
// The drag is cancelled/dropped outside the list: only `dragend` fires, no `drop`.
act(() => {
window.dispatchEvent(new Event('dragend'))
})
expect(latest.isDragging).toBe(false)
})
it('keeps isDragging active across dragOver updates until the drag ends', () => {
act(() => {
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
})
expect(latest.isDragging).toBe(true)
// A subsequent dragOver must not tear down the active drag.
act(() => {
latest.createRootDropZone().onDragOver(fakeDragOverEvent() as never)
})
expect(latest.isDragging).toBe(true)
})
})
@@ -649,11 +649,21 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
if (target && container.contains(target)) return
handleDragEnd()
}
/**
* `dragend` always fires on the drag source at the end of any drag operation, including
* Esc-cancels and drops on non-droppable targets. Without this reset, a non-sidebar drag
* that entered the list (flipping `isDragging` on via `initDragOver`) but ended without a
* `drop` inside the container would strand `isDragging` at `true` — leaving the absolutely
* positioned edge drop zones mounted over the first/last rows and stealing their grab band.
*/
const onWindowDragEnd = () => handleDragEnd()
container.addEventListener('dragleave', onLeave)
window.addEventListener('drop', onWindowDrop, true)
window.addEventListener('dragend', onWindowDragEnd, true)
return () => {
container.removeEventListener('dragleave', onLeave)
window.removeEventListener('drop', onWindowDrop, true)
window.removeEventListener('dragend', onWindowDragEnd, true)
}
}, [isDragging, handleDragEnd])