mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-12 01:24:12 +08:00
⚡️ perf(sidebar): 消除左侧树拖拽改宽时的卡顿
- 拖拽与松手提交期间标记 data-sidebar-resizing,关闭 Sider 默认 transition - 避免 Ant Design transition:all 带动工作台/DataGrid 连续 reflow - 补充 resize 标记生命周期与 CSS 约束回归测试
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 && (');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user