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();
+ });
+});