From c4273b9e3cf20a992654267f6b0be548549c9285 Mon Sep 17 00:00:00 2001 From: PenghuiXu Date: Thu, 17 Sep 2026 19:15:51 +0800 Subject: [PATCH 1/2] fix: auto-scroll when a native drag reaches the edge of a virtual list A virtual list does not scroll when an item is dragged to its top/bottom edge, so items outside the viewport can't be reached. The container is `overflow: hidden`, which disables the browser's native drag-to-edge autoscroll, and the JS fallback in `useScrollDrag` cannot cover it: it runs on `mousemove`, which the browser does not fire during a native HTML5 drag. It also skips draggable targets on purpose (see #386) so the mouse path never fights the native drag - that skip is kept as is. Add `useDragEdgeScroll` next to it to drive the scrolling from the drag events: `dragover` computes the offset from the pointer's distance to the edge and keeps a rAF loop running, `dragleave` / `drop` / `dragend` cancel it. `dragover` only fires while a native drag is in progress, so the hook needs no "is dragging" flag from the consumer and can stay mounted: consumers such as rc-tree get the behavior without passing any state. `drop` and `dragend` are listened on the document in the capture phase, because a consumer's node may stop their propagation in the bubble phase (rc-tree's TreeNode does). Band sizing (`min(itemHeight * 1.2, height / 4)`) and easing reuse `smoothScrollOffset` from `useScrollDrag`, so both paths feel the same. --- src/List.tsx | 9 ++ src/hooks/useDragEdgeScroll.ts | 110 +++++++++++++++ src/hooks/useScrollDrag.ts | 2 +- tests/dragEdgeScroll.test.js | 248 +++++++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useDragEdgeScroll.ts create mode 100644 tests/dragEdgeScroll.test.js diff --git a/src/List.tsx b/src/List.tsx index a7b438c..d622e67 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -14,6 +14,7 @@ import { useGetSize } from './hooks/useGetSize'; import useHeights from './hooks/useHeights'; import useMobileTouchMove from './hooks/useMobileTouchMove'; import useOriginScroll from './hooks/useOriginScroll'; +import useDragEdgeScroll from './hooks/useDragEdgeScroll'; import useScrollDrag from './hooks/useScrollDrag'; import type { ScrollOffset, ScrollOffsetInfo, ScrollPos, ScrollTarget } from './hooks/useScrollTo'; import useScrollTo from './hooks/useScrollTo'; @@ -470,6 +471,14 @@ export function RawList(props: ListProps, ref: React.Ref) { syncScrollTop((top) => top + offset); }); + // Native HTML5 drag (e.g. a draggable Tree node) toward the edge: the + // `mousemove` path above is not fired during a native drag, and the native + // drag-to-edge autoscroll is disabled by `overflow: hidden`, so drive it from + // the drag events here instead. + useDragEdgeScroll(inVirtual, componentRef, height, itemHeight, (offset) => { + syncScrollTop((top) => top + offset); + }); + useLayoutEffect(() => { // Firefox only function onMozMousePixelScroll(e: WheelEvent) { diff --git a/src/hooks/useDragEdgeScroll.ts b/src/hooks/useDragEdgeScroll.ts new file mode 100644 index 0000000..ec6dad0 --- /dev/null +++ b/src/hooks/useDragEdgeScroll.ts @@ -0,0 +1,110 @@ +import { raf } from '@rc-component/util'; +import * as React from 'react'; +import { smoothScrollOffset } from './useScrollDrag'; + +/** + * The mouse-drag path in `useScrollDrag` runs on `mousemove`, which the browser + * does not fire during a native HTML5 drag — and it deliberately skips + * draggable targets so it never fights the native drag. So when a consumer + * (e.g. a draggable Tree) drags a node toward the edge of a virtual list, the + * container is `overflow: hidden`, the browser's native drag-to-edge autoscroll + * is disabled, and nothing scrolls. + * + * Drive the edge scrolling from the native drag events instead. `dragover` only + * fires while a drag is in progress, so no extra "is dragging" flag is needed: + * the listeners can stay mounted and simply do nothing until a drag happens. + */ +export default function useDragEdgeScroll( + inVirtual: boolean, + componentRef: React.RefObject, + height: number, + itemHeight: number, + onScrollOffset: (offset: number) => void, +) { + const onScrollOffsetRef = React.useRef(onScrollOffset); + onScrollOffsetRef.current = onScrollOffset; + + React.useEffect(() => { + const ele = componentRef.current; + if (!inVirtual || !ele || !height) { + return; + } + + // `height / 4` caps each band so the top and bottom never overlap on short + // containers (an idle zone always remains in the middle); `itemHeight * 1.2` + // keeps a roughly one-row band otherwise. + const edgeThreshold = Math.min(itemHeight * 1.2, height / 4); + + let rafId: number | null = null; + let offset = 0; + + const stopScroll = () => { + if (rafId !== null) { + raf.cancel(rafId); + rafId = null; + } + offset = 0; + }; + + const scrollFrame = () => { + onScrollOffsetRef.current(offset); + rafId = raf(scrollFrame); + }; + + const continueScroll = () => { + // `0` happens when the pointer sits exactly on the band boundary; spinning + // the loop for a no-op would fire a scroll callback on every frame. + if (offset === 0) { + stopScroll(); + return; + } + if (rafId === null) { + rafId = raf(scrollFrame); + } + }; + + const onDragOver = (e: DragEvent) => { + const { top, bottom } = ele.getBoundingClientRect(); + const { clientY } = e; + + if (clientY <= top + edgeThreshold) { + offset = -smoothScrollOffset(top + edgeThreshold - clientY); + continueScroll(); + } else if (clientY >= bottom - edgeThreshold) { + offset = smoothScrollOffset(clientY - (bottom - edgeThreshold)); + continueScroll(); + } else { + stopScroll(); + } + }; + + const onDragLeave = (e: DragEvent) => { + // `dragleave` also fires when moving between inner nodes; only stop when + // the pointer truly leaves the container. + const related = e.relatedTarget as Node | null; + if (!related || !ele.contains(related)) { + stopScroll(); + } + }; + + const ownerDocument = ele.ownerDocument; + + // `dragover` / `dragleave` need the container rect, so listen on it. + // `drop` / `dragend` are listened on the document in the CAPTURE phase: a + // consumer's node may stop their propagation in the bubble phase (rc-tree's + // `TreeNode` does), so capture is the only reliable place to see the drag + // ending. + ele.addEventListener('dragover', onDragOver); + ele.addEventListener('dragleave', onDragLeave); + ownerDocument.addEventListener('drop', stopScroll, true); + ownerDocument.addEventListener('dragend', stopScroll, true); + + return () => { + stopScroll(); + ele.removeEventListener('dragover', onDragOver); + ele.removeEventListener('dragleave', onDragLeave); + ownerDocument.removeEventListener('drop', stopScroll, true); + ownerDocument.removeEventListener('dragend', stopScroll, true); + }; + }, [inVirtual, height, itemHeight]); +} diff --git a/src/hooks/useScrollDrag.ts b/src/hooks/useScrollDrag.ts index 226c555..7449d7c 100644 --- a/src/hooks/useScrollDrag.ts +++ b/src/hooks/useScrollDrag.ts @@ -1,7 +1,7 @@ import { raf } from '@rc-component/util'; import * as React from 'react'; -function smoothScrollOffset(offset: number) { +export function smoothScrollOffset(offset: number) { return Math.floor(offset ** 0.5); } diff --git a/tests/dragEdgeScroll.test.js b/tests/dragEdgeScroll.test.js new file mode 100644 index 0000000..ea49e61 --- /dev/null +++ b/tests/dragEdgeScroll.test.js @@ -0,0 +1,248 @@ +import { act, createEvent, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import List from '../src'; +import { spyElementPrototypes } from './utils/domHook'; + +function genData(count) { + return new Array(count).fill(null).map((_, index) => ({ id: String(index) })); +} + +// The virtual list positions its content by writing `transform: translateY(...)` +// as a plain string, so this does not depend on a real layout engine and stays +// correct under jsdom. +function getScrollTop(container) { + const innerEle = container.querySelector('.rc-virtual-list-holder-inner'); + const { transform } = innerEle.style; + const m = transform && transform.match(/\d+/); + return m ? Number(m[0]) : 0; +} + +// jsdom's synthetic drag event drops unknown init props, so assign the pointer +// coordinate directly (same approach as the mouse-drag specs). +function fireDragOver(el, clientY) { + const event = createEvent.dragOver(el); + event.clientY = clientY; + fireEvent(el, event); +} + +function fireDragLeave(el, relatedTarget) { + const event = createEvent.dragLeave(el); + event.relatedTarget = relatedTarget; + fireEvent(el, event); +} + +describe('List.NativeDragEdgeScroll', () => { + let mockElement; + + beforeEach(() => { + jest.useFakeTimers(); + // Container rect: top=0, bottom=100, height=100. + // edgeThreshold = min(itemHeight * 1.2, height / 4) = min(24, 25) = 24. + // => top band [0, 24], bottom band [76, 100], idle zone (24, 76). + mockElement = spyElementPrototypes(HTMLElement, { + offsetHeight: { + get() { + const height = this.getAttribute('data-height'); + return Number(height || 20); + }, + }, + clientHeight: { + get: () => 100, + }, + getBoundingClientRect: () => ({ top: 0, bottom: 100, width: 100, height: 100 }), + offsetParent: { + get: () => document.body, + }, + }); + }); + + afterEach(() => { + mockElement.mockRestore(); + jest.useRealTimers(); + }); + + function renderList(props) { + return render( + + {({ id }) =>
  • {id}
  • } +
    , + ); + } + + function getInnerLi(container) { + return container.querySelector('.rc-virtual-list-holder-inner li'); + } + + it('scrolls down when a drag reaches the bottom edge', () => { + const { container } = renderList(); + expect(getScrollTop(container)).toEqual(0); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container)).toBeGreaterThan(0); + }); + + it('scrolls up when a drag reaches the top edge', () => { + const listRef = React.createRef(); + const { container } = renderList({ ref: listRef }); + act(() => { + listRef.current.scrollTo(400); + jest.advanceTimersByTime(100); + }); + const before = getScrollTop(container); + expect(before).toBeGreaterThan(0); + + fireDragOver(getInnerLi(container), 5); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container)).toBeLessThan(before); + }); + + it('does not scroll while the pointer stays in the idle middle zone', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 50); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(0); + }); + + it('does not scroll when the pointer sits exactly on the band boundary', () => { + const { container } = renderList(); + + // Prove the loop can run first: inside the bottom band. + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + expect(getScrollTop(container)).toBeGreaterThan(0); + const atBoundary = getScrollTop(container); + + // Exactly `bottom - edgeThreshold` (100 - 24 = 76): distance to the edge is + // 0, so the offset is 0 and the loop must stop instead of spinning. + fireDragOver(getInnerLi(container), 76); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(atBoundary); + }); + + it('reuses the running loop when a drag stays inside the edge band', () => { + const { container } = renderList(); + + // Baseline: one `dragover`, then measure how far the loop moves us. + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const perFrame = getScrollTop(container); + expect(perFrame).toBeGreaterThan(0); + + // Second `dragover`, still inside the band: the loop is already scheduled, so + // the next frame moves by the same amount. A second loop would double it. + fireDragOver(getInnerLi(container), 88); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container) - perFrame).toBe(perFrame); + }); + + it('stops scrolling on drop', () => { + const { container } = renderList(); + const holder = container.querySelector('.rc-virtual-list-holder'); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + fireEvent.drop(holder); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(afterEdge); + }); + + it('stops scrolling on dragend even when released outside the container', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + // Release anywhere; the document-level capture listener must still stop it. + fireEvent.dragEnd(document.body); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(afterEdge); + }); + + it('stops scrolling when the drag really leaves the container', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + // `relatedTarget` outside the container => the pointer truly left. + fireDragLeave(getInnerLi(container), document.body); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(afterEdge); + }); + + it('keeps scrolling when the drag only moves between inner nodes', () => { + const { container } = renderList(); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + const afterEdge = getScrollTop(container); + expect(afterEdge).toBeGreaterThan(0); + + // `dragleave` also fires between inner nodes; a `relatedTarget` still inside + // the container must NOT be treated as leaving. + const holder = container.querySelector('.rc-virtual-list-holder'); + fireDragLeave(getInnerLi(container), holder); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(getScrollTop(container)).toBeGreaterThan(afterEdge); + }); + + it('does not attach the behavior for a non-virtual list', () => { + // `virtual={false}` keeps native drag-to-edge to the browser. + const { container } = renderList({ virtual: false }); + + fireDragOver(getInnerLi(container), 95); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(getScrollTop(container)).toEqual(0); + }); +}); From 147f8859c6cca7b0b809c6dd8bf981c19dfbd973 Mon Sep 17 00:00:00 2001 From: PenghuiXu Date: Fri, 18 Sep 2026 11:39:01 +0800 Subject: [PATCH 2/2] fix: let only the innermost virtual List handle a nested dragover A nested virtual List (see examples/nest.tsx) is rendered inside the item of another one, so a dragover on an inner item bubbles to both holders. Both useDragEdgeScroll listeners ran and each started its own rAF loop, scrolling the outer list as well. Mark the event the same way useScrollDrag already does on mousedown: the innermost holder sees it first, the outer ones bail out. dragover fires repeatedly while the drag lasts, so the flag is set per event. Also use the same clientY for the second dragover in the loop-reuse test. Different coordinates yield different offsets (floor(sqrt(19)) = 4 vs floor(sqrt(12)) = 3), which masked the difference between one loop and two - with one coordinate the assertion can actually tell them apart. --- src/hooks/useDragEdgeScroll.ts | 13 +++++++++++-- tests/dragEdgeScroll.test.js | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDragEdgeScroll.ts b/src/hooks/useDragEdgeScroll.ts index ec6dad0..6a71827 100644 --- a/src/hooks/useDragEdgeScroll.ts +++ b/src/hooks/useDragEdgeScroll.ts @@ -64,6 +64,17 @@ export default function useDragEdgeScroll( }; const onDragOver = (e: DragEvent) => { + // Skip if a nested virtual List already handled this event, the same way + // `useScrollDrag` does on `mousedown`: a nested List's `dragover` bubbles + // to both holders and only the innermost one should scroll. + const event = e as DragEvent & { _virtualHandled?: boolean }; + if (event._virtualHandled) { + return; + } + // `dragover` keeps firing while the drag lasts, so mark each event: the + // innermost holder sees it first and the outer ones bail out above. + event._virtualHandled = true; + const { top, bottom } = ele.getBoundingClientRect(); const { clientY } = e; @@ -79,8 +90,6 @@ export default function useDragEdgeScroll( }; const onDragLeave = (e: DragEvent) => { - // `dragleave` also fires when moving between inner nodes; only stop when - // the pointer truly leaves the container. const related = e.relatedTarget as Node | null; if (!related || !ele.contains(related)) { stopScroll(); diff --git a/tests/dragEdgeScroll.test.js b/tests/dragEdgeScroll.test.js index ea49e61..41558e2 100644 --- a/tests/dragEdgeScroll.test.js +++ b/tests/dragEdgeScroll.test.js @@ -148,7 +148,7 @@ describe('List.NativeDragEdgeScroll', () => { // Second `dragover`, still inside the band: the loop is already scheduled, so // the next frame moves by the same amount. A second loop would double it. - fireDragOver(getInnerLi(container), 88); + fireDragOver(getInnerLi(container), 95); act(() => { jest.advanceTimersByTime(100); }); @@ -234,6 +234,39 @@ describe('List.NativeDragEdgeScroll', () => { expect(getScrollTop(container)).toBeGreaterThan(afterEdge); }); + it('only the innermost virtual list scrolls on a nested drag', () => { + // `examples/nest.tsx` renders a virtual List inside the item of another one, + // so a `dragover` on an inner item bubbles to both holders. + const { container } = render( + + {() => ( +
  • + + {({ id }) =>
  • {id}
  • } +
    + + )} + , + ); + + const holdersInners = () => container.querySelectorAll('.rc-virtual-list-holder-inner'); + const readTop = (ele) => { + const matched = ele.style.transform && ele.style.transform.match(/\d+/); + return matched ? Number(matched[0]) : 0; + }; + + // The mocked rect is 0..100 for every element, so `95` lands in both bottom + // bands: inner `min(10 * 1.2, 40 / 4)` = 10 -> [90, 100], outer + // `min(20 * 1.2, 100 / 4)` = 24 -> [76, 100]. Only the inner one may react. + fireDragOver(holdersInners()[1].querySelector('li'), 95); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(readTop(holdersInners()[1])).toBeGreaterThan(0); + expect(readTop(holdersInners()[0])).toEqual(0); + }); + it('does not attach the behavior for a non-virtual list', () => { // `virtual={false}` keeps native drag-to-edge to the browser. const { container } = renderList({ virtual: false });