mirror of
https://github.com/mathuo/dockview
synced 2025-08-17 22:56:01 +00:00
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:
parent
212863cbec
commit
414244cc8c
@ -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,
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
.dv-dockview {
|
||||
position: relative;
|
||||
background-color: var(--dv-group-view-background-color);
|
||||
contain: layout;
|
||||
|
||||
.dv-watermark-container {
|
||||
position: absolute;
|
||||
|
@ -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);
|
||||
|
@ -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 = () => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user