Skip to content

Commit d54efbe

Browse files
authored
Merge pull request #3410 from adumesny/master
(react) drag-in widget id fix
2 parents c043102 + acc5386 commit d54efbe

3 files changed

Lines changed: 56 additions & 0 deletions

File tree

‎doc/CHANGES.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Change log
159159
[title_drag.html](https://gridstackjs.com/demo/title_drag.html).
160160
* fix: [#3188](https://github.com/gridstack/gridstack.js/issues/3188) release the global touch latch when a widget is destroyed mid-touch
161161
* fix: [#2953](https://github.com/gridstack/gridstack.js/issues/2953) API update() honors maxRow like dragging does
162+
* fix: [#2976](https://github.com/gridstack/gridstack.js/issues/2976) (react): render a drag-in widget that has a component but no id
162163
* fix: [#2703](https://github.com/gridstack/gridstack.js/issues/2703) allow dragging from elements nested inside a button/input handle
163164

164165
## 13.3.0 (2026-09-11)

‎react/projects/lib/gridstack-react.test.tsx‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,50 @@ describe("GridStack React wrapper", () => {
255255
// ...and its React-rendered content must have followed, not been unmounted.
256256
expect(document.querySelector("[data-testid=\"portal\"]")?.textContent).toBe("hello");
257257
});
258+
259+
it("#2976 renders a sidebar drag-in widget that has a component but no id", async () => {
260+
await act(async () => {
261+
root.render(
262+
<GridStack
263+
options={{ column: 12, cellHeight: 50, margin: 0, children: [] }}
264+
components={{
265+
Side: (p: Record<string, unknown>) => (
266+
<span data-testid="dragin">{String(p.label ?? "")}</span>
267+
),
268+
}}
269+
/>
270+
);
271+
});
272+
await act(flush);
273+
274+
const gridEl = container.querySelector(".grid-stack") as GridHTMLElement;
275+
const g = gridEl?.gridstack as GridStackInstance;
276+
expect(g).toBeTruthy();
277+
278+
// What GS core does at the end of its `drop` handler for a sidebar item
279+
// (gridstack.ts: `el = this.addWidget(node)`): the spec comes straight from
280+
// GridStack.setupDragIn(..., widgets) so it has NO id of its own.
281+
await act(async () => {
282+
g.addWidget({ w: 2, h: 2, component: "Side", props: { label: "dropped" } } as GridStackWidget);
283+
});
284+
await act(flush);
285+
286+
expect(
287+
document.querySelector('[data-testid="dragin"]')?.textContent
288+
).toBe("dropped");
289+
290+
// a second drop of the same spec must get its own portal, not collide with the first
291+
await act(async () => {
292+
g.addWidget({ w: 2, h: 2, component: "Side", props: { label: "again" } } as GridStackWidget);
293+
});
294+
await act(flush);
295+
const both = Array.from(document.querySelectorAll('[data-testid="dragin"]')).map(e => e.textContent);
296+
expect(both.sort()).toEqual(["again", "dropped"]);
297+
298+
// and the minted id round-trips through save() so layouts stay reloadable
299+
const saved = g.save(false) as GridStackWidget[];
300+
expect(saved.length).toBe(2);
301+
expect(saved.every(w => !!w.id)).toBe(true);
302+
expect(new Set(saved.map(w => w.id)).size).toBe(2);
303+
});
258304
});

‎react/projects/lib/src/registry.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import type {
1010
GridStackWidget,
1111
} from "./types";
1212

13+
/** @internal minted ids for widgets that arrive without one (sidebar drag-in) - see gsCreateReactComponents */
14+
let syntheticIdSeq = 0;
15+
1316
export function installGridStackReactCallbacks(): void {
1417
if (!GridStack.addRemoveCB) {
1518
GridStack.addRemoveCB = gsCreateReactComponents;
@@ -60,6 +63,12 @@ export function gsCreateReactComponents(
6063
const el = Utils.createDiv(itemClasses) as GridItemHTMLElement;
6164
Utils.createDiv(["grid-stack-item-content"], el);
6265

66+
// Widgets dropped in from a sidebar (GridStack.setupDragIn) carry no id - the same spec is
67+
// dropped over and over, so it can't have one - yet the portal that renders `component` is
68+
// keyed by id. Without one we used to silently skip rendering and leave an empty item (#2976).
69+
// `w` is the object GS turns into the node, so writing it back sticks for save()/updateCB too.
70+
if (!w.id && w.component) w.id = `gs-react-${++syntheticIdSeq}`;
71+
6372
const id = w.id;
6473
if (id) {
6574
el._gridItemRef = { id, gridComp: gridHost };

0 commit comments

Comments
 (0)