fix: prevent Windows shaking when adding always-rendered panels #988

- Add position caching with RAF batching to prevent excessive DOM measurements
- Replace direct DOM updates with requestAnimationFrame-based positioning
- Add CSS containment to overlay containers to prevent layout cascade
- Update tests to handle async RAF behavior and add specific test for issue #988

This resolves visual shaking on Windows by eliminating layout thrashing
caused by frequent getBoundingClientRect() calls during panel operations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
mathuo 2025-08-08 21:17:37 +01:00
parent 212863cbec
commit 414244cc8c
No known key found for this signature in database
GPG Key ID: C6EEDEFD6CA07281
5 changed files with 151 additions and 13 deletions

View File

@ -45,6 +45,12 @@ export function exhaustMicrotaskQueue(): Promise<void> {
return new Promise<void>((resolve) => resolve());
}
export function exhaustAnimationFrame(): Promise<void> {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
export const mockGetBoundingClientRect = ({
left,
top,

View File

@ -6,7 +6,7 @@ import {
OverlayRenderContainer,
} from '../../overlay/overlayRenderContainer';
import { fromPartial } from '@total-typescript/shoehorn';
import { Writable, exhaustMicrotaskQueue } from '../__test_utils__/utils';
import { Writable, exhaustMicrotaskQueue, exhaustAnimationFrame } from '../__test_utils__/utils';
import { DockviewComponent } from '../../dockview/dockviewComponent';
import { DockviewGroupPanel } from '../../dockview/dockviewGroupPanel';
@ -160,6 +160,7 @@ describe('overlayRenderContainer', () => {
const container = cut.attach({ panel, referenceContainer });
await exhaustMicrotaskQueue();
await exhaustAnimationFrame();
expect(panelContentEl.parentElement).toBe(container);
expect(container.parentElement).toBe(parentContainer);
@ -175,6 +176,7 @@ describe('overlayRenderContainer', () => {
).toHaveBeenCalledTimes(1);
onDidDimensionsChange.fire({});
await exhaustAnimationFrame();
expect(container.style.display).toBe('');
expect(container.style.left).toBe('49px');
@ -262,4 +264,87 @@ describe('overlayRenderContainer', () => {
'calc(var(--dv-overlay-z-index, 999) + 5)'
);
});
test('that frequent resize calls are batched to prevent shaking (issue #988)', async () => {
const cut = new OverlayRenderContainer(
parentContainer,
fromPartial<DockviewComponent>({})
);
const panelContentEl = document.createElement('div');
const onDidVisibilityChange = new Emitter<any>();
const onDidDimensionsChange = new Emitter<any>();
const onDidLocationChange = new Emitter<any>();
const panel = fromPartial<IDockviewPanel>({
api: {
id: 'test_panel_id',
onDidVisibilityChange: onDidVisibilityChange.event,
onDidDimensionsChange: onDidDimensionsChange.event,
onDidLocationChange: onDidLocationChange.event,
isVisible: true,
location: { type: 'grid' },
},
view: {
content: {
element: panelContentEl,
},
},
group: {
api: {
location: {
type: 'grid',
},
},
},
});
jest.spyOn(referenceContainer.element, 'getBoundingClientRect')
.mockReturnValue(
fromPartial<DOMRect>({
left: 100,
top: 200,
width: 150,
height: 250,
})
);
jest.spyOn(parentContainer, 'getBoundingClientRect').mockReturnValue(
fromPartial<DOMRect>({
left: 50,
top: 100,
width: 200,
height: 300,
})
);
const container = cut.attach({ panel, referenceContainer });
// Wait for initial positioning
await exhaustMicrotaskQueue();
await exhaustAnimationFrame();
expect(container.style.left).toBe('50px');
expect(container.style.top).toBe('100px');
// Simulate rapid resize events that could cause shaking
onDidDimensionsChange.fire({});
onDidDimensionsChange.fire({});
onDidDimensionsChange.fire({});
onDidDimensionsChange.fire({});
onDidDimensionsChange.fire({});
// Even with multiple rapid events, only one RAF should be scheduled
await exhaustAnimationFrame();
expect(container.style.left).toBe('50px');
expect(container.style.top).toBe('100px');
expect(container.style.width).toBe('150px');
expect(container.style.height).toBe('250px');
// Verify that DOM measurements are cached within the same frame
// Should be called initially and possibly one more time for visibility change
expect(referenceContainer.element.getBoundingClientRect).toHaveBeenCalledTimes(2);
expect(parentContainer.getBoundingClientRect).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,6 +1,7 @@
.dv-dockview {
position: relative;
background-color: var(--dv-group-view-background-color);
contain: layout;
.dv-watermark-container {
position: absolute;

View File

@ -4,6 +4,8 @@
position: absolute;
z-index: 1;
height: 100%;
contain: layout paint;
isolation: isolate;
&.dv-render-overlay-float {
z-index: calc(var(--dv-overlay-z-index) - 1);

View File

@ -10,6 +10,32 @@ import {
import { IDockviewPanel } from '../dockview/dockviewPanel';
import { DockviewComponent } from '../dockview/dockviewComponent';
class PositionCache {
private cache = new Map<Element, { rect: { left: number; top: number; width: number; height: number }; frameId: number }>();
private currentFrameId = 0;
private rafId: number | null = null;
getPosition(element: Element): { left: number; top: number; width: number; height: number } {
const cached = this.cache.get(element);
if (cached && cached.frameId === this.currentFrameId) {
return cached.rect;
}
this.scheduleFrameUpdate();
const rect = getDomNodePagePosition(element);
this.cache.set(element, { rect, frameId: this.currentFrameId });
return rect;
}
private scheduleFrameUpdate() {
if (this.rafId) return;
this.rafId = requestAnimationFrame(() => {
this.currentFrameId++;
this.rafId = null;
});
}
}
export type DockviewPanelRenderer = 'onlyWhenVisible' | 'always';
export interface IRenderable {
@ -35,6 +61,8 @@ export class OverlayRenderContainer extends CompositeDisposable {
> = {};
private _disposed = false;
private positionCache = new PositionCache();
private pendingUpdates = new Set<string>();
constructor(
readonly element: HTMLElement,
@ -94,19 +122,35 @@ export class OverlayRenderContainer extends CompositeDisposable {
}
const resize = () => {
// TODO propagate position to avoid getDomNodePagePosition calls, possible performance bottleneck?
const box = getDomNodePagePosition(referenceContainer.element);
const box2 = getDomNodePagePosition(this.element);
focusContainer.style.left = `${box.left - box2.left}px`;
focusContainer.style.top = `${box.top - box2.top}px`;
focusContainer.style.width = `${box.width}px`;
focusContainer.style.height = `${box.height}px`;
const panelId = panel.api.id;
if (this.pendingUpdates.has(panelId)) {
return; // Update already scheduled
}
this.pendingUpdates.add(panelId);
requestAnimationFrame(() => {
this.pendingUpdates.delete(panelId);
if (this.isDisposed || !this.map[panelId]) {
return;
}
const box = this.positionCache.getPosition(referenceContainer.element);
const box2 = this.positionCache.getPosition(this.element);
focusContainer.style.left = `${box.left - box2.left}px`;
focusContainer.style.top = `${box.top - box2.top}px`;
focusContainer.style.width = `${box.width}px`;
focusContainer.style.height = `${box.height}px`;
toggleClass(
focusContainer,
'dv-render-overlay-float',
panel.group.api.location.type === 'floating'
);
toggleClass(
focusContainer,
'dv-render-overlay-float',
panel.group.api.location.type === 'floating'
);
});
};
const visibilityChanged = () => {