️ perf(sidebar): 消除左侧树拖拽改宽时的卡顿

- 拖拽与松手提交期间标记 data-sidebar-resizing,关闭 Sider 默认 transition
- 避免 Ant Design transition:all 带动工作台/DataGrid 连续 reflow
- 补充 resize 标记生命周期与 CSS 约束回归测试
This commit is contained in:
Syngnat
2026-07-24 19:50:58 +08:00
parent e1bc65b788
commit 0c9e807674
4 changed files with 143 additions and 20 deletions

View File

@@ -1009,6 +1009,16 @@ body[data-ui-version] .ant-layout-sider[data-sidebar-panel='true'] {
--gonavi-sidebar-collapse-duration: 200ms;
}
/*
* Ant Design Sider defaults to `transition: all`. That is desirable for the
* collapse/expand animation, but drag-resizing width must snap immediately —
* otherwise the workbench/DataGrid reflows for ~200ms and feels heavily janky.
*/
body[data-ui-version] .ant-layout-sider[data-sidebar-panel='true'][data-sidebar-resizing='true'],
body[data-sidebar-resizing='true'] .ant-layout-sider[data-sidebar-panel='true'] {
transition: none !important;
}
body[data-ui-version] .ant-layout-sider[data-sidebar-collapsed='true'] {
min-width: var(--gonavi-sidebar-collapsed-width, 0px) !important;
max-width: var(--gonavi-sidebar-collapsed-width, 0px) !important;

View File

@@ -358,6 +358,18 @@ describe('settings center tool entries', () => {
expect(v2ThemeCss).toMatch(/body\[data-ui-version="v2"\]\s+\.gn-v2-app-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
});
it('disables sider width transition while drag-resizing so the workbench does not reflow for 200ms', () => {
expect(appSidebarResizeSource).toContain("sider.setAttribute('data-sidebar-resizing', 'true')");
expect(appSidebarResizeSource).toContain("document.body.setAttribute('data-sidebar-resizing', 'true')");
expect(appSidebarResizeSource).toContain('scheduleClearSidebarResizing');
expect(appCss).toMatch(
/body\[data-ui-version\]\s+\.ant-layout-sider\[data-sidebar-panel='true'\]\[data-sidebar-resizing='true'\][\s\S]*?transition:\s*none\s*!important;/,
);
expect(appCss).toMatch(
/body\[data-sidebar-resizing='true'\]\s+\.ant-layout-sider\[data-sidebar-panel='true'\][\s\S]*?transition:\s*none\s*!important;/,
);
});
it('keeps connection modal warm-mounted while leaving the remaining heavyweight modals conditional', () => {
expect(appSource).toContain('const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);');
expect(appSource).toContain('{isConnectionModalMounted && (');

View File

@@ -30,12 +30,46 @@ class FakeEventTarget {
}
}
class FakeHTMLElement {
class FakeAttributeHost {
private attributes = new Map<string, string>();
setAttribute(name: string, value: string) {
this.attributes.set(name, value);
}
removeAttribute(name: string) {
this.attributes.delete(name);
}
getAttribute(name: string) {
return this.attributes.has(name) ? this.attributes.get(name)! : null;
}
}
class FakeHTMLElement extends FakeAttributeHost {
getBoundingClientRect() {
return { right: 240, width: 240 };
}
}
class FakeBody extends FakeAttributeHost {
style = {
cursor: 'wait',
userSelect: 'text',
webkitUserSelect: 'auto',
};
}
const flushAnimationFrames = (frames: Map<number, FrameRequestCallback>, passes = 2) => {
for (let pass = 0; pass < passes; pass += 1) {
const pending = [...frames.entries()];
frames.clear();
for (const [, callback] of pending) {
callback(0);
}
}
};
describe('useAppSidebarResize interaction cleanup', () => {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
@@ -46,15 +80,7 @@ describe('useAppSidebarResize interaction cleanup', () => {
let renderer: ReactTestRenderer | null = null;
let resize: ReturnType<typeof useAppSidebarResize> | null = null;
let fakeWindow: FakeEventTarget & { getComputedStyle: () => { minWidth: string; maxWidth: string }; innerWidth: number };
let fakeDocument: FakeEventTarget & {
body: {
style: {
cursor: string;
userSelect: string;
webkitUserSelect: string;
};
};
};
let fakeDocument: FakeEventTarget & { body: FakeBody };
let ghost: { style: { display: string; left: string } };
let scheduledFrames: Map<number, FrameRequestCallback>;
let nextFrameId: number;
@@ -89,13 +115,7 @@ describe('useAppSidebarResize interaction cleanup', () => {
innerWidth: 1200,
});
fakeDocument = Object.assign(new FakeEventTarget(), {
body: {
style: {
cursor: 'wait',
userSelect: 'text',
webkitUserSelect: 'auto',
},
},
body: new FakeBody(),
});
ghost = { style: { display: 'none', left: '' } };
@@ -201,4 +221,23 @@ describe('useAppSidebarResize interaction cleanup', () => {
expect(fakeWindow.listenerCount('blur')).toBe(0);
expect(setSidebarWidth).not.toHaveBeenCalled();
});
it('marks the sider as resizing during drag and keeps the flag across width commit', () => {
const sider = (resize!.siderRef as React.MutableRefObject<FakeHTMLElement>).current;
beginResize();
expect(sider.getAttribute('data-sidebar-resizing')).toBe('true');
expect(fakeDocument.body.getAttribute('data-sidebar-resizing')).toBe('true');
act(() => fakeDocument.dispatch('mouseup', { clientX: 280 }));
expect(setSidebarWidth).toHaveBeenCalledWith(320);
// Still marked while the commit paints, so Ant Design width transition stays off.
expect(sider.getAttribute('data-sidebar-resizing')).toBe('true');
expect(fakeDocument.body.getAttribute('data-sidebar-resizing')).toBe('true');
act(() => flushAnimationFrames(scheduledFrames, 2));
expect(sider.getAttribute('data-sidebar-resizing')).toBe(null);
expect(fakeDocument.body.getAttribute('data-sidebar-resizing')).toBe(null);
});
});

View File

@@ -52,6 +52,7 @@ export const useAppSidebarResize = ({
}: UseAppSidebarResizeOptions) => {
const sidebarDragRef = useRef<SidebarResizeDragState | null>(null);
const rafRef = useRef<number | null>(null);
const clearResizingFrameRef = useRef<number | null>(null);
const ghostRef = useRef<HTMLDivElement>(null);
const siderRef = useRef<HTMLDivElement | null>(null);
const sidebarDragBodyStyleRef = useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null);
@@ -61,6 +62,51 @@ export const useAppSidebarResize = ({
setSidebarWidthRef.current = setSidebarWidth;
const sidebarResizeHandleWidth = Math.max(16, Math.round(16 * effectiveUiScale));
const cancelClearResizingFrame = useCallback(() => {
if (clearResizingFrameRef.current === null) return;
cancelAnimationFrame(clearResizingFrameRef.current);
clearResizingFrameRef.current = null;
}, []);
/**
* Mark the sider as mid-resize so CSS can disable Ant Design's default
* `transition: all`. Without this, committing width animates for ~200ms and
* forces the workbench/DataGrid to reflow on every animation frame.
*/
const setSidebarResizing = useCallback((active: boolean) => {
const sider = siderRef.current;
if (sider instanceof HTMLElement) {
if (active) {
sider.setAttribute('data-sidebar-resizing', 'true');
} else {
sider.removeAttribute('data-sidebar-resizing');
}
}
if (typeof document !== 'undefined') {
if (active) {
document.body.setAttribute('data-sidebar-resizing', 'true');
} else {
document.body.removeAttribute('data-sidebar-resizing');
}
}
}, []);
const scheduleClearSidebarResizing = useCallback(() => {
cancelClearResizingFrame();
if (typeof window === 'undefined') {
setSidebarResizing(false);
return;
}
// Wait two frames so React can paint the committed width while transition
// is still disabled, then re-enable collapse animations.
clearResizingFrameRef.current = requestAnimationFrame(() => {
clearResizingFrameRef.current = requestAnimationFrame(() => {
clearResizingFrameRef.current = null;
setSidebarResizing(false);
});
});
}, [cancelClearResizingFrame, setSidebarResizing]);
const detachSidebarResizeListeners = useCallback(() => {
const listeners = sidebarResizeListenersRef.current;
if (!listeners) return;
@@ -105,12 +151,25 @@ export const useAppSidebarResize = ({
if (commit && dragState) {
const finalMouseX = Number.isFinite(clientX) ? clientX as number : latestMouseX.current;
const delta = finalMouseX - dragState.startX;
// Keep transition disabled across the state commit + first paint.
setSidebarResizing(true);
setSidebarWidthRef.current(clampSidebarResizeWidth(
dragState.startWidth + delta,
dragState,
));
scheduleClearSidebarResizing();
return;
}
}, [detachSidebarResizeListeners, restoreSidebarDragBodyStyles]);
cancelClearResizingFrame();
setSidebarResizing(false);
}, [
cancelClearResizingFrame,
detachSidebarResizeListeners,
restoreSidebarDragBodyStyles,
scheduleClearSidebarResizing,
setSidebarResizing,
]);
const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) {
@@ -123,6 +182,8 @@ export const useAppSidebarResize = ({
e.stopPropagation();
finishSidebarResize(undefined, false);
cancelClearResizingFrame();
setSidebarResizing(true);
if (typeof document !== 'undefined') {
sidebarDragBodyStyleRef.current = {
@@ -182,11 +243,12 @@ export const useAppSidebarResize = ({
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
window.addEventListener('blur', handleBlur);
}, [finishSidebarResize, sidebarWidth]);
}, [cancelClearResizingFrame, finishSidebarResize, setSidebarResizing, sidebarWidth]);
useEffect(() => () => {
finishSidebarResize(undefined, false);
}, [finishSidebarResize]);
cancelClearResizingFrame();
}, [cancelClearResizingFrame, finishSidebarResize]);
return {
ghostRef,