mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
⚡️ perf(sidebar): 优化拖拽与折叠展开流畅度
- 拖拽时实时同步侧栏全部宽度约束并移除内容区锁定 - 折叠与展开期间合并尺寸观察回调,结束后统一刷新 - 为依赖观察器增加安装补丁及生命周期回归测试
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
112
frontend/src/utils/rcResizeObserverSidebarLifecycle.test.ts
Normal file
112
frontend/src/utils/rcResizeObserverSidebarLifecycle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user