From 163f19a852218c906c48c70f7beeff28f486f227 Mon Sep 17 00:00:00 2001 From: mathuo Date: Mon, 20 Jul 2026 21:35:05 +0000 Subject: [PATCH] fix(core): preserve edge group size when nested dockview host is hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ShellManager installs its own ResizeObserver on the shell element to drive the edge-group splitview layout. Unlike the Resizable base class, it did not guard against the element being hidden or detached from the DOM. When a nested dockview's `onlyWhenVisible` host panel is deactivated the shell element collapses to (0, 0), so the observer laid the splitview out at zero and clamped the low-priority edge groups down to their minimum size — losing the user's sizing when the panel was later reactivated. Skip resize events when the shell has no offsetParent (display: none on it or an ancestor) or is no longer in the document, mirroring the existing guard in Resizable. Fixes #1495 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018axBKCULSaEET94Tefd1Ru --- .../__tests__/dockview/dockviewShell.spec.ts | 120 ++++++++++++++++++ .../src/dockview/dockviewShell.ts | 24 +++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts index b2978048c..9a849a349 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewShell.spec.ts @@ -792,4 +792,124 @@ describe('ShellManager', () => { shell.dispose(); }); }); + + describe('resize observer visibility guard (#1495)', () => { + // Reproduces #1495: a nested dockview whose `onlyWhenVisible` host + // panel is deactivated has its shell element hidden/detached, which + // fires a (0, 0) resize. Without a visibility guard that zero size is + // propagated to the edge-group splitview, clamping the edge group to + // its minimum size and losing the user's sizing. + let observerCallbacks: Array<(entries: any[]) => void>; + let rAFCallbacks: FrameRequestCallback[]; + let originalResizeObserver: typeof window.ResizeObserver; + + beforeEach(() => { + observerCallbacks = []; + rAFCallbacks = []; + + originalResizeObserver = window.ResizeObserver; + (window as any).ResizeObserver = class { + constructor(cb: (entries: any[]) => void) { + observerCallbacks.push(cb); + } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + }; + + jest.spyOn(window, 'requestAnimationFrame').mockImplementation( + (cb) => { + rAFCallbacks.push(cb); + return rAFCallbacks.length; + } + ); + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + jest.restoreAllMocks(); + }); + + function fireResize(width: number, height: number): void { + for (const cb of observerCallbacks) { + cb([{ contentRect: { width, height } }]); + } + const pending = [...rAFCallbacks]; + rAFCallbacks = []; + for (const cb of pending) { + cb(performance.now()); + } + } + + function setVisible(shell: ShellManager, visible: boolean): void { + // jsdom does not compute offsetParent; drive it explicitly so the + // guard sees the shell as hidden (null) or visible (an element). + Object.defineProperty(shell.element, 'offsetParent', { + configurable: true, + get: () => (visible ? document.body : null), + }); + } + + // Build a shell with a real layout and an edge group sized to `size` + // (as an established sash drag would leave it), returning the shell. + function makeSizedShell( + position: 'left' | 'right', + size: number + ): ShellManager { + const shell = new ShellManager( + container, + dockviewElement, + layoutGrid + ); + setVisible(shell, true); + fireResize(1000, 800); + shell.addEdgeView(position, { id: position }, makeGroup()); + const splitview = (shell as any)._outerSplitview; + const index = + position === 'left' + ? (shell as any)._leftIndex + : (shell as any)._rightIndex; + splitview.resizeView(index, size); + return shell; + } + + test('preserves the edge group size when the shell is hidden', () => { + const shell = makeSizedShell('right', 300); + expect(shell.toJSON().right!.size).toBe(300); + + // Host panel deactivates: element hidden → (0, 0) resize fires. + setVisible(shell, false); + fireResize(0, 0); + + // The size must be preserved, not clamped to the minimum. + expect(shell.toJSON().right!.size).toBe(300); + + // Reactivating restores the same layout without any size change. + setVisible(shell, true); + fireResize(1000, 800); + expect(shell.toJSON().right!.size).toBe(300); + + shell.dispose(); + }); + + test('does not lay out at zero when detached from the document', () => { + const shell = makeSizedShell('left', 250); + expect(shell.toJSON().left!.size).toBe(250); + + // offsetParent stays truthy but the element leaves the document — + // still a hidden/meaningless (0, 0) measurement. + shell.element.remove(); + fireResize(0, 0); + + expect(shell.toJSON().left!.size).toBe(250); + + shell.dispose(); + }); + }); }); diff --git a/packages/dockview-core/src/dockview/dockviewShell.ts b/packages/dockview-core/src/dockview/dockviewShell.ts index 5541cd4df..3a7206ee9 100644 --- a/packages/dockview-core/src/dockview/dockviewShell.ts +++ b/packages/dockview-core/src/dockview/dockviewShell.ts @@ -6,7 +6,7 @@ import { Orientation, Splitview, } from '../splitview/splitview'; -import { watchElementResize } from '../dom'; +import { isInDocument, watchElementResize } from '../dom'; export type EdgeGroupPosition = 'top' | 'bottom' | 'left' | 'right'; @@ -490,6 +490,28 @@ export class ShellManager implements IDisposable { this._disposables.addDisposables( watchElementResize(this._shellElement, (entry) => { + /** + * When the shell (or an ancestor) becomes hidden — e.g. a + * nested dockview whose `onlyWhenVisible` host panel is + * deactivated — the element collapses to (0, 0) or is detached + * from the DOM. Propagating that zero size would relayout the + * edge-group splitview at 0, clamping the low-priority edge + * groups down to their minimum size and destroying the user's + * sizing. Skip these cases so sizes are preserved while hidden; + * mirrors the guard in the `Resizable` base class. See #1495. + * + * offsetParent === null is equivalent to display: none on the + * element or one of its ancestors. + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent + */ + if (!this._shellElement.offsetParent) { + return; + } + + if (!isInDocument(this._shellElement)) { + return; + } + const width = Math.round(entry.contentRect.width); const height = Math.round(entry.contentRect.height); if (