From 0c9e807674280476f389ac7d10abd5a3fffae6ad Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 24 Jul 2026 19:50:58 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(sidebar):=20=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E5=B7=A6=E4=BE=A7=E6=A0=91=E6=8B=96=E6=8B=BD=E6=94=B9?= =?UTF-8?q?=E5=AE=BD=E6=97=B6=E7=9A=84=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖拽与松手提交期间标记 data-sidebar-resizing,关闭 Sider 默认 transition - 避免 Ant Design transition:all 带动工作台/DataGrid 连续 reflow - 补充 resize 标记生命周期与 CSS 约束回归测试 --- frontend/src/App.css | 10 +++ frontend/src/App.tool-center.test.ts | 12 +++ .../src/hooks/useAppSidebarResize.test.tsx | 73 ++++++++++++++----- frontend/src/hooks/useAppSidebarResize.ts | 68 ++++++++++++++++- 4 files changed, 143 insertions(+), 20 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 0200bd28..877ddcc8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 119b209d..e55efb34 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -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 && ('); diff --git a/frontend/src/hooks/useAppSidebarResize.test.tsx b/frontend/src/hooks/useAppSidebarResize.test.tsx index a8c8fbb0..4e1ca417 100644 --- a/frontend/src/hooks/useAppSidebarResize.test.tsx +++ b/frontend/src/hooks/useAppSidebarResize.test.tsx @@ -30,12 +30,46 @@ class FakeEventTarget { } } -class FakeHTMLElement { +class FakeAttributeHost { + private attributes = new Map(); + + 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, 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 | 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; 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).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); + }); }); diff --git a/frontend/src/hooks/useAppSidebarResize.ts b/frontend/src/hooks/useAppSidebarResize.ts index 5f027ffe..8972fc5d 100644 --- a/frontend/src/hooks/useAppSidebarResize.ts +++ b/frontend/src/hooks/useAppSidebarResize.ts @@ -52,6 +52,7 @@ export const useAppSidebarResize = ({ }: UseAppSidebarResizeOptions) => { const sidebarDragRef = useRef(null); const rafRef = useRef(null); + const clearResizingFrameRef = useRef(null); const ghostRef = useRef(null); const siderRef = useRef(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,