diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index f600bbda5..c84860ead 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -110,6 +110,10 @@ // by default (`contextMenuItem: false`) so the base context-menu // spec keeps seeing exactly its app-supplied items. const pinAutoInject = params.get('pinautoinject') === '1'; + // Handle to the inner dockview mounted by the 'nested-dockview' + // panel (set in createComponent), so the #1495 nested-edge-group + // spec can read/serialize the inner edge group. + let nestedHandle = null; const dockview = new lib.DockviewComponent(el, { keyboardNavigation: true, overflow, @@ -164,6 +168,72 @@ { label: 'Custom Action', action: () => {} }, ], createComponent: (options) => { + // A panel that hosts its own (nested) dockview with a + // right edge group — drives the #1495 scenario where the + // host panel uses the default `onlyWhenVisible` renderer, + // so switching the outer tab detaches this content and + // fires a 0x0 resize inside the nested dockview. + if (options.name === 'nested-dockview') { + const element = document.createElement('div'); + element.className = 'dv-nested-host'; + element.style.width = '100%'; + element.style.height = '100%'; + let inner = null; + let edgeAdded = false; + return { + element, + init() { + inner = new lib.DockviewComponent(element, { + createComponent: (o) => { + const e = + document.createElement('div'); + e.className = 'dv-test-panel'; + e.textContent = o.id; + return { + element: e, + init() {}, + dispose() {}, + }; + }, + }); + inner.addPanel({ + id: 'inner-main', + component: 'default', + }); + nestedHandle = inner; + }, + // Forward the host content-area size to the inner + // dockview (as a real embedding app would). Add + // the edge group only once the inner dockview has + // a real size, so it lands at its configured + // 300px rather than being distributed to minimum. + layout(width, height) { + if (!inner) return; + inner.layout(width, height); + if (!edgeAdded && width > 0 && height > 0) { + edgeAdded = true; + inner.addEdgeGroup('right', { + id: 'inner-edge', + initialSize: 300, + }); + inner.addPanel({ + id: 'inner-edge-panel', + component: 'default', + position: { + referenceGroup: 'inner-edge', + direction: 'within', + }, + }); + } + }, + dispose() { + if (inner) inner.dispose(); + inner = null; + nestedHandle = null; + }, + }; + } + const element = document.createElement('div'); element.className = 'dv-test-panel'; element.tabIndex = 0; @@ -241,6 +311,31 @@ if (g) g.api.setHeaderPosition(position); }, closePanel: (id) => panels[id] && panels[id].api.close(), + // #1495: a panel hosting a nested dockview that has a right + // edge group. It is the only outer panel, so it stays active + // and its nested dockview lays out to a real size. + setupNestedEdgeGroup: () => { + panels['host'] = dockview.addPanel({ + id: 'host', + component: 'nested-dockview', + title: 'host', + }); + }, + // Hide/show the nested host by toggling display:none on it — + // the inner shell's offsetParent becomes null (while still in + // the document) and its ResizeObserver reports 0x0. This is + // the "host panel becomes hidden" case #1495 is about: a real + // browser fires the 0x0 that the shell's guard must skip. + setNestedHostVisible: (visible) => { + const host = document.querySelector('.dv-nested-host'); + if (host) host.style.display = visible ? '' : 'none'; + }, + // The nested dockview's right edge-group size, as serialized + // (this is the value #1495 reports resetting to the minimum). + nestedEdgeSize: () => + nestedHandle + ? nestedHandle.toJSON().edgeGroups?.right?.size + : undefined, // Add a panel, optionally splitting a new group off in a // direction (records an 'add' mutation for layout history). addPanelAt: (id, direction) => { diff --git a/e2e/tests/nested-edge-group.spec.ts b/e2e/tests/nested-edge-group.spec.ts new file mode 100644 index 000000000..59abe92ef --- /dev/null +++ b/e2e/tests/nested-edge-group.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '@playwright/test'; + +/** + * #1495: a dockview nested inside another dockview's panel, with an edge group. + * When the host panel becomes hidden the nested dockview's shell collapses to + * 0x0 and its ResizeObserver fires — the shell's guard must skip that so the + * edge group keeps its size instead of being clamped to the minimum. + * + * Real-browser only: the bug rides on a real ResizeObserver firing a 0x0 + * measurement when an ancestor is hidden (offsetParent -> null) — neither of + * which jsdom models. The guard is additionally unit-/integration-tested in + * dockviewShell.spec.ts and edgeGroupHiddenHost.spec.ts. + * + * The host is hidden via `display: none` (the canonical "ancestor hidden" form + * of `onlyWhenVisible` deactivation) rather than a tab switch, because a real + * browser does not fire a ResizeObserver for an element that is fully removed + * from the DOM — so only the display path reproduces the reported clamp. + */ +test.describe('nested edge group (#1495)', () => { + const edgeSize = (page) => + page.evaluate(() => (window as any).__dv.nestedEdgeSize()); + + test('keeps its size when the host is hidden and reshown', async ({ + page, + }) => { + await page.goto('/e2e/fixtures/index.html'); + await page.waitForFunction(() => (window as any).__ready === true); + await page.evaluate(() => (window as any).__dv.setupNestedEdgeGroup()); + + // The nested edge group is added on the inner dockview's first real + // layout, sized well above its ~85 minimum. + await expect.poll(() => edgeSize(page)).toBe(300); + + // Hide the host: the nested shell's ResizeObserver fires a 0x0 (rAF + // deferred), which without the guard clamps the edge group. + await page.evaluate(() => (window as any).__dv.setNestedHostVisible(false)); + await page.waitForTimeout(250); + expect(await edgeSize(page)).toBe(300); + + // Reveal it again — still the original size, not the clamped minimum. + await page.evaluate(() => (window as any).__dv.setNestedHostVisible(true)); + await page.waitForTimeout(250); + expect(await edgeSize(page)).toBe(300); + }); +}); diff --git a/packages/dockview-core/src/__tests__/dockview/edgeGroupHiddenHost.spec.ts b/packages/dockview-core/src/__tests__/dockview/edgeGroupHiddenHost.spec.ts new file mode 100644 index 000000000..f4607c696 --- /dev/null +++ b/packages/dockview-core/src/__tests__/dockview/edgeGroupHiddenHost.spec.ts @@ -0,0 +1,145 @@ +import { DockviewComponent } from '../../dockview/dockviewComponent'; + +/** + * Integration coverage for #1495 through the real DockviewComponent + shell + * wiring (the unit-level guard is exercised directly in dockviewShell.spec.ts). + * + * Reproduces the reported scenario at the component seam: a dockview with an + * edge group whose host shell becomes hidden (as happens when a nested + * dockview's `onlyWhenVisible` outer panel is deactivated and its content is + * detached). The shell's own ResizeObserver then fires a 0x0 measurement; the + * guard must skip it so the edge group keeps its size instead of being clamped + * to the minimum. + * + * jsdom neither fires ResizeObserver nor computes offsetParent, so both are + * driven explicitly here — the same technique used in resizable.spec.ts. + */ +class TestPanel { + readonly element = document.createElement('div'); + init(): void { + /* noop */ + } + layout(): void { + /* noop */ + } + dispose(): void { + /* noop */ + } +} + +describe('edge group size preservation when the host shell is hidden (#1495)', () => { + let container: HTMLElement; + let observers: Array<{ el: Element; cb: (entries: any[]) => void }>; + let rAFCallbacks: FrameRequestCallback[]; + let originalResizeObserver: typeof window.ResizeObserver; + + beforeEach(() => { + observers = []; + rAFCallbacks = []; + + originalResizeObserver = window.ResizeObserver; + (window as any).ResizeObserver = class { + private readonly _cb: (entries: any[]) => void; + constructor(cb: (entries: any[]) => void) { + this._cb = cb; + } + observe(el: Element): void { + observers.push({ el, cb: this._cb }); + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + }; + + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rAFCallbacks.push(cb); + return rAFCallbacks.length; + }); + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + jest.restoreAllMocks(); + container.remove(); + }); + + function flushRAF(): void { + const pending = [...rAFCallbacks]; + rAFCallbacks = []; + for (const cb of pending) { + cb(performance.now()); + } + } + + // Fire a resize only for the observer watching `el` (the shell installs its + // own observer on the shell element; other observers must not be triggered). + function fireResizeFor(el: Element, width: number, height: number): void { + for (const entry of observers) { + if (entry.el === el) { + entry.cb([{ contentRect: { width, height }, target: el }]); + } + } + flushRAF(); + } + + function setOffsetParent(el: HTMLElement, value: Element | null): void { + // jsdom always reports null; drive it so the guard sees the shell as + // visible (an element) or hidden by an ancestor's display:none (null). + Object.defineProperty(el, 'offsetParent', { + configurable: true, + get: () => value, + }); + } + + test('keeps its size when the shell is hidden then reshown', () => { + const dockview = new DockviewComponent(container, { + createComponent: () => new TestPanel(), + }); + + dockview.layout(1000, 800); + dockview.addPanel({ id: 'main', component: 'default' }); + + // A right edge group, expanded and visible, sized to 300 — well above + // its ~85 expanded minimum, so a clamp would be plainly visible. Adding + // it into the already-laid-out 1000px shell leaves it at exactly 300. + dockview.addEdgeGroup('right', { id: 'edge', initialSize: 300 }); + dockview.addPanel({ + id: 'edgePanel', + component: 'default', + position: { referenceGroup: 'edge', direction: 'within' }, + }); + + // An expanded, visible edge group serializes its live splitview size, so + // toJSON reflects any clamp the 0x0 layout would cause. + const sizeBefore = dockview.toJSON().edgeGroups?.right?.size; + expect(sizeBefore).toBe(300); + + // Prime the shell observer with a real measurement while visible, so its + // cached width/height are non-zero — otherwise the later 0x0 event is + // absorbed by the unchanged-size early-return before reaching the guard. + const shell = dockview.rootElement; + setOffsetParent(shell, document.body); + fireResizeFor(shell, 1000, 800); + expect(dockview.toJSON().edgeGroups?.right?.size).toBe(300); + + // Host deactivates: the shell loses its offsetParent (ancestor hidden) + // and its ResizeObserver reports 0x0. + setOffsetParent(shell, null); + fireResizeFor(shell, 0, 0); + + expect(dockview.toJSON().edgeGroups?.right?.size).toBe(300); + + // Reactivating lays the shell back out at its real size — still 300. + setOffsetParent(shell, document.body); + fireResizeFor(shell, 1000, 800); + expect(dockview.toJSON().edgeGroups?.right?.size).toBe(300); + + dockview.dispose(); + }); +});