onDragStop is dropped when the release arrives before the threshold-crossing move renders (placeholder stays on the grid)
Describe the bug
GridLayout's onDragStop returns early when activeDrag is null (src/react/components/GridLayout.tsx#L595).
With the default drag threshold (3 px), onDragStart runs inside the mousemove that crosses the threshold, and that is where setActiveDrag(placeholder) is called. React renders that update in a later task. If the mouseup is dispatched before that render, GridItem calls the onDragStop from the previous render, whose closure still sees activeDrag === null, and the stop is dropped:
- the
onDragStopprop is never called setActiveDrag(null)never runs, so the placeholder stays on the grid once the pending render lands. It sits above the item (z-index 2), so that item can no longer be grabbed, and layout prop changes are ignored whileactiveDragis set- it only clears when another item is dragged
In a browser this happens when a click slips 3 px or more as the button comes up, and every time with automation that sends press, move and release back to back (we hit it with Chrome DevTools Protocol input). #1745 described the same race for 1.x under React 18. #2263 may be another way into the same state.
Your Example Website or App
Private app - a self-contained test is below.
Steps to Reproduce the Bug or Issue
Vitest + jsdom + @testing-library/react 16, React 19.2.8, react-grid-layout 2.2.4:
import { act, render } from "@testing-library/react";
import GridLayout from "react-grid-layout";
import { expect, it, vi } from "vitest";
it("ends a drag whose release comes back to back with the move that started it", async () => {
// jsdom has no layout, and GridItem needs an offsetParent to start a drag
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get() {
return this.parentElement;
},
});
const onDragStop = vi.fn();
const { container } = render(
<GridLayout
width={1200}
layout={[
{ i: "a", x: 0, y: 0, w: 4, h: 2 },
{ i: "b", x: 4, y: 0, w: 4, h: 2 },
]}
gridConfig={{ cols: 24, rowHeight: 64, margin: [16, 16], containerPadding: [0, 0] }}
dragConfig={{ enabled: true }}
onDragStop={onDragStop}
>
<div key="a">a</div>
<div key="b">b</div>
</GridLayout>,
);
const item = container.querySelector(".react-grid-item")!;
const mouse = (type: string, clientX: number) =>
new MouseEvent(type, { bubbles: true, button: 0, buttons: type === "mouseup" ? 0 : 1, clientX, clientY: 10 });
act(() => {
item.dispatchEvent(mouse("mousedown", 10));
});
// cross the threshold and release back to back, outside act, as a browser can deliver them
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false;
item.dispatchEvent(mouse("mousemove", 60));
item.dispatchEvent(mouse("mouseup", 60));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(onDragStop).toHaveBeenCalledTimes(1); // fails: 0 calls
expect(container.querySelector(".react-grid-placeholder")).toBeNull(); // fails too: the placeholder is still there
});
With each of the three events wrapped in its own act(), both assertions pass.
Expected behavior
The drag ends: onDragStop fires once and the placeholder is removed.
Suggested fix
oldDragItemRef.current is set synchronously in onDragStart and cleared in onDragStop, so it can serve as the guard:
- if (!activeDrag) return;
+ if (!oldDragItemRef.current) return;
We apply this one line as a local patch to 2.2.4, and with it the test above passes.
react-grid-layout library version
2.2.4 (the guard is unchanged on master)
Operating System Version
macOS
Browser
Chrome
Additional context
No response
Screenshots or Videos
No response
Source: react-grid-layout/react-grid-layout