perf(ui): drive sidebar resize by direct DOM writes, drop motion/react

Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-15 15:40:18 +08:00
parent c87e160374
commit 0fdfe91123
3 changed files with 227 additions and 186 deletions

View File

@@ -5,7 +5,6 @@ import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useTranslation } from "react-i18next"
import { motion, useReducedMotion } from "motion/react"
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
import { cn } from "@multica/ui/lib/utils"
@@ -35,13 +34,7 @@ const SIDEBAR_WIDTH_MAX = 360
const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_MOTION_TRANSITION = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.8,
} as const
const SIDEBAR_INSTANT_TRANSITION = { duration: 0 } as const
const SIDEBAR_DRAG_THRESHOLD = 2
function clampSidebarWidth(width: number) {
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(SIDEBAR_WIDTH_MAX, width))
@@ -58,16 +51,12 @@ type SidebarContextProps = {
}
type SidebarResizeContextProps = {
width: number
setWidth: (width: number) => void
persistWidth: (width: number) => void
isResizing: boolean
setIsResizing: (v: boolean) => void
commitWidth: (width: number) => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
// Drag width changes every animation frame. Keeping it in a separate context
// prevents controls that only consume open/mobile state from rerendering.
// Width previews are written directly to the two layout shells during drag.
// This context only exposes the one committed state transition on pointer-up.
const SidebarResizeContext = React.createContext<SidebarResizeContextProps | null>(null)
function useSidebar() {
@@ -109,7 +98,6 @@ function SidebarProvider({
const [openMobile, setOpenMobile] = React.useState(false)
const [width, _setWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT)
const [isResizing, setIsResizing] = React.useState(false)
React.useEffect(() => {
const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
if (stored) {
@@ -119,11 +107,10 @@ function SidebarProvider({
}
}
}, [])
const setWidth = React.useCallback((w: number) => {
_setWidth(clampSidebarWidth(w))
}, [])
const persistWidth = React.useCallback((w: number) => {
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(w)))
const commitWidth = React.useCallback((w: number) => {
const clamped = clampSidebarWidth(w)
_setWidth(clamped)
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clamped))
}, [])
// This is the internal state of the sidebar.
@@ -168,13 +155,9 @@ function SidebarProvider({
)
const resizeContextValue = React.useMemo<SidebarResizeContextProps>(
() => ({
width,
setWidth,
persistWidth,
isResizing,
setIsResizing,
commitWidth,
}),
[width, setWidth, persistWidth, isResizing]
[commitWidth]
)
return (
@@ -182,7 +165,6 @@ function SidebarProvider({
<SidebarResizeContext.Provider value={resizeContextValue}>
<div
data-slot="sidebar-wrapper"
data-sidebar-resizing={isResizing ? "true" : undefined}
style={
{
"--sidebar-width": `${width}px`,
@@ -217,12 +199,6 @@ function Sidebar({
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isResizing, width } = useSidebarResize()
const reducedMotion = useReducedMotion()
const animateOffcanvas = collapsible === "offcanvas"
const motionTransition = reducedMotion || isResizing
? SIDEBAR_INSTANT_TRANSITION
: SIDEBAR_MOTION_TRANSITION
if (collapsible === "none") {
return (
@@ -275,49 +251,30 @@ function Sidebar({
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<motion.div
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent",
!animateOffcanvas && !isResizing && "transition-[width] duration-200 ease-linear",
!animateOffcanvas && "group-data-[collapsible=offcanvas]:w-0",
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-out motion-reduce:transition-none",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? !animateOffcanvas && "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: !animateOffcanvas && "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
animate={animateOffcanvas ? { width: state === "collapsed" ? 0 : width } : undefined}
initial={false}
transition={motionTransition}
/>
<motion.div
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
animateOffcanvas
? side === "left"
? "left-0"
: "right-0"
: "data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
!animateOffcanvas && !isResizing && "transition-[left,right,width] duration-200 ease-linear",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-out motion-reduce:transition-none md:flex",
"data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
animate={
animateOffcanvas
? {
x: state === "collapsed" ? (side === "left" ? -width : width) : 0,
width,
}
: undefined
}
initial={false}
transition={motionTransition}
{...(props as React.ComponentProps<typeof motion.div>)}
{...props}
>
<div
data-sidebar="sidebar"
@@ -326,7 +283,7 @@ function Sidebar({
>
{children}
</div>
</motion.div>
</div>
</div>
)
}
@@ -360,82 +317,126 @@ function SidebarTrigger({
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { persistWidth, setWidth, setIsResizing } = useSidebarResize()
const { commitWidth } = useSidebarResize()
const { t } = useTranslation("ui")
const toggleLabel = t(($) => $.toggle_sidebar)
const didDragRef = React.useRef(false)
const dragRef = React.useRef<{
pointerId: number
startX: number
startWidth: number
latestWidth: number
direction: 1 | -1
wrapperEl: HTMLElement
gapEl: HTMLElement
containerEl: HTMLElement
} | null>(null)
const pendingWidthRef = React.useRef<number | null>(null)
const resizeFrameRef = React.useRef<number | null>(null)
const cancelActiveDragRef = React.useRef<(() => void) | null>(null)
// Native mousemove can fire several times between paints. Only commit the
// latest width once per frame so React and layout do no redundant work.
const scheduleWidth = React.useCallback((width: number) => {
pendingWidthRef.current = width
if (resizeFrameRef.current !== null) return
React.useEffect(() => () => cancelActiveDragRef.current?.(), [])
resizeFrameRef.current = requestAnimationFrame(() => {
resizeFrameRef.current = null
const pendingWidth = pendingWidthRef.current
pendingWidthRef.current = null
if (pendingWidth !== null) setWidth(pendingWidth)
})
}, [setWidth])
React.useEffect(() => () => {
if (resizeFrameRef.current !== null) {
cancelAnimationFrame(resizeFrameRef.current)
}
}, [])
const onMouseDown = React.useCallback(
(e: React.MouseEvent) => {
const onPointerDown = React.useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0 || e.isPrimary === false) return
e.preventDefault()
cancelActiveDragRef.current?.()
didDragRef.current = false
const sidebarEl = (e.target as HTMLElement).closest("[data-slot='sidebar']")
const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']")
if (!containerEl) return
const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width)
dragRef.current = { startX: e.clientX, startWidth, latestWidth: startWidth }
setWidth(startWidth)
setIsResizing(true)
const railEl = e.currentTarget
const sidebarEl = railEl.closest<HTMLElement>("[data-slot='sidebar']")
const wrapperEl = railEl.closest<HTMLElement>("[data-slot='sidebar-wrapper']")
const gapEl = sidebarEl?.querySelector<HTMLElement>("[data-slot='sidebar-gap']")
const containerEl = sidebarEl?.querySelector<HTMLElement>("[data-slot='sidebar-container']")
if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return
const onMouseMove = (ev: MouseEvent) => {
if (!dragRef.current) return
didDragRef.current = true
const delta = ev.clientX - dragRef.current.startX
const nextWidth = clampSidebarWidth(dragRef.current.startWidth + delta)
dragRef.current.latestWidth = nextWidth
scheduleWidth(nextWidth)
const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width)
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startWidth,
latestWidth: startWidth,
direction: sidebarEl.dataset.side === "right" ? -1 : 1,
wrapperEl,
gapEl,
containerEl,
}
const onMouseUp = () => {
const finalWidth = dragRef.current?.latestWidth
if (resizeFrameRef.current !== null) {
cancelAnimationFrame(resizeFrameRef.current)
resizeFrameRef.current = null
}
pendingWidthRef.current = null
if (didDragRef.current && finalWidth !== undefined) {
setWidth(finalWidth)
persistWidth(finalWidth)
wrapperEl.setAttribute("data-sidebar-resizing", "true")
document.documentElement.setAttribute("data-sidebar-resizing", "true")
let finished = false
const finishDrag = (mode: "commit" | "cancel") => {
if (finished) return
finished = true
document.removeEventListener("pointermove", onPointerMove)
document.removeEventListener("pointerup", onPointerUp)
document.removeEventListener("pointercancel", onPointerCancel)
window.removeEventListener("blur", onWindowBlur)
railEl.removeEventListener("lostpointercapture", onLostPointerCapture)
const drag = dragRef.current
if (drag) {
if (mode === "commit" && didDragRef.current) {
drag.wrapperEl.style.setProperty(
"--sidebar-width",
`${drag.latestWidth}px`
)
commitWidth(drag.latestWidth)
}
drag.gapEl.style.removeProperty("width")
drag.containerEl.style.removeProperty("width")
drag.wrapperEl.removeAttribute("data-sidebar-resizing")
}
dragRef.current = null
setIsResizing(false)
document.removeEventListener("mousemove", onMouseMove)
document.removeEventListener("mouseup", onMouseUp)
document.body.style.cursor = ""
document.body.style.userSelect = ""
cancelActiveDragRef.current = null
document.documentElement.removeAttribute("data-sidebar-resizing")
if (railEl.hasPointerCapture?.(e.pointerId)) {
railEl.releasePointerCapture?.(e.pointerId)
}
}
document.addEventListener("mousemove", onMouseMove)
document.addEventListener("mouseup", onMouseUp)
document.body.style.cursor = "col-resize"
document.body.style.userSelect = "none"
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
const delta = (event.clientX - drag.startX) * drag.direction
if (!didDragRef.current && Math.abs(delta) < SIDEBAR_DRAG_THRESHOLD) {
return
}
didDragRef.current = true
const nextWidth = clampSidebarWidth(drag.startWidth + delta)
if (nextWidth === drag.latestWidth) return
drag.latestWidth = nextWidth
// Only the two layout shells depend on the live width. Direct writes
// let the browser coalesce layout at paint time without a React commit
// or an inherited custom-property invalidation on the whole app tree.
drag.gapEl.style.width = `${nextWidth}px`
drag.containerEl.style.width = `${nextWidth}px`
}
const onPointerUp = (event: PointerEvent) => {
if (event.pointerId === e.pointerId) finishDrag("commit")
}
const onPointerCancel = (event: PointerEvent) => {
if (event.pointerId === e.pointerId) finishDrag("cancel")
}
const onLostPointerCapture = (event: PointerEvent) => {
if (event.pointerId === e.pointerId) finishDrag("cancel")
}
const onWindowBlur = () => finishDrag("cancel")
document.addEventListener("pointermove", onPointerMove)
document.addEventListener("pointerup", onPointerUp)
document.addEventListener("pointercancel", onPointerCancel)
window.addEventListener("blur", onWindowBlur)
railEl.addEventListener("lostpointercapture", onLostPointerCapture)
cancelActiveDragRef.current = () => finishDrag("cancel")
railEl.setPointerCapture?.(e.pointerId)
},
[persistWidth, scheduleWidth, setWidth, setIsResizing]
[commitWidth]
)
const handleClick = React.useCallback(() => {
@@ -450,11 +451,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
aria-label={toggleLabel}
tabIndex={-1}
onClick={handleClick}
onMouseDown={onMouseDown}
onPointerDown={onPointerDown}
title={toggleLabel}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-[transform,background-color] ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",
"absolute inset-y-0 z-20 hidden w-4 touch-none cursor-ew-resize transition-[transform,background-color] ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",

View File

@@ -218,6 +218,26 @@
color: var(--sidebar-accent-foreground);
}
/* Sidebar resizing follows the same cursor contract as
* react-resizable-panels: the resize cursor survives leaving the narrow rail
* and wins over descendants with their own cursor declarations. Live width is
* written directly to the two layout shells, so their toggle transitions must
* stay disabled until the gesture finishes. */
html[data-sidebar-resizing="true"],
html[data-sidebar-resizing="true"] * {
cursor: ew-resize !important;
user-select: none !important;
}
[data-sidebar-resizing="true"] [data-slot="sidebar-gap"],
[data-sidebar-resizing="true"] [data-slot="sidebar-container"] {
transition: none !important;
}
[data-sidebar-resizing="true"] [data-sidebar="rail"]::after {
background-color: var(--sidebar-border);
}
/* Right detail sidebars use react-resizable-panels, which sizes panels by
* updating the outer panel's flex-grow. Animate that property for button
* toggles only; mount-time layout restoration and resize sync should snap

View File

@@ -1,5 +1,4 @@
import { act, fireEvent } from "@testing-library/react";
import type { HTMLAttributes, ReactNode } from "react";
import { fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -10,45 +9,10 @@ import {
} from "@multica/ui/components/ui/sidebar";
import { renderWithI18n } from "../test/i18n";
vi.mock("motion/react", () => ({
motion: {
div: ({
animate: _animate,
children,
initial: _initial,
transition,
...props
}: HTMLAttributes<HTMLDivElement> & {
animate?: unknown;
children?: ReactNode;
initial?: unknown;
transition?: unknown;
}) => (
<div data-motion-transition={JSON.stringify(transition)} {...props}>
{children}
</div>
),
},
useReducedMotion: () => false,
}));
describe("left sidebar resizing", () => {
let animationFrames: Map<number, FrameRequestCallback>;
let nextAnimationFrameId: number;
beforeEach(() => {
localStorage.clear();
animationFrames = new Map();
nextAnimationFrameId = 1;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const id = nextAnimationFrameId++;
animationFrames.set(id, callback);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
animationFrames.delete(id);
});
document.documentElement.removeAttribute("data-sidebar-resizing");
});
afterEach(() => {
@@ -56,7 +20,7 @@ describe("left sidebar resizing", () => {
vi.unstubAllGlobals();
});
it("updates width once per frame without rerendering stable sidebar consumers", () => {
it("previews width directly and commits only when the pointer is released", () => {
const stableConsumerRender = vi.fn();
const setItem = vi.spyOn(Storage.prototype, "setItem");
@@ -78,7 +42,13 @@ describe("left sidebar resizing", () => {
const wrapper = container.querySelector<HTMLElement>("[data-slot='sidebar-wrapper']")!;
const sidebarContainer = container.querySelector<HTMLElement>("[data-slot='sidebar-container']")!;
const sidebarGap = container.querySelector<HTMLElement>("[data-slot='sidebar-gap']")!;
const sidebar = container.querySelector<HTMLElement>("[data-slot='sidebar']")!;
const rail = container.querySelector<HTMLButtonElement>("[data-slot='sidebar-rail']")!;
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
rail.setPointerCapture = setPointerCapture;
rail.hasPointerCapture = vi.fn(() => true);
rail.releasePointerCapture = releasePointerCapture;
vi.spyOn(sidebarContainer, "getBoundingClientRect").mockReturnValue({
bottom: 0,
@@ -92,36 +62,87 @@ describe("left sidebar resizing", () => {
toJSON: () => ({}),
});
fireEvent.mouseDown(rail, { clientX: 256 });
expect(sidebarGap).toHaveAttribute("data-motion-transition", JSON.stringify({ duration: 0 }));
fireEvent.mouseMove(document, { clientX: 280 });
fireEvent.mouseMove(document, { clientX: 300 });
expect(animationFrames).toHaveLength(1);
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
expect(setItem).not.toHaveBeenCalled();
act(() => {
const frame = animationFrames.entries().next().value;
if (!frame) throw new Error("Expected a scheduled sidebar resize frame");
const [id, callback] = frame;
animationFrames.delete(id);
callback(16);
fireEvent.pointerDown(rail, {
button: 0,
clientX: 256,
isPrimary: true,
pointerId: 7,
});
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("300px");
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
expect(setPointerCapture).toHaveBeenCalledWith(7);
expect(rail).toHaveClass("cursor-ew-resize");
expect(wrapper).toHaveAttribute("data-sidebar-resizing", "true");
expect(document.documentElement).toHaveAttribute("data-sidebar-resizing", "true");
fireEvent.pointerMove(document, { buttons: 1, clientX: 280, pointerId: 7 });
fireEvent.pointerMove(document, { buttons: 1, clientX: 300, pointerId: 7 });
expect(sidebarGap.style.width).toBe("300px");
expect(sidebarContainer.style.width).toBe("300px");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
expect(setItem).not.toHaveBeenCalled();
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
fireEvent.mouseUp(document);
fireEvent.pointerUp(document, { pointerId: 7 });
expect(sidebarGap.style.width).toBe("");
expect(sidebarContainer.style.width).toBe("");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("300px");
expect(setItem).toHaveBeenCalledTimes(1);
expect(setItem).toHaveBeenCalledWith("sidebar_width", "300");
expect(sidebarGap).toHaveAttribute(
"data-motion-transition",
expect.stringContaining('"type":"spring"'),
expect(releasePointerCapture).toHaveBeenCalledWith(7);
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
fireEvent.click(rail);
expect(sidebar).toHaveAttribute("data-state", "expanded");
});
it("restores the committed width and cursor state when pointer capture is cancelled", () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const { container } = renderWithI18n(
<SidebarProvider>
<Sidebar>
<SidebarRail />
</Sidebar>
</SidebarProvider>,
);
const wrapper = container.querySelector<HTMLElement>("[data-slot='sidebar-wrapper']")!;
const sidebarContainer = container.querySelector<HTMLElement>("[data-slot='sidebar-container']")!;
const sidebarGap = container.querySelector<HTMLElement>("[data-slot='sidebar-gap']")!;
const rail = container.querySelector<HTMLButtonElement>("[data-slot='sidebar-rail']")!;
rail.setPointerCapture = vi.fn();
rail.hasPointerCapture = vi.fn(() => true);
rail.releasePointerCapture = vi.fn();
vi.spyOn(sidebarContainer, "getBoundingClientRect").mockReturnValue({
bottom: 0,
height: 0,
left: 0,
right: 256,
top: 0,
width: 256,
x: 0,
y: 0,
toJSON: () => ({}),
});
fireEvent.pointerDown(rail, {
button: 0,
clientX: 256,
isPrimary: true,
pointerId: 8,
});
fireEvent.pointerMove(document, { buttons: 1, clientX: 320, pointerId: 8 });
fireEvent.pointerCancel(document, { pointerId: 8 });
expect(sidebarGap.style.width).toBe("");
expect(sidebarContainer.style.width).toBe("");
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
expect(setItem).not.toHaveBeenCalled();
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
});
});