a panel is no longer remounted for stopping being second

The remount table in todo §5.2 was reasoned from the code and never observed, so
this mounts the real WorkspaceRenderer against a mount-counting probe and lets
the real layout-utils mutators produce the "after" tree. Eight cases.

It found one the reading had missed, and it is the cheapest of the lot. ChildEntry
returned `<>{children}</>` for the first child and `<><Handle/>{children}</>` for
every other, so the panel sat in fragment slot 0 when it was first and slot 1
when it was not. Remove the leftmost of three panels and the second one finds a
ResizableHandle in the slot it used to occupy — different element type, so React
unmounts a panel that nothing happened to. Scroll position, media playback, a
transcode, and for a chat panel a re-read of the durable log, all thrown away
because a neighbour was closed. Now the handle slot is always there, holding null
when it is not needed.

The other cases confirm what the doc said but for a different reason. Splitting
against the parent direction, and a two-child group collapsing, both change the
element *type* at that position — PanelSlot becomes ResizablePanelGroup, or the
reverse. React reconciles by type before it looks at keys, so the "reuse the id
so the key doesn't flip" fix the doc proposes would not have moved either one.
This commit is contained in:
2026-08-07 10:45:59 +00:00
parent 34b40cb094
commit 56ca411cf3
2 changed files with 197 additions and 14 deletions
@@ -0,0 +1,171 @@
import { describe, expect, test } from 'bun:test';
import { useEffect } from 'react';
import { render, cleanup } from '@testing-library/react';
import type { AppRegistryMap, LayoutGroup, LayoutNode, LayoutPanel } from './types';
import { movePanel, removePanel, splitPanel, updateSizes } from './layout-utils';
import { WorkspaceRenderer } from './WorkspaceRenderer';
import { WorkspaceProvider, inertInteraction } from './WorkspaceContext';
/**
* `docs/workspace-panel-todo.md` §5.2 carries a table of which layout operations remount a panel, and
* names one cause: the React key on the panel's nearest ancestor group slot. These tests exist because
* that table was reasoned from the code and never observed. A remount is not cosmetic — it is scroll
* position, media playback, a transcode, and for a chat panel a re-read of the durable log.
*
* The instrument is a probe app that counts its own mounts. Everything is measured through the real
* `WorkspaceRenderer`, with the real `layout-utils` mutators producing the "after" tree, so a change to
* either half moves these numbers rather than quietly agreeing with a hand-written fixture.
*/
const mounts = new Map<string, number>();
const mountCount = (id: string) => mounts.get(id) ?? 0;
const Probe = ({ panelId }: { panelId: string }) => {
useEffect(() => {
mounts.set(panelId, (mounts.get(panelId) ?? 0) + 1);
}, [panelId]);
return <div data-testid={`probe-${panelId}`} />;
};
const ProbeIcon = ((props: Record<string, unknown>) => <svg {...props} />) as AppRegistryMap[string]['icon'];
const registry: AppRegistryMap = { probe: { name: 'Probe', icon: ProbeIcon, component: Probe } };
const panel = (id: string): LayoutPanel => ({ type: 'panel', id, appType: 'probe' });
const group = (id: string, direction: 'horizontal' | 'vertical', children: LayoutNode[]): LayoutGroup => ({
type: 'group',
id,
direction,
children: children.map((node) => ({ node, size: 100 / children.length })),
});
const noop = () => {};
// The interaction half switched off: these tests are about reconciliation, not about rearranging.
const context = {
workspace: null,
cwd: '~',
panelConfigs: {},
...inertInteraction,
isMobile: false,
onMobileBack: null,
};
const tree = (layout: LayoutNode) => (
<WorkspaceProvider value={context}>
<WorkspaceRenderer
layout={layout}
registry={registry}
onSetApp={noop}
onSplit={noop}
onRemove={noop}
onResized={noop}
/>
</WorkspaceProvider>
);
const mount = (layout: LayoutNode) => {
mounts.clear();
const view = render(tree(layout));
return { rerender: (next: LayoutNode) => view.rerender(tree(next)) };
};
describe('what actually remounts a panel', () => {
test('resizing a group remounts nothing', () => {
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
const view = mount(root);
expect(mountCount('a')).toBe(1);
view.rerender(updateSizes(root, 'g', [70, 30]));
expect(mountCount('a')).toBe(1);
expect(mountCount('b')).toBe(1);
cleanup();
});
test('splitting in the parent direction keeps every existing panel mounted', () => {
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
const view = mount(root);
view.rerender(splitPanel(root, 'a', 'horizontal'));
expect(mountCount('a')).toBe(1);
expect(mountCount('b')).toBe(1);
cleanup();
});
test('splitting against the parent direction remounts the panel being split — and only it', () => {
const root = group('g', 'horizontal', [panel('a'), panel('b')]);
const view = mount(root);
view.rerender(splitPanel(root, 'a', 'vertical'));
// `a` is wrapped in a new group, so the element type at that position changes from a PanelSlot to a
// ResizablePanelGroup. No key can prevent this: React reconciles by type before it looks at keys.
expect(mountCount('a')).toBe(2);
expect(mountCount('b')).toBe(1);
cleanup();
});
test('splitting a root that is a single panel remounts it', () => {
const root = panel('a');
const view = mount(root);
view.rerender(splitPanel(root, 'a', 'horizontal'));
expect(mountCount('a')).toBe(2);
cleanup();
});
test('removing from a three-child group leaves the survivors mounted', () => {
const root = group('g', 'horizontal', [panel('a'), panel('b'), panel('c')]);
const view = mount(root);
view.rerender(removePanel(root, 'b'));
expect(mountCount('a')).toBe(1);
expect(mountCount('c')).toBe(1);
cleanup();
});
test('the panel that becomes first is not remounted for it', () => {
const root = group('g', 'horizontal', [panel('a'), panel('b'), panel('c')]);
const view = mount(root);
view.rerender(removePanel(root, 'a'));
// `b` moves from index 1 to index 0, so it loses the splitter handle that used to precede it. That
// handle sat in the same fragment slot the panel now occupies, and React remounted the panel over
// it. Nothing about `b` changed; it only stopped being second.
expect(mountCount('b')).toBe(1);
expect(mountCount('c')).toBe(1);
cleanup();
});
test('removing from a two-child group remounts the survivor, and only it', () => {
const root = group('outer', 'horizontal', [group('inner', 'vertical', [panel('a'), panel('b')]), panel('c')]);
const view = mount(root);
view.rerender(removePanel(root, 'b'));
// The inner group collapses to `a`, so `a` takes the group's slot: both its key and its element type
// change. `c`, in an untouched slot, does not pay for it.
expect(mountCount('a')).toBe(2);
expect(mountCount('c')).toBe(1);
cleanup();
});
test('a move leaves the panels it was dropped beside mounted', () => {
const root = group('g', 'horizontal', [panel('a'), panel('b'), panel('c')]);
const view = mount(root);
view.rerender(movePanel(root, 'a', 'c', 'right'));
// `movePanel` mints a fresh id for the moved panel, so its own remount is unavoidable and is counted
// under an id this test cannot predict. What is worth pinning is that its neighbours survive.
expect(mountCount('b')).toBe(1);
expect(mountCount('c')).toBe(1);
cleanup();
});
});
@@ -78,7 +78,10 @@ const getFixedHeight = (node: LayoutNode, registry: AppRegistryMap): number | un
};
/** Find a panel node by id anywhere in the layout tree */
const findChildById = (children: { node: LayoutNode; size: number }[], id: string): { node: LayoutNode; size: number } | undefined => {
const findChildById = (
children: { node: LayoutNode; size: number }[],
id: string,
): { node: LayoutNode; size: number } | undefined => {
for (const child of children) {
if (child.node.id === id) return child;
if (child.node.type === 'panel' && child.node.id === id) return child;
@@ -188,7 +191,9 @@ const LayoutNodeRenderer = ({
const hasFixedChild =
node.direction === 'vertical' &&
node.children.some((c) => getFixedHeight(c.node, registry) !== undefined || (c.node.type === 'panel' && c.node.fitContent));
node.children.some(
(c) => getFixedHeight(c.node, registry) !== undefined || (c.node.type === 'panel' && c.node.fitContent),
);
if (hasFixedChild) {
return (
@@ -197,7 +202,11 @@ const LayoutNodeRenderer = ({
const fixed = getFixedHeight(child.node, registry);
const fit = child.node.type === 'panel' && child.node.fitContent;
return (
<div key={child.node.id} className={fixed !== undefined || fit ? 'shrink-0' : 'min-h-0 flex-1'} style={fixed !== undefined ? { height: fixed } : undefined}>
<div
key={child.node.id}
className={fixed !== undefined || fit ? 'shrink-0' : 'min-h-0 flex-1'}
style={fixed !== undefined ? { height: fixed } : undefined}
>
<LayoutNodeRenderer
node={child.node}
registry={registry}
@@ -223,7 +232,7 @@ const LayoutNodeRenderer = ({
return (
<ResizablePanelGroup id={node.id} direction={node.direction} onLayout={handleLayout} className="h-full w-full">
{node.children.map((child, i) => (
<ChildEntry key={child.node.id} index={i} total={node.children.length}>
<ChildEntry key={child.node.id} index={i}>
<ResizablePanel id={child.node.id} order={i} defaultSize={child.size} minSize={5}>
<div className="h-full w-full">
<LayoutNodeRenderer
@@ -251,16 +260,19 @@ const LayoutNodeRenderer = ({
type ChildEntryProps = {
index: number;
total: number;
children: React.ReactNode;
};
const ChildEntry = ({ index, total, children }: ChildEntryProps) => {
if (index === 0) return <>{children}</>;
return (
/**
* Every child but the first is preceded by a splitter handle. The `null` matters: returning
* `<>{children}</>` for the first child put the panel in slot 0 of the fragment and the handle in slot 0
* for everyone else, so a panel that became first — remove the leftmost of three, drag one away — found a
* `ResizableHandle` where it used to be and React remounted it. Keeping two slots keeps the panel at
* index 1 whatever happens around it. Measured in `WorkspaceRenderer.test.tsx`.
*/
const ChildEntry = ({ index, children }: ChildEntryProps) => (
<>
<ResizableHandle className="bg-transparent after:bg-transparent" />
{index > 0 ? <ResizableHandle className="bg-transparent after:bg-transparent" /> : null}
{children}
</>
);
};
);