Skip to content

Commit 4a98b8d

Browse files
authored
fix(layout): strip nested sizes from layout structure signature (#211)
2 parents f82480e + 841bbaf commit 4a98b8d

2 files changed

Lines changed: 156 additions & 4 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { type SerializedDockview } from 'dockview-react';
3+
import { cloneLayout, getLayoutStructureSignature } from './layout-snapshot';
4+
5+
/** Build a minimal dockview-shaped serialized layout. dockview stores branch
6+
* children under `data` (an array) and leaf view-membership under `data` (an
7+
* object) — the shape `stripSizes` recurses over. */
8+
function leaf(views: string[], size: number) {
9+
return { type: 'leaf', data: { views, activeView: views[0], id: views[0] }, size };
10+
}
11+
12+
function layout(root: any, panelIds: string[]): SerializedDockview {
13+
return {
14+
grid: { root, orientation: 'HORIZONTAL', width: 1000, height: 800 },
15+
panels: Object.fromEntries(
16+
panelIds.map(id => [id, { id, contentComponent: 'terminal', params: {} }]),
17+
),
18+
activeGroup: 'g1',
19+
} as unknown as SerializedDockview;
20+
}
21+
22+
describe('getLayoutStructureSignature', () => {
23+
it('ignores nested sizes so resizing does not change the signature', () => {
24+
// A two-pane split. The only difference between the two layouts is the
25+
// `size` of every node — the shape and panel membership are identical.
26+
// This is the regression: previously stripSizes returned early on the
27+
// root branch node (its children live under `data`, which is truthy), so
28+
// nested sizes leaked into the "structural" signature and a resize
29+
// silently disabled exact-layout reattach.
30+
const before = layout(
31+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-b'], 500)], size: 1000 },
32+
['pane-a', 'pane-b'],
33+
);
34+
const afterResize = layout(
35+
{ type: 'branch', data: [leaf(['pane-a'], 700), leaf(['pane-b'], 300)], size: 1000 },
36+
['pane-a', 'pane-b'],
37+
);
38+
39+
expect(getLayoutStructureSignature(before)).toBe(
40+
getLayoutStructureSignature(afterResize),
41+
);
42+
});
43+
44+
it('ignores sizes at arbitrary nesting depth', () => {
45+
const shallow = layout(
46+
{
47+
type: 'branch',
48+
data: [
49+
leaf(['pane-a'], 400),
50+
{ type: 'branch', data: [leaf(['pane-b'], 300), leaf(['pane-c'], 300)], size: 600 },
51+
],
52+
size: 1000,
53+
},
54+
['pane-a', 'pane-b', 'pane-c'],
55+
);
56+
const resizedDeep = layout(
57+
{
58+
type: 'branch',
59+
data: [
60+
leaf(['pane-a'], 250),
61+
{ type: 'branch', data: [leaf(['pane-b'], 500), leaf(['pane-c'], 100)], size: 750 },
62+
],
63+
size: 1000,
64+
},
65+
['pane-a', 'pane-b', 'pane-c'],
66+
);
67+
68+
expect(getLayoutStructureSignature(shallow)).toBe(
69+
getLayoutStructureSignature(resizedDeep),
70+
);
71+
});
72+
73+
it('distinguishes a different tree shape', () => {
74+
const flat = layout(
75+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-b'], 500)], size: 1000 },
76+
['pane-a', 'pane-b'],
77+
);
78+
const nested = layout(
79+
{
80+
type: 'branch',
81+
data: [{ type: 'branch', data: [leaf(['pane-a'], 500)], size: 500 }, leaf(['pane-b'], 500)],
82+
size: 1000,
83+
},
84+
['pane-a', 'pane-b'],
85+
);
86+
87+
expect(getLayoutStructureSignature(flat)).not.toBe(
88+
getLayoutStructureSignature(nested),
89+
);
90+
});
91+
92+
it('distinguishes different panel membership', () => {
93+
const ab = layout(
94+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-b'], 500)], size: 1000 },
95+
['pane-a', 'pane-b'],
96+
);
97+
const ac = layout(
98+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-c'], 500)], size: 1000 },
99+
['pane-a', 'pane-c'],
100+
);
101+
102+
expect(getLayoutStructureSignature(ab)).not.toBe(
103+
getLayoutStructureSignature(ac),
104+
);
105+
});
106+
107+
it('distinguishes which leaf a panel is grouped into', () => {
108+
const grouped = layout(
109+
{ type: 'branch', data: [leaf(['pane-a', 'pane-b'], 1000)], size: 1000 },
110+
['pane-a', 'pane-b'],
111+
);
112+
const split = layout(
113+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-b'], 500)], size: 1000 },
114+
['pane-a', 'pane-b'],
115+
);
116+
117+
expect(getLayoutStructureSignature(grouped)).not.toBe(
118+
getLayoutStructureSignature(split),
119+
);
120+
});
121+
122+
it('is independent of panel key ordering', () => {
123+
const forward = layout(
124+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-b'], 500)], size: 1000 },
125+
['pane-a', 'pane-b'],
126+
);
127+
const reversed = layout(
128+
{ type: 'branch', data: [leaf(['pane-a'], 500), leaf(['pane-b'], 500)], size: 1000 },
129+
['pane-b', 'pane-a'],
130+
);
131+
132+
expect(getLayoutStructureSignature(forward)).toBe(
133+
getLayoutStructureSignature(reversed),
134+
);
135+
});
136+
});
137+
138+
describe('cloneLayout', () => {
139+
it('deep-clones so mutations do not leak back', () => {
140+
const original = layout(
141+
{ type: 'branch', data: [leaf(['pane-a'], 500)], size: 1000 },
142+
['pane-a'],
143+
);
144+
const clone = cloneLayout(original);
145+
(clone.grid.root as any).size = 42;
146+
expect((original.grid.root as any).size).toBe(1000);
147+
});
148+
});

lib/src/lib/layout-snapshot.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ export function cloneLayout(layout: SerializedDockview): SerializedDockview {
44
return structuredClone(layout);
55
}
66

7-
/** Strip size data from the grid tree so we only compare structure. */
7+
/** Strip size data from the grid tree so we only compare structure.
8+
* dockview branch nodes hold their children under `data` (an array); leaf
9+
* nodes hold their view membership under `data` (an object). We drop each
10+
* node's own `size` and recurse into branch children so nested resizes don't
11+
* leak into the structural signature. Leaf `data` is preserved so panel
12+
* grouping still counts. */
813
function stripSizes(node: any): any {
914
const { size, ...rest } = node;
10-
if (rest.data) return rest;
11-
if (rest.children) {
12-
return { ...rest, children: rest.children.map(stripSizes) };
15+
if (Array.isArray(rest.data)) {
16+
return { ...rest, data: rest.data.map(stripSizes) };
1317
}
1418
return rest;
1519
}

0 commit comments

Comments
 (0)