"use client" /** * Keeps a resizable shell rail (library secondary nav, docked Ask Leo) at the * pixel width the user last chose. * * Why this exists — `react-resizable-panels` stores layout as percentages. A * pixel `defaultSize` is converted once, against the sum of the panel widths it * can see at that instant. In the app shell that instant is a bad one: the * primary sidebar is still expanded, so the sum is ~200px short and a rail asked * for 256px is registered as a percentage of the narrower row. When the sidebar * collapses, the row grows and the percentage pays out wider than asked * (256 → 301 at 1440px). `groupResizeBehavior: "preserve-pixel-size"` does not * catch it, because the group never observes a resize — the conversion simply * used a width that was already stale. * * That alone would be a cosmetic 45px. The damage came from persisting it: * `onResize` fires for layout recomputation as well as drags, so the inflated * width was saved and became the next session's `defaultSize`, which inflated * again. Measured on a fresh profile: 256 → 301 → 354 → 416 → 480 (the ceiling), * one step per page load, so a rail nobody ever dragged ends up twice its * intended width. * * The fix is to treat the percentage layout as an approximation and re-assert * the pixel width once, on the frame after the panel mounts, and to persist only * sizes that arrive after that (a drag). `usePanelCallbackRef` gives us the * panel handle as state, so the correction re-runs on every panel mount — * compact ↔ expanded, Leo opening, route changes — not just the first one. */ import * as React from "react" import { type PanelImperativeHandle, usePanelCallbackRef, } from "react-resizable-panels" import { usePersistedState } from "../lib/persisted-state" /** Props to spread onto the rail's `ResizablePanel`. */ export type ShellRailPanelProps = { defaultSize: string minSize: string maxSize: string groupResizeBehavior: "preserve-pixel-size" panelRef: React.Dispatch> onResize: (size: { inPixels: number }) => void } export type UseShellRailWidthOptions = { /** `usePersistedState` key, e.g. `shell:secondary-panel-width`. */ storageKey: string defaultWidth: number minWidth: number maxWidth: number /** Keeps a stored width inside `minWidth`..`maxWidth`. Must be stable. */ clamp: (width: number) => number /** Bump to drop widths written by an older, broken version. */ version?: number } export function useShellRailWidth({ storageKey, defaultWidth, minWidth, maxWidth, clamp, version = 2, }: UseShellRailWidthOptions): { width: number panelProps: ShellRailPanelProps } { const [width, setWidth] = usePersistedState(storageKey, defaultWidth, { version, }) const [panelHandle, setPanelHandle] = usePanelCallbackRef() const latestWidth = React.useRef(width) React.useEffect(() => { latestWidth.current = width }, [width]) // Stable for the life of a panel: changing `defaultSize` re-registers the // panel and corrupts the group layout mid-drag. Refreshed on unmount so the // next mount starts from the width the user actually left behind. const defaultSizeRef = React.useRef(`${clamp(width)}px`) // Only a drag should be written back. Layout recomputation also calls // `onResize`, and that is exactly what used to ratchet the stored width up. const acceptResize = React.useRef(false) React.useEffect(() => { if (!panelHandle) return acceptResize.current = false const frame = requestAnimationFrame(() => { const target = clamp(latestWidth.current) // Sub-pixel drift is normal (percentages are rounded to 3 places); only // correct a real gap so we never fight the layout on every mount. if (Math.abs(panelHandle.getSize().inPixels - target) > 1) { panelHandle.resize(`${target}px`) } acceptResize.current = true }) return () => { cancelAnimationFrame(frame) acceptResize.current = false defaultSizeRef.current = `${clamp(latestWidth.current)}px` } }, [panelHandle, clamp]) const onResize = React.useCallback( (size: { inPixels: number }) => { if (!acceptResize.current) return setWidth(clamp(size.inPixels)) }, [clamp, setWidth], ) return { width, panelProps: { defaultSize: defaultSizeRef.current, minSize: `${minWidth}px`, maxSize: `${maxWidth}px`, groupResizeBehavior: "preserve-pixel-size", panelRef: setPanelHandle, onResize, }, } }