onDragStop is dropped when the release arrives before the threshold-crossing move renders (placeholder stays on the grid)

Author: NichlasBrownieCreated Sep 14, 2026Updated Sep 14, 2026

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 onDragStop prop 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 while activeDrag is 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