From 0fdfe91123e212cd51ac916bdea5c9c758c2fc92 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:40:18 +0800 Subject: [PATCH] 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 --- packages/ui/components/ui/sidebar.tsx | 248 +++++++++--------- packages/ui/styles/base.css | 20 ++ packages/views/layout/sidebar-resize.test.tsx | 145 +++++----- 3 files changed, 227 insertions(+), 186 deletions(-) diff --git a/packages/ui/components/ui/sidebar.tsx b/packages/ui/components/ui/sidebar.tsx index c83ddc650d..3bdd0be3b4 100644 --- a/packages/ui/components/ui/sidebar.tsx +++ b/packages/ui/components/ui/sidebar.tsx @@ -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(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(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( () => ({ - width, - setWidth, - persistWidth, - isResizing, - setIsResizing, + commitWidth, }), - [width, setWidth, persistWidth, isResizing] + [commitWidth] ) return ( @@ -182,7 +165,6 @@ function SidebarProvider({
{/* This is what handles the sidebar gap on desktop */} - - +
) } @@ -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(null) - const resizeFrameRef = React.useRef(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) => { + 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("[data-slot='sidebar']") + const wrapperEl = railEl.closest("[data-slot='sidebar-wrapper']") + const gapEl = sidebarEl?.querySelector("[data-slot='sidebar-gap']") + const containerEl = sidebarEl?.querySelector("[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", diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index ab562383f1..270a38a910 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -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 diff --git a/packages/views/layout/sidebar-resize.test.tsx b/packages/views/layout/sidebar-resize.test.tsx index df15a820fd..06796b4f6a 100644 --- a/packages/views/layout/sidebar-resize.test.tsx +++ b/packages/views/layout/sidebar-resize.test.tsx @@ -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 & { - animate?: unknown; - children?: ReactNode; - initial?: unknown; - transition?: unknown; - }) => ( -
- {children} -
- ), - }, - useReducedMotion: () => false, -})); - describe("left sidebar resizing", () => { - let animationFrames: Map; - 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("[data-slot='sidebar-wrapper']")!; const sidebarContainer = container.querySelector("[data-slot='sidebar-container']")!; const sidebarGap = container.querySelector("[data-slot='sidebar-gap']")!; + const sidebar = container.querySelector("[data-slot='sidebar']")!; const rail = container.querySelector("[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( + + + + + , ); + + const wrapper = container.querySelector("[data-slot='sidebar-wrapper']")!; + const sidebarContainer = container.querySelector("[data-slot='sidebar-container']")!; + const sidebarGap = container.querySelector("[data-slot='sidebar-gap']")!; + const rail = container.querySelector("[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"); }); });