From f3d8c1ae0536dad7630e34b5fae0025b52ff3a1e Mon Sep 17 00:00:00 2001 From: davidgodinez Date: Tue, 16 Jun 2026 13:38:09 -0600 Subject: [PATCH 1/4] fix: DH-21257: Check if panel parent is in root when opening --- plugins/ui/src/js/src/layout/ReactPanel.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx index c81e94589..c5db86dc6 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx @@ -192,7 +192,18 @@ function ReactPanel({ isClosable, }; - // If we didn't find it, we still want to open it in the same place in the layout as before (parent stack) instead of opening at the root + // If we didn't find it, we still want to open it in the same place in the layout as before (parent stack) instead of opening at the root. + // Check if the parent is still attached to the root. If not, add it back to the root first. + let itemInLayout: typeof parent | null = parent; + while (itemInLayout != null && itemInLayout !== root) { + itemInLayout = itemInLayout.parent; + } + if (itemInLayout === null) { + // Root can only have one direct child (a row/column container), so add to that instead + const rootChild = + root.contentItems.length > 0 ? root.contentItems[0] : root; + rootChild.addChild(parent); + } LayoutUtils.openComponent({ root: parent, config }); log.debug('Opened panel', panelId, config); } else if (openedMetadataRef.current !== metadata) { From 2ddeb611ca0dc371749640402e507c3fc5ad194c Mon Sep 17 00:00:00 2001 From: davidgodinez Date: Tue, 16 Jun 2026 13:44:45 -0600 Subject: [PATCH 2/4] unit test --- .../ui/src/js/src/layout/ReactPanel.test.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx index 7fd8f0fcf..c9f6196f5 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx @@ -146,6 +146,29 @@ it('finds and closes existing panels from the layout root, but opens in the pare }); }); +it('re-attaches a detached parent to the root before opening the panel', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + // A parent that is detached from the root (parent.parent === null) + const parent = TestUtils.createMockProxy({ parent: null }); + + render( + + {makeTestComponent({ onOpen, onClose })} + + ); + + const { root } = (useLayoutManager as jest.Mock).mock.results[0].value; + + // Root has no children in the mock, so addChild should be called on root itself + expect(root.addChild).toHaveBeenCalledWith(parent); + // Panel should still open in the parent stack after re-attachment + expect(LayoutUtils.openComponent).toHaveBeenCalledTimes(1); + expect(LayoutUtils.openComponent).toHaveBeenCalledWith( + expect.objectContaining({ root: parent }) + ); +}); + it('only calls open once if the panel has not closed and only children change', () => { const onOpen = jest.fn(); const onClose = jest.fn(); From 2cf8002a03a7a5423f1b88f5e67da0fbb26d4044 Mon Sep 17 00:00:00 2001 From: davidgodinez Date: Tue, 16 Jun 2026 15:10:25 -0600 Subject: [PATCH 3/4] attach topmost --- .../ui/src/js/src/layout/ReactPanel.test.tsx | 29 ++++++++++++++++++- plugins/ui/src/js/src/layout/ReactPanel.tsx | 19 +++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx index c9f6196f5..573c11277 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.test.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.test.tsx @@ -160,7 +160,7 @@ it('re-attaches a detached parent to the root before opening the panel', () => { const { root } = (useLayoutManager as jest.Mock).mock.results[0].value; - // Root has no children in the mock, so addChild should be called on root itself + // parent is the topmost detached ancestor; root has no children so addChild is called on root expect(root.addChild).toHaveBeenCalledWith(parent); // Panel should still open in the parent stack after re-attachment expect(LayoutUtils.openComponent).toHaveBeenCalledTimes(1); @@ -169,6 +169,33 @@ it('re-attaches a detached parent to the root before opening the panel', () => { ); }); +it('re-attaches the topmost detached ancestor to the root before opening the panel', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + // parent is a stack inside a detached row (grandparent.parent === null) + const grandparent = TestUtils.createMockProxy({ parent: null }); + const parent = TestUtils.createMockProxy({ + parent: grandparent, + }); + + render( + + {makeTestComponent({ onOpen, onClose })} + + ); + + const { root } = (useLayoutManager as jest.Mock).mock.results[0].value; + + // The topmost detached ancestor (grandparent) should be re-added, not just parent + expect(root.addChild).toHaveBeenCalledWith(grandparent); + expect(root.addChild).not.toHaveBeenCalledWith(parent); + // Panel should still open in the original parent (not grandparent) + expect(LayoutUtils.openComponent).toHaveBeenCalledTimes(1); + expect(LayoutUtils.openComponent).toHaveBeenCalledWith( + expect.objectContaining({ root: parent }) + ); +}); + it('only calls open once if the panel has not closed and only children change', () => { const onOpen = jest.fn(); const onClose = jest.fn(); diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx index c5db86dc6..42f5bea78 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx @@ -193,16 +193,21 @@ function ReactPanel({ }; // If we didn't find it, we still want to open it in the same place in the layout as before (parent stack) instead of opening at the root. - // Check if the parent is still attached to the root. If not, add it back to the root first. - let itemInLayout: typeof parent | null = parent; - while (itemInLayout != null && itemInLayout !== root) { - itemInLayout = itemInLayout.parent; + // Walk up from parent, tracking the topmost item seen, to determine in one pass whether the parent + // is detached and, if so, which ancestor to re-add. Re-adding the topmost detached ancestor (e.g. a + // row containing multiple stacks) better preserves the layout and ensures stack headers are rendered. + let topDetached: typeof parent = parent; + let cursor: typeof parent | null = parent.parent; + while (cursor != null && cursor !== root) { + topDetached = cursor; + cursor = cursor.parent; } - if (itemInLayout === null) { - // Root can only have one direct child (a row/column container), so add to that instead + if (cursor === null) { + // cursor reached null without hitting root, so the parent is detached. + // Root can only have one direct child (a row/column container), so add to that instead. const rootChild = root.contentItems.length > 0 ? root.contentItems[0] : root; - rootChild.addChild(parent); + rootChild.addChild(topDetached); } LayoutUtils.openComponent({ root: parent, config }); log.debug('Opened panel', panelId, config); From 0379d8153ec164e3bea2a9d8994c45e6a1730ae8 Mon Sep 17 00:00:00 2001 From: davidgodinez Date: Wed, 17 Jun 2026 07:26:46 -0600 Subject: [PATCH 4/4] refactor --- plugins/ui/src/js/src/layout/ReactPanel.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/ui/src/js/src/layout/ReactPanel.tsx b/plugins/ui/src/js/src/layout/ReactPanel.tsx index 42f5bea78..d9442e54f 100644 --- a/plugins/ui/src/js/src/layout/ReactPanel.tsx +++ b/plugins/ui/src/js/src/layout/ReactPanel.tsx @@ -197,13 +197,13 @@ function ReactPanel({ // is detached and, if so, which ancestor to re-add. Re-adding the topmost detached ancestor (e.g. a // row containing multiple stacks) better preserves the layout and ensures stack headers are rendered. let topDetached: typeof parent = parent; - let cursor: typeof parent | null = parent.parent; - while (cursor != null && cursor !== root) { - topDetached = cursor; - cursor = cursor.parent; + let currentParent: typeof parent | null = parent.parent; + while (currentParent != null && currentParent !== root) { + topDetached = currentParent; + currentParent = currentParent.parent; } - if (cursor === null) { - // cursor reached null without hitting root, so the parent is detached. + if (currentParent === null) { + // currentParent reached null without hitting root, so the parent is detached. // Root can only have one direct child (a row/column container), so add to that instead. const rootChild = root.contentItems.length > 0 ? root.contentItems[0] : root;