️ perf(sidebar): 优化拖拽与折叠展开流畅度

- 拖拽时实时同步侧栏全部宽度约束并移除内容区锁定
- 折叠与展开期间合并尺寸观察回调,结束后统一刷新
- 为依赖观察器增加安装补丁及生命周期回归测试
This commit is contained in:
Syngnat
2026-08-01 14:51:56 +08:00
parent 544a332339
commit 849dad6ea8
8 changed files with 369 additions and 50 deletions

View File

@@ -2915,20 +2915,12 @@ body[data-ui-version] .ant-layout-sider[data-sidebar-panel='true'] {
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;
min-width: var(--gonavi-sidebar-resize-width) !important;
max-width: var(--gonavi-sidebar-resize-width) !important;
width: var(--gonavi-sidebar-resize-width) !important;
flex: 0 0 var(--gonavi-sidebar-resize-width) !important;
}
/*
* Keep the workbench viewport stable while the sider follows the pointer.
* This prevents Monaco, DataGrid and the tab strip from re-laying out on
* every mouse move; they receive the final available width after release.
*/
body[data-ui-version] .ant-layout-content[data-sidebar-resize-content='true'][data-sidebar-resize-content-locked='true'] {
width: var(--gonavi-sidebar-resize-content-width) !important;
flex: 0 0 var(--gonavi-sidebar-resize-content-width) !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

@@ -4186,6 +4186,7 @@ function App() {
effectiveUiScale,
setSidebarWidth,
sidebarWidth,
sidebarCollapsed: isSidebarCollapsed,
});
useEffect(() => {
@@ -7733,7 +7734,6 @@ function App() {
</div>
</Sider>
<Content
data-sidebar-resize-content="true"
style={{ background: bgContent, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}
>
{securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && (

View File

@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppSidebarResize } from './useAppSidebarResize';
import { createSidebarResizeAwareFrameScheduler } from '../utils/sidebarResizeLifecycle';
type Listener = (event: any) => void;
@@ -72,6 +73,7 @@ class FakeStyle {
class FakeHTMLElement extends FakeAttributeHost {
style = new FakeStyle();
nextElementSibling: FakeHTMLElement | null = null;
private listeners = new Map<string, Set<Listener>>();
constructor(private readonly width = 240) {
super();
@@ -80,6 +82,22 @@ class FakeHTMLElement extends FakeAttributeHost {
getBoundingClientRect() {
return { right: this.width, width: this.width };
}
addEventListener(type: string, listener: Listener) {
const listeners = this.listeners.get(type) ?? new Set<Listener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: Listener) {
this.listeners.get(type)?.delete(listener);
}
dispatch(type: string, event: any = {}) {
for (const listener of [...(this.listeners.get(type) ?? [])]) {
listener(event);
}
}
}
class FakeBody extends FakeAttributeHost {
@@ -117,12 +135,14 @@ describe('useAppSidebarResize interaction cleanup', () => {
let fakeSider: FakeHTMLElement;
let fakeContent: FakeHTMLElement;
const Harness = () => {
resize = useAppSidebarResize({
const Harness = ({ sidebarCollapsed = false }: { sidebarCollapsed?: boolean }) => {
const options = {
effectiveUiScale: 1,
setSidebarWidth,
sidebarWidth: 240,
});
sidebarCollapsed,
};
resize = useAppSidebarResize(options);
return null;
};
@@ -274,7 +294,7 @@ describe('useAppSidebarResize interaction cleanup', () => {
expect(setSidebarWidth).not.toHaveBeenCalled();
});
it('marks the sider as resizing during drag and keeps the flag across width commit', () => {
it('keeps the workbench fluid while marking the resize lifecycle', () => {
const sider = resize!.siderRef.current as unknown as FakeHTMLElement;
const settledListener = vi.fn();
fakeWindow.addEventListener('gonavi:sidebar-resize-settled', settledListener);
@@ -282,15 +302,15 @@ describe('useAppSidebarResize interaction cleanup', () => {
beginResize();
expect(sider.getAttribute('data-sidebar-resizing')).toBe('true');
expect(fakeDocument.body.getAttribute('data-sidebar-resizing')).toBe('true');
expect(fakeContent.getAttribute('data-sidebar-resize-content-locked')).toBe('true');
expect(fakeContent.style.getPropertyValue('--gonavi-sidebar-resize-content-width')).toBe('960px');
expect(fakeContent.getAttribute('data-sidebar-resize-content-locked')).toBe(null);
expect(fakeContent.style.getPropertyValue('--gonavi-sidebar-resize-content-width')).toBe('');
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');
expect(fakeContent.getAttribute('data-sidebar-resize-content-locked')).toBe('true');
expect(fakeContent.getAttribute('data-sidebar-resize-content-locked')).toBe(null);
expect(settledListener).not.toHaveBeenCalled();
act(() => flushAnimationFrames(scheduledFrames, 2));
@@ -302,4 +322,46 @@ describe('useAppSidebarResize interaction cleanup', () => {
expect(fakeContent.style.getPropertyValue('--gonavi-sidebar-resize-content-width')).toBe('');
expect(settledListener).toHaveBeenCalledTimes(1);
});
it('suspends observer work and settles once across collapse and expand commits', () => {
const callback = vi.fn();
const settledListener = vi.fn();
const scheduler = createSidebarResizeAwareFrameScheduler(callback);
fakeWindow.addEventListener('gonavi:sidebar-resize-settled', settledListener);
act(() => renderer?.update(<Harness sidebarCollapsed />));
expect(fakeDocument.body.getAttribute('data-sidebar-transitioning')).toBe('true');
for (let index = 0; index < 100; index += 1) scheduler.schedule();
expect(scheduledFrames.size).toBe(0);
expect(callback).not.toHaveBeenCalled();
act(() => fakeSider.dispatch('transitionend', {
target: fakeSider,
propertyName: 'width',
}));
expect(fakeDocument.body.getAttribute('data-sidebar-transitioning')).toBe(null);
expect(settledListener).toHaveBeenCalledTimes(1);
expect(scheduledFrames.size).toBe(1);
act(() => flushAnimationFrames(scheduledFrames, 1));
expect(callback).toHaveBeenCalledTimes(1);
act(() => renderer?.update(<Harness sidebarCollapsed={false} />));
expect(fakeDocument.body.getAttribute('data-sidebar-transitioning')).toBe('true');
for (let index = 0; index < 100; index += 1) scheduler.schedule();
expect(callback).toHaveBeenCalledTimes(1);
act(() => fakeSider.dispatch('transitionend', {
target: fakeSider,
propertyName: 'flex-basis',
}));
act(() => flushAnimationFrames(scheduledFrames, 1));
expect(fakeDocument.body.getAttribute('data-sidebar-transitioning')).toBe(null);
expect(settledListener).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenCalledTimes(2);
expect(setSidebarWidth).not.toHaveBeenCalled();
scheduler.dispose();
});
});

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import {
SIDEBAR_RESIZE_MAX_WIDTH,
SIDEBAR_RESIZE_MIN_WIDTH,
@@ -6,6 +6,7 @@ import {
} from '../utils/sidebarLayout';
import {
SIDEBAR_RESIZING_ATTRIBUTE,
SIDEBAR_TRANSITIONING_ATTRIBUTE,
notifySidebarResizeSettled,
} from '../utils/sidebarResizeLifecycle';
@@ -43,26 +44,35 @@ const clampSidebarResizeWidth = (width: number, bounds: SidebarResizeBounds): nu
);
const SIDEBAR_RESIZE_WIDTH_CSS_VARIABLE = '--gonavi-sidebar-resize-width';
const SIDEBAR_RESIZE_CONTENT_WIDTH_CSS_VARIABLE = '--gonavi-sidebar-resize-content-width';
const SIDEBAR_RESIZE_CONTENT_ATTRIBUTE = 'data-sidebar-resize-content';
const SIDEBAR_RESIZE_CONTENT_LOCK_ATTRIBUTE = 'data-sidebar-resize-content-locked';
type UseAppSidebarResizeOptions = {
effectiveUiScale: number;
setSidebarWidth: (width: number) => void;
sidebarWidth: number;
sidebarCollapsed?: boolean;
};
const SIDEBAR_COLLAPSE_TRANSITION_FALLBACK_MS = 260;
const SIDEBAR_GEOMETRY_TRANSITION_PROPERTIES = new Set([
'flex-basis',
'max-width',
'min-width',
'width',
]);
export const useAppSidebarResize = ({
effectiveUiScale,
setSidebarWidth,
sidebarWidth,
sidebarCollapsed,
}: UseAppSidebarResizeOptions) => {
const sidebarDragRef = useRef<SidebarResizeDragState | null>(null);
const rafRef = useRef<number | null>(null);
const clearResizingFrameRef = useRef<number | null>(null);
const siderRef = useRef<HTMLDivElement | null>(null);
const lockedContentRef = useRef<HTMLElement | null>(null);
const previousSidebarCollapsedRef = useRef(sidebarCollapsed);
const sidebarTransitionActiveRef = useRef(false);
const sidebarTransitionCleanupRef = useRef<(() => void) | null>(null);
const sidebarDragBodyStyleRef = useRef<{ cursor: string; userSelect: string; webkitUserSelect: string } | null>(null);
const sidebarResizeListenersRef = useRef<SidebarResizeListeners | null>(null);
const latestMouseX = useRef<number>(0);
@@ -76,25 +86,6 @@ export const useAppSidebarResize = ({
clearResizingFrameRef.current = null;
}, []);
const lockWorkbenchContentWidth = useCallback(() => {
const content = siderRef.current?.nextElementSibling as HTMLElement | null;
if (!(content instanceof HTMLElement)) return;
if (content.getAttribute(SIDEBAR_RESIZE_CONTENT_ATTRIBUTE) !== 'true') return;
const width = content.getBoundingClientRect().width;
if (!Number.isFinite(width) || width <= 0) return;
content.style.setProperty(SIDEBAR_RESIZE_CONTENT_WIDTH_CSS_VARIABLE, `${width}px`);
content.setAttribute(SIDEBAR_RESIZE_CONTENT_LOCK_ATTRIBUTE, 'true');
lockedContentRef.current = content;
}, []);
const unlockWorkbenchContentWidth = useCallback(() => {
const content = lockedContentRef.current;
lockedContentRef.current = null;
if (!content) return;
content.removeAttribute(SIDEBAR_RESIZE_CONTENT_LOCK_ATTRIBUTE);
content.style.removeProperty(SIDEBAR_RESIZE_CONTENT_WIDTH_CSS_VARIABLE);
}, []);
/**
* Mark the sider as mid-resize so CSS can disable Ant Design's default
* `transition: all`. Without this, committing width animates for ~200ms and
@@ -121,10 +112,9 @@ export const useAppSidebarResize = ({
}
}
if (!active) {
unlockWorkbenchContentWidth();
if (wasActive) notifySidebarResizeSettled();
}
}, [unlockWorkbenchContentWidth]);
}, []);
const previewSidebarWidth = useCallback((width: number) => {
const sider = siderRef.current;
@@ -148,6 +138,54 @@ export const useAppSidebarResize = ({
});
}, [cancelClearResizingFrame, setSidebarResizing]);
const finishSidebarCollapseTransition = useCallback(() => {
sidebarTransitionCleanupRef.current?.();
sidebarTransitionCleanupRef.current = null;
if (!sidebarTransitionActiveRef.current) return;
sidebarTransitionActiveRef.current = false;
if (typeof document !== 'undefined') {
document.body.removeAttribute(SIDEBAR_TRANSITIONING_ATTRIBUTE);
}
notifySidebarResizeSettled();
}, []);
const beginSidebarCollapseTransition = useCallback(() => {
const sider = siderRef.current;
if (!(sider instanceof HTMLElement) || typeof document === 'undefined') return;
// Rapid toggle clicks restart one lifecycle without flushing observers at
// the intermediate width. Only the final settled layout is measured.
sidebarTransitionCleanupRef.current?.();
sidebarTransitionCleanupRef.current = null;
sidebarTransitionActiveRef.current = true;
document.body.setAttribute(SIDEBAR_TRANSITIONING_ATTRIBUTE, 'true');
const handleTransitionEnd = (event: TransitionEvent) => {
if (
event.target !== sider
|| !SIDEBAR_GEOMETRY_TRANSITION_PROPERTIES.has(event.propertyName)
) return;
finishSidebarCollapseTransition();
};
const fallbackTimer = setTimeout(
finishSidebarCollapseTransition,
SIDEBAR_COLLAPSE_TRANSITION_FALLBACK_MS,
);
sider.addEventListener('transitionend', handleTransitionEnd);
sidebarTransitionCleanupRef.current = () => {
clearTimeout(fallbackTimer);
sider.removeEventListener('transitionend', handleTransitionEnd);
};
}, [finishSidebarCollapseTransition]);
useLayoutEffect(() => {
if (sidebarCollapsed === undefined) return;
if (previousSidebarCollapsedRef.current === sidebarCollapsed) return;
previousSidebarCollapsedRef.current = sidebarCollapsed;
beginSidebarCollapseTransition();
}, [beginSidebarCollapseTransition, sidebarCollapsed]);
const detachSidebarResizeListeners = useCallback(() => {
const listeners = sidebarResizeListenersRef.current;
if (!listeners) return;
@@ -240,7 +278,6 @@ export const useAppSidebarResize = ({
const startWidth = siderRect?.width ?? sidebarWidth;
const resizeBounds = resolveSidebarResizeBounds(siderRef.current);
lockWorkbenchContentWidth();
previewSidebarWidth(startWidth);
setSidebarResizing(true);
@@ -280,12 +317,13 @@ export const useAppSidebarResize = ({
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
window.addEventListener('blur', handleBlur);
}, [cancelClearResizingFrame, finishSidebarResize, lockWorkbenchContentWidth, previewSidebarWidth, setSidebarResizing, sidebarWidth]);
}, [cancelClearResizingFrame, finishSidebarResize, previewSidebarWidth, setSidebarResizing, sidebarWidth]);
useEffect(() => () => {
finishSidebarResize(undefined, false);
cancelClearResizingFrame();
}, [cancelClearResizingFrame, finishSidebarResize]);
finishSidebarCollapseTransition();
}, [cancelClearResizingFrame, finishSidebarCollapseTransition, finishSidebarResize]);
return {
handleSidebarMouseDown,

View File

@@ -0,0 +1,112 @@
import { readFileSync } from 'node:fs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const SIDEBAR_RESIZING_ATTRIBUTE = 'data-sidebar-resizing';
const SIDEBAR_TRANSITIONING_ATTRIBUTE = 'data-sidebar-transitioning';
const SIDEBAR_RESIZE_SETTLED_EVENT = 'gonavi:sidebar-resize-settled';
describe('rc-resize-observer sidebar resize patch', () => {
let sidebarResizeActive = false;
let sidebarTransitionActive = false;
let fakeWindow: EventTarget;
beforeEach(() => {
vi.resetModules();
sidebarResizeActive = false;
sidebarTransitionActive = false;
fakeWindow = new EventTarget();
vi.stubGlobal('window', fakeWindow);
vi.stubGlobal('document', {
body: {
getAttribute: (name: string) => (
(name === SIDEBAR_RESIZING_ATTRIBUTE && sidebarResizeActive)
|| (name === SIDEBAR_TRANSITIONING_ATTRIBUTE && sidebarTransitionActive)
? 'true'
: null
),
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});
it('coalesces repeated dependency callbacks until the sidebar settles', async () => {
const { _el, _rs } = await import('rc-resize-observer/es/utils/observerUtil.js');
const firstTarget = {} as Element;
const secondTarget = {} as Element;
const firstListener = vi.fn();
const secondListener = vi.fn();
_el.set(firstTarget, new Set([firstListener]));
_el.set(secondTarget, new Set([secondListener]));
sidebarResizeActive = true;
_rs([
{ target: firstTarget },
{ target: firstTarget },
{ target: secondTarget },
] as ResizeObserverEntry[]);
expect(firstListener).not.toHaveBeenCalled();
expect(secondListener).not.toHaveBeenCalled();
sidebarResizeActive = false;
fakeWindow.dispatchEvent(new Event(SIDEBAR_RESIZE_SETTLED_EVENT));
expect(firstListener).toHaveBeenCalledTimes(1);
expect(firstListener).toHaveBeenCalledWith(firstTarget);
expect(secondListener).toHaveBeenCalledTimes(1);
expect(secondListener).toHaveBeenCalledWith(secondTarget);
_rs([{ target: firstTarget }] as ResizeObserverEntry[]);
expect(firstListener).toHaveBeenCalledTimes(2);
_el.delete(firstTarget);
_el.delete(secondTarget);
});
it('ships the same lifecycle behavior in the install-time patch', () => {
const patch = readFileSync(
new URL('../../patches/rc-resize-observer+1.4.3.patch', import.meta.url),
'utf8',
);
expect(patch).toContain("getAttribute(SIDEBAR_RESIZING_ATTRIBUTE) === 'true'");
expect(patch).toContain("getAttribute(SIDEBAR_TRANSITIONING_ATTRIBUTE) === 'true'");
expect(patch).toContain('deferredSidebarResizeTargets.add(entity.target)');
expect(patch).toContain('window.addEventListener(SIDEBAR_RESIZE_SETTLED_EVENT');
expect(patch).toContain('if (isSidebarResizeActive()) return;');
expect(patch).toContain('targets.forEach(notifyTarget)');
});
it('coalesces dependency callbacks throughout a sidebar collapse transition', async () => {
const { _el, _rs } = await import('rc-resize-observer/es/utils/observerUtil.js');
const target = {} as Element;
const listener = vi.fn();
_el.set(target, new Set([listener]));
sidebarTransitionActive = true;
_rs([{ target }] as ResizeObserverEntry[]);
expect(listener).not.toHaveBeenCalled();
sidebarTransitionActive = false;
fakeWindow.dispatchEvent(new Event(SIDEBAR_RESIZE_SETTLED_EVENT));
expect(listener).toHaveBeenCalledTimes(1);
_el.delete(target);
});
it('overrides every Ant Design Sider width constraint during live preview', () => {
const appCss = readFileSync(new URL('../App.css', import.meta.url), 'utf8');
const activeResizeRule = appCss.match(
/body\[data-sidebar-resizing='true'\][\s\S]*?\{([\s\S]*?)\}/,
)?.[1] ?? '';
expect(activeResizeRule).toContain('min-width: var(--gonavi-sidebar-resize-width) !important');
expect(activeResizeRule).toContain('max-width: var(--gonavi-sidebar-resize-width) !important');
expect(activeResizeRule).toContain('width: var(--gonavi-sidebar-resize-width) !important');
expect(activeResizeRule).toContain('flex: 0 0 var(--gonavi-sidebar-resize-width) !important');
});
});

View File

@@ -135,6 +135,24 @@ describe('sidebarResizeLifecycle', () => {
scheduler.dispose();
});
it('defers resize work throughout a sidebar collapse transition', () => {
const callback = vi.fn();
const scheduler = createSidebarResizeAwareFrameScheduler(callback);
fakeBody.setAttribute('data-sidebar-transitioning', 'true');
scheduler.schedule();
expect(scheduledFrames.size).toBe(0);
expect(callback).not.toHaveBeenCalled();
fakeBody.removeAttribute('data-sidebar-transitioning');
fakeWindow.dispatchEvent({ type: SIDEBAR_RESIZE_SETTLED_EVENT });
flushAnimationFrames();
expect(callback).toHaveBeenCalledTimes(1);
scheduler.dispose();
});
it('cancels pending work when disposed', () => {
const callback = vi.fn();
const scheduler = createSidebarResizeAwareFrameScheduler(callback);

View File

@@ -1,9 +1,13 @@
export const SIDEBAR_RESIZING_ATTRIBUTE = 'data-sidebar-resizing';
export const SIDEBAR_TRANSITIONING_ATTRIBUTE = 'data-sidebar-transitioning';
export const SIDEBAR_RESIZE_SETTLED_EVENT = 'gonavi:sidebar-resize-settled';
export const isSidebarResizeActive = (): boolean => (
typeof document !== 'undefined'
&& document.body?.getAttribute(SIDEBAR_RESIZING_ATTRIBUTE) === 'true'
&& (
document.body?.getAttribute(SIDEBAR_RESIZING_ATTRIBUTE) === 'true'
|| document.body?.getAttribute(SIDEBAR_TRANSITIONING_ATTRIBUTE) === 'true'
)
);
export const notifySidebarResizeSettled = (): void => {
@@ -21,8 +25,9 @@ type SidebarResizeAwareFrameScheduler = {
/**
* Coalesces resize work into one animation frame and suspends it while the
* sidebar is being dragged. The final sidebar width is measured once after
* the drag settles instead of forcing React/layout work for every mouse move.
* sidebar is being dragged or collapsing/expanding. The final sidebar width is
* measured once after layout settles instead of forcing React/layout work for
* every intermediate geometry update.
*/
export const createSidebarResizeAwareFrameScheduler = (
callback: () => void,