From 97e8e7db805c82402b5256223ddca3f6cc809a20 Mon Sep 17 00:00:00 2001 From: mathuo Date: Tue, 21 Jul 2026 08:05:14 +0000 Subject: [PATCH] test(core): add integration + e2e coverage for edge group hidden-host guard (#1495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two higher-level tests for the shell resize-observer visibility guard that preserves an edge group's size when its host is hidden (the unit-level guard already lives in dockviewShell.spec.ts). - edgeGroupHiddenHost.spec.ts (jsdom): drives a real DockviewComponent with an expanded right edge group, fires the shell ResizeObserver at 0x0 with the shell hidden (offsetParent null), and asserts the serialized size is preserved rather than clamped to the minimum. Fails without the guard (85 vs 300). - nested-edge-group.spec.ts (Playwright): a dockview nested inside a host panel with a right edge group. Hiding the host via display:none fires a real 0x0 ResizeObserver inside the nested dockview; the edge group must keep its size. Reproduces the reported clamp in a real browser — the only layer that can, since jsdom models neither ResizeObserver nor offsetParent, and a browser does not fire a resize for a fully-removed element. Fails without the guard (clamps to 85 and does not recover on reshow). Extends the e2e fixture with a nested-dockview host component and helpers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018axBKCULSaEET94Tefd1Ru --- e2e/fixtures/index.html | 95 ++++++++++++ e2e/tests/nested-edge-group.spec.ts | 45 ++++++ .../dockview/edgeGroupHiddenHost.spec.ts | 145 ++++++++++++++++++ 3 files changed, 285 insertions(+) create mode 100644 e2e/tests/nested-edge-group.spec.ts create mode 100644 packages/dockview-core/src/__tests__/dockview/edgeGroupHiddenHost.spec.ts 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(); + }); +});