Skip to content

Commit 1bf2d79

Browse files
authored
Merge pull request #3377 from adumesny/master
vue, react: fix item content vanishing when dragging between 2 grids
2 parents 7da3f54 + 1574bb7 commit 1bf2d79

6 files changed

Lines changed: 189 additions & 7 deletions

File tree

doc/CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ Change log
150150
* feat: [#2781](https://github.com/gridstack/gridstack.js/issues/3177) [#2781](https://github.com/gridstack/gridstack.js/issues/3177) mobile: pause to drag/reszie vs scroll behavior
151151
* fix: [#3374](https://github.com/gridstack/gridstack.js/issues/3374) use `moveBefore()` (when supported) instead of `appendChild()` in `_sortDom()` so reordering doesn't reload iframes / lose element state
152152
* fix: [#934](https://github.com/gridstack/gridstack.js/issues/934) disable pointer events on iframes while dragging/resizing so fast mouse moves aren't swallowed by the iframe's own document
153+
* fix: [#3371](https://github.com/gridstack/gridstack.js/issues/3371) (vue, react): item content vanished after dragging between 2 grids with `acceptWidgets`
153154

154155
## 13.2.0 (2026-08-19)
155156
* feat: [#701](https://github.com/gridstack/gridstack.js/issues/701) removed printMode as we support much better printing now that doesn't compromise.

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

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { createRoot, type Root } from "react-dom/client";
33
import { act } from "react";
44
import { useState } from "react";
5-
import type { GridHTMLElement } from "gridstack";
5+
import type { GridHTMLElement, GridItemHTMLElement, GridStack as GridStackInstance } from "gridstack";
66
import type { GridStackWidget } from "gridstack";
77
import { GridStack } from "./src/gridstack";
88
import { useWidgetSerializer } from "./src/hooks";
@@ -13,6 +13,32 @@ function flush(): Promise<void> {
1313
});
1414
}
1515

16+
/**
17+
* Replicates the DOM/engine bookkeeping GS core does in the `drop` handler when an
18+
* item is dragged from one live grid into another (gridstack.ts `.on(this.el, 'drop', ...)`):
19+
* the item element is reused via `appendChild` (no `addRemoveCB` call) and the node object
20+
* is moved from one engine's node list to the other's. Used to reproduce cross-grid DnD
21+
* without driving the real pointer-based drag/drop implementation through jsdom.
22+
*/
23+
function simulateCrossGridDrop(from: GridStackInstance, to: GridStackInstance, el: GridItemHTMLElement) {
24+
const node = el.gridstackNode!;
25+
const f = from as unknown as { engine: { nodes: unknown[]; removedNodes: unknown[]; removeNodeFromLayoutCache(n: unknown): void }; _triggerRemoveEvent(): unknown; _triggerChangeEvent(): unknown };
26+
const t = to as unknown as { engine: { nodes: unknown[]; addedNodes: unknown[] }; el: HTMLElement; _triggerAddEvent(): unknown; _triggerChangeEvent(): unknown };
27+
28+
f.engine.removeNodeFromLayoutCache(node);
29+
f.engine.nodes = f.engine.nodes.filter((n) => n !== node);
30+
f.engine.removedNodes.push(node);
31+
f._triggerRemoveEvent();
32+
f._triggerChangeEvent();
33+
34+
node.grid = to;
35+
t.engine.nodes.push(node);
36+
t.el.appendChild(el);
37+
t.engine.addedNodes.push(node);
38+
t._triggerAddEvent();
39+
t._triggerChangeEvent();
40+
}
41+
1642
function Num({ start }: { start: number }) {
1743
const [n] = useState(start);
1844
useWidgetSerializer({ serialize: () => ({ extra: n }) });
@@ -180,4 +206,53 @@ describe("GridStack React wrapper", () => {
180206
await act(flush);
181207
expect(container.querySelector(".grid-stack")).toBeNull();
182208
});
209+
210+
it("keeps component content rendered after cross-grid drag & drop (#3371)", async () => {
211+
const T = (p: Record<string, unknown>) => (
212+
<span data-testid="portal">{String(p.label ?? "")}</span>
213+
);
214+
215+
await act(async () => {
216+
root.render(
217+
<>
218+
<GridStack
219+
options={{
220+
column: 12,
221+
cellHeight: 50,
222+
margin: 0,
223+
acceptWidgets: true,
224+
children: [
225+
{ id: "a1", x: 0, y: 0, w: 2, h: 2, component: "T", props: { label: "hello" } },
226+
],
227+
}}
228+
components={{ T }}
229+
/>
230+
<GridStack
231+
options={{ column: 12, cellHeight: 50, margin: 0, acceptWidgets: true, children: [] }}
232+
components={{ T }}
233+
/>
234+
</>
235+
);
236+
});
237+
await act(flush);
238+
239+
expect(document.querySelector("[data-testid=\"portal\"]")?.textContent).toBe("hello");
240+
241+
const [gridElA, gridElB] = Array.from(
242+
container.querySelectorAll(".grid-stack")
243+
) as GridHTMLElement[];
244+
const gridA = gridElA.gridstack!;
245+
const gridB = gridElB.gridstack!;
246+
const itemEl = gridElA.querySelector(".grid-stack-item") as GridItemHTMLElement;
247+
248+
await act(async () => {
249+
simulateCrossGridDrop(gridA, gridB, itemEl);
250+
});
251+
await act(flush);
252+
253+
// The item's DOM element physically moved into grid B's container...
254+
expect(gridElB.contains(itemEl)).toBe(true);
255+
// ...and its React-rendered content must have followed, not been unmounted.
256+
expect(document.querySelector("[data-testid=\"portal\"]")?.textContent).toBe("hello");
257+
});
183258
});

react/projects/lib/src/gridstack.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { GridStackItem } from "./gridstack-item";
2020
import { installGridStackReactCallbacks } from "./registry";
2121
import type {
2222
GridStackHostApi,
23+
GridStackNode,
2324
GridStackOptions,
2425
GridStackWidget,
2526
GridHTMLElement,
@@ -242,6 +243,19 @@ export const GridStackComponent = forwardRef<GridStackHandle, GridStackProps>(
242243
const addedHandler: GridStackNodesHandler = (e, nodes) => {
243244
bumpLayout();
244245
setIsEmpty(false);
246+
// Cross-grid DnD: GS reuses the existing item element (no addRemoveCB call), so
247+
// `_gridItemRef.gridComp` still points at the source grid. Transfer portal
248+
// ownership to this grid or the React subtree gets unmounted (item content vanishes).
249+
nodes.forEach((node) => {
250+
const n = node as GridStackNode;
251+
const el = n.el as GridItemHTMLElement | undefined;
252+
const ref = el?._gridItemRef;
253+
if (n.component && ref && ref.gridComp !== hostApiRef.current) {
254+
ref.gridComp.unregisterSyntheticItemId(ref.id);
255+
hostApiRef.current.registerSyntheticItemId(ref.id);
256+
el._gridItemRef = { id: ref.id, gridComp: hostApiRef.current };
257+
}
258+
});
245259
onAdded?.(e, nodes);
246260
};
247261
g.on("added", addedHandler);

react/vitest.config.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import { fileURLToPath, URL } from 'node:url'
55
export default defineConfig({
66
plugins: [react()],
77
resolve: {
8-
alias: {
9-
'gridstack/dist/react': fileURLToPath(
10-
new URL('./projects/lib/src/index.ts', import.meta.url)
11-
),
12-
},
8+
alias: [
9+
{
10+
find: 'gridstack/dist/react',
11+
replacement: fileURLToPath(new URL('./projects/lib/src/index.ts', import.meta.url)),
12+
},
13+
{
14+
find: /^gridstack$/,
15+
replacement: fileURLToPath(new URL('../src/gridstack.ts', import.meta.url)),
16+
},
17+
],
1318
},
1419
test: {
1520
environment: 'jsdom',

vue/projects/lib/gridstack-vue.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, afterEach } from 'vitest'
22
import { createApp, defineComponent, h, ref, type App } from 'vue'
3-
import type { GridHTMLElement } from 'gridstack'
3+
import type { GridHTMLElement, GridItemHTMLElement, GridStack as GridStackInstance } from 'gridstack'
44
import type { GridStackWidget } from './src/types'
55
import { GridStack } from './src/gridstack'
66
import { useWidgetSerializer } from './src/composables'
@@ -20,6 +20,32 @@ function mountApp(component: ReturnType<typeof defineComponent> | ReturnType<typ
2020
return { app, container }
2121
}
2222

23+
/**
24+
* Replicates the DOM/engine bookkeeping GS core does in the `drop` handler when an
25+
* item is dragged from one live grid into another (gridstack.ts `.on(this.el, 'drop', ...)`):
26+
* the item element is reused via `appendChild` (no `addRemoveCB` call) and the node object
27+
* is moved from one engine's node list to the other's. Used to reproduce cross-grid DnD
28+
* without driving the real pointer-based drag/drop implementation through jsdom.
29+
*/
30+
function simulateCrossGridDrop(from: GridStackInstance, to: GridStackInstance, el: GridItemHTMLElement) {
31+
const node = el.gridstackNode!
32+
const f = from as unknown as { engine: { nodes: unknown[]; removedNodes: unknown[]; removeNodeFromLayoutCache(n: unknown): void }; _triggerRemoveEvent(): unknown; _triggerChangeEvent(): unknown }
33+
const t = to as unknown as { engine: { nodes: unknown[]; addedNodes: unknown[] }; el: HTMLElement; _triggerAddEvent(): unknown; _triggerChangeEvent(): unknown }
34+
35+
f.engine.removeNodeFromLayoutCache(node)
36+
f.engine.nodes = f.engine.nodes.filter((n) => n !== node)
37+
f.engine.removedNodes.push(node)
38+
f._triggerRemoveEvent()
39+
f._triggerChangeEvent()
40+
41+
node.grid = to
42+
t.engine.nodes.push(node)
43+
t.el.appendChild(el)
44+
t.engine.addedNodes.push(node)
45+
t._triggerAddEvent()
46+
t._triggerChangeEvent()
47+
}
48+
2349
describe('GridStack Vue wrapper', () => {
2450
let app: App
2551
let container: HTMLDivElement
@@ -159,4 +185,51 @@ describe('GridStack Vue wrapper', () => {
159185

160186
expect(container.querySelector('.grid-stack')).toBeNull()
161187
})
188+
189+
it('keeps component content rendered after cross-grid drag & drop (#3371)', async () => {
190+
const T = defineComponent({
191+
props: { label: { type: String, default: '' } },
192+
setup(props) {
193+
return () => h('span', { 'data-testid': 'portal' }, props.label)
194+
},
195+
})
196+
197+
const Root = defineComponent({
198+
setup() {
199+
const optionsA = {
200+
column: 12, cellHeight: 50, margin: 0, acceptWidgets: true,
201+
children: [
202+
{ id: 'a1', x: 0, y: 0, w: 2, h: 2, component: 'T', props: { label: 'hello' } },
203+
],
204+
}
205+
const optionsB = {
206+
column: 12, cellHeight: 50, margin: 0, acceptWidgets: true,
207+
children: [] as GridStackWidget[],
208+
}
209+
return () =>
210+
h('div', [
211+
h(GridStack, { options: optionsA, components: { T } }),
212+
h(GridStack, { options: optionsB, components: { T } }),
213+
])
214+
},
215+
})
216+
217+
;({ app, container } = mountApp(Root))
218+
await flush()
219+
220+
expect(document.querySelector('[data-testid="portal"]')?.textContent).toBe('hello')
221+
222+
const [gridElA, gridElB] = Array.from(container.querySelectorAll('.grid-stack')) as GridHTMLElement[]
223+
const gridA = gridElA.gridstack!
224+
const gridB = gridElB.gridstack!
225+
const itemEl = gridElA.querySelector('.grid-stack-item') as GridItemHTMLElement
226+
227+
simulateCrossGridDrop(gridA, gridB, itemEl)
228+
await flush()
229+
230+
// The item's DOM element physically moved into grid B's container...
231+
expect(gridElB.contains(itemEl)).toBe(true)
232+
// ...and its Vue-rendered content must have followed, not been unmounted.
233+
expect(document.querySelector('[data-testid="portal"]')?.textContent).toBe('hello')
234+
})
162235
})

vue/projects/lib/src/gridstack.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
GridHTMLElement,
2323
GridItemHTMLElement,
2424
GridStackHostApi,
25+
GridStackNode,
2526
GridStackOptions,
2627
GridStackWidget,
2728
} from './types'
@@ -208,6 +209,19 @@ export const GridStackComponent = defineComponent({
208209
grid.on('added', ((e: Event, nodes: Parameters<GridStackNodesHandler>[1]) => {
209210
layoutVersion.value++
210211
isEmpty.value = false
212+
// Cross-grid DnD: GS reuses the existing item element (no addRemoveCB call), so
213+
// `_gridItemRef.gridComp` still points at the source grid. Transfer teleport
214+
// ownership to this grid or the Vue subtree gets unmounted (item content vanishes).
215+
nodes.forEach((node) => {
216+
const n = node as GridStackNode
217+
const el = n.el as GridItemHTMLElement | undefined
218+
const ref = el?._gridItemRef
219+
if (n.component && ref && ref.gridComp !== hostApi) {
220+
ref.gridComp.unregisterSyntheticItemId(ref.id)
221+
hostApi.registerSyntheticItemId(ref.id)
222+
el._gridItemRef = { id: ref.id, gridComp: hostApi }
223+
}
224+
})
211225
emit('added', e, nodes)
212226
}) as GridStackNodesHandler)
213227

0 commit comments

Comments
 (0)