diff --git a/.changeset/pagelayout-resizable-persistence.md b/.changeset/pagelayout-resizable-persistence.md new file mode 100644 index 00000000000..50c4762aca0 --- /dev/null +++ b/.changeset/pagelayout-resizable-persistence.md @@ -0,0 +1,88 @@ +--- +'@primer/react': minor +--- + +Add custom persistence options to PageLayout.Pane's `resizable` prop with controlled width support + +The `resizable` prop now accepts additional configuration options: + +- `true` - Enable resizing with default localStorage persistence (existing behavior) +- `false` - Disable resizing (existing behavior) +- `{persist: false}` - Enable resizing without any persistence (avoids hydration mismatches) +- `{persist: 'localStorage'}` - Enable resizing with explicit localStorage persistence +- `{persist: fn}` - Enable resizing with custom persistence function (e.g., server-side, IndexedDB) +- `{width: number, persist: ...}` - Controlled width mode: provide current width and persistence handler + +**Key Features:** + +1. **Flexible persistence**: Choose between no persistence, localStorage, or custom persistence function +2. **Controlled width support**: Separate current width from default constraints using `resizable.width` +3. **SSR-friendly**: No persistence mode avoids hydration mismatches in server-rendered apps + +**New types exported:** + +- `PersistFunction` - Type for custom persistence function: `(width: number, options: SaveOptions) => void | Promise` +- `SaveOptions` - Options passed to custom persist function: `{widthStorageKey: string}` +- `PersistConfig` - Configuration object: `{width?: number, persist: false | 'localStorage' | PersistFunction}` +- `ResizableConfig` - Union type for all resizable configurations: `boolean | PersistConfig` +- `PaneWidth` - Type for preset width names: `'small' | 'medium' | 'large'` +- `PaneWidthValue` - Union type for width prop: `PaneWidth | CustomWidthOptions` + +**New values exported:** + +- `defaultPaneWidth` - Record of preset width values: `{small: 256, medium: 296, large: 320}` + +**Example usage:** + +```tsx +// No persistence - useful for SSR to avoid hydration mismatches + + +// Explicit localStorage persistence + + +// Custom persistence function - save to your own storage + { + // Save to server, IndexedDB, sessionStorage, etc. + myStorage.set(widthStorageKey, width) + } + }} +/> + +// Controlled width - separate current value from constraints +const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.medium) + { + setCurrentWidth(width) + localStorage.setItem('my-pane-width', width.toString()) + } + }} +/> + +// Using named size for constraints with controlled current width +const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.medium) + setCurrentWidth(width) + }} +/> + +// Using defaultPaneWidth for initialization +import {defaultPaneWidth} from '@primer/react' + +const [currentWidth, setCurrentWidth] = useState(defaultPaneWidth.large) + +``` diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index aef5c9346b0..fc08b95b767 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -1,9 +1,11 @@ import type {Meta, StoryFn} from '@storybook/react-vite' +import React from 'react' import {PageLayout} from './PageLayout' import {Placeholder} from '../Placeholder' -import {BranchName, Heading, Link, StateLabel, Text} from '..' +import {BranchName, Heading, Link, StateLabel, Text, useIsomorphicLayoutEffect} from '..' import TabNav from '../TabNav' import classes from './PageLayout.features.stories.module.css' +import {defaultPaneWidth} from './usePaneWidth' export default { title: 'Components/PageLayout/Features', @@ -358,3 +360,167 @@ export const WithCustomPaneHeading: StoryFn = () => ( ) + +export const ResizablePaneWithoutPersistence: StoryFn = () => ( + + + + + + + + + + + + + + +) +ResizablePaneWithoutPersistence.storyName = 'Resizable pane without persistence' + +export const ResizablePaneWithCustomPersistence: StoryFn = () => { + const key = 'page-layout-features-stories-custom-persistence-pane-width' + + // Read initial width from localStorage (CSR only), falling back to medium preset + const getInitialWidth = (): number => { + if (typeof window !== 'undefined') { + const storedWidth = localStorage.getItem(key) + if (storedWidth !== null) { + const parsed = parseFloat(storedWidth) + if (!isNaN(parsed) && parsed > 0) { + return parsed + } + } + } + return defaultPaneWidth.medium + } + + const [currentWidth, setCurrentWidth] = React.useState(getInitialWidth) + useIsomorphicLayoutEffect(() => { + setCurrentWidth(getInitialWidth()) + }, []) + return ( + + + + + { + setCurrentWidth(width) + localStorage.setItem(key, width.toString()) + }, + }} + aria-label="Side pane" + > + + + + + + + + + + ) +} +ResizablePaneWithCustomPersistence.storyName = 'Resizable pane with custom persistence' + +export const ResizablePaneWithNumberWidth: StoryFn = () => { + const key = 'page-layout-features-stories-number-width' + + // Read initial width from localStorage (CSR only), falling back to medium preset + const getInitialWidth = (): number => { + if (typeof window !== 'undefined') { + const storedWidth = localStorage.getItem(key) + if (storedWidth !== null) { + const parsed = parseInt(storedWidth, 10) + if (!isNaN(parsed) && parsed > 0) { + return parsed + } + } + } + return defaultPaneWidth.medium + } + + const [currentWidth, setCurrentWidth] = React.useState(getInitialWidth) + + return ( + + + + + { + setCurrentWidth(newWidth) + localStorage.setItem(key, newWidth.toString()) + }, + }} + aria-label="Side pane" + > + + + + + + + + + + ) +} +ResizablePaneWithNumberWidth.storyName = 'Resizable pane with number width' + +export const ResizablePaneWithControlledWidth: StoryFn = () => { + const key = 'page-layout-features-stories-controlled-width' + + // Read initial width from localStorage (CSR only), falling back to medium preset + const getInitialWidth = (): number => { + if (typeof window !== 'undefined') { + const storedWidth = localStorage.getItem(key) + if (storedWidth !== null) { + const parsed = parseInt(storedWidth, 10) + if (!isNaN(parsed) && parsed > 0) { + return parsed + } + } + } + return defaultPaneWidth.medium + } + + const [currentWidth, setCurrentWidth] = React.useState(getInitialWidth) + + return ( + + + + + { + setCurrentWidth(newWidth) + localStorage.setItem(key, newWidth.toString()) + }, + }} + aria-label="Side pane" + > + + + + + + + + + + ) +} +ResizablePaneWithControlledWidth.storyName = 'Resizable pane with controlled width (new API)' diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 753e353ae95..2b41576f61e 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -17,8 +17,8 @@ import { isCustomWidthOptions, isPaneWidth, ARROW_KEY_STEP, - type CustomWidthOptions, - type PaneWidth, + type PaneWidthValue, + type ResizableConfig, } from './usePaneWidth' import {setDraggingStyles, removeDraggingStyles} from './paneUtils' @@ -595,9 +595,27 @@ export type PageLayoutPaneProps = { positionWhenNarrow?: 'inherit' | keyof typeof panePositions 'aria-labelledby'?: string 'aria-label'?: string - width?: PaneWidth | CustomWidthOptions + /** + * The width of the pane - defines constraints and defaults only. + * - Named sizes: `'small'` | `'medium'` | `'large'` + * - Custom object: `{min: string, default: string, max: string}` + * + * For controlled width (current value), use `resizable.width` instead. + */ + width?: PaneWidthValue minWidth?: number - resizable?: boolean + /** + * Enable resizable pane behavior. + * - `true`: Enable with default localStorage persistence + * - `false`: Disable resizing + * - `{width?: number, persist: false}`: Enable without persistence, optionally with controlled current width + * - `{width?: number, persist: 'localStorage'}`: Enable with localStorage, optionally with controlled current width + * - `{width?: number, persist: fn}`: Enable with custom persistence, optionally with controlled current width + * + * The `width` property in the config represents the current/controlled width value. + * When provided, it takes precedence over the default width from the `width` prop. + */ + resizable?: ResizableConfig widthStorageKey?: string padding?: keyof typeof SPACING_MAP divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'> @@ -746,10 +764,11 @@ const Pane = React.forwardRef
{ expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) }) + + it('should not read from localStorage when {persist: false} is provided', () => { + localStorage.setItem('test-pane', '500') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {persist: false}, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use default, not localStorage value + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + + it('should not save to any storage when {persist: false} is provided', () => { + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {persist: false}, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + act(() => { + result.current.saveWidth(450) + }) + + // Width state should update + expect(result.current.currentWidth).toBe(450) + // But localStorage should not be written + expect(localStorage.getItem('test-pane')).toBeNull() + }) + + it('should initialize with resizable.width when provided', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {width: 400, persist: false}, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use resizable.width, not the default from width prop + expect(result.current.currentWidth).toBe(400) + }) + + it('should prefer resizable.width over localStorage', () => { + localStorage.setItem('test-pane', '350') + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {width: 500, persist: 'localStorage'}, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use resizable.width, not localStorage + expect(result.current.currentWidth).toBe(500) + }) + + it('should sync when resizable.width changes', () => { + const refs = createMockRefs() + type ResizableType = {width?: number; persist: false} + + const {result, rerender} = renderHook( + ({resizable}: {resizable: ResizableType}) => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable, + widthStorageKey: 'test-sync-resizable', + ...refs, + }), + {initialProps: {resizable: {width: 350, persist: false} as ResizableType}}, + ) + + expect(result.current.currentWidth).toBe(350) + + // Change resizable.width + rerender({resizable: {width: 450, persist: false}}) + + expect(result.current.currentWidth).toBe(450) + }) + + it('should fall back to default when resizable.width is removed', () => { + const refs = createMockRefs() + type ResizableType = {width?: number; persist: false} + + const {result, rerender} = renderHook( + ({resizable}: {resizable: ResizableType}) => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable, + widthStorageKey: 'test-fallback', + ...refs, + }), + {initialProps: {resizable: {width: 400, persist: false} as ResizableType}}, + ) + + expect(result.current.currentWidth).toBe(400) + + // Remove width from resizable config + rerender({resizable: {persist: false}}) + + // Should fall back to default from width prop + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + + it('should not sync width prop default when resizable.width is provided', () => { + const refs = createMockRefs() + type WidthType = 'small' | 'medium' | 'large' + type ResizableType = {width: number; persist: false} + + const {result, rerender} = renderHook( + ({width, resizable}: {width: WidthType; resizable: ResizableType}) => + usePaneWidth({ + width, + minWidth: 256, + resizable, + widthStorageKey: 'test-no-sync', + ...refs, + }), + { + initialProps: { + width: 'medium' as WidthType, + resizable: {width: 400, persist: false} as ResizableType, + }, + }, + ) + + expect(result.current.currentWidth).toBe(400) + + // Change width prop (default changes from 296 to 320) + rerender({width: 'large', resizable: {width: 400, persist: false}}) + + // Should NOT sync to new default because resizable.width is controlling + expect(result.current.currentWidth).toBe(400) + }) }) describe('saveWidth', () => { @@ -182,6 +342,196 @@ describe('usePaneWidth', () => { localStorage.setItem = originalSetItem }) + + it('should use localStorage when {persist: "localStorage"} is provided', () => { + const refs = createMockRefs() + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: {persist: 'localStorage'}, + widthStorageKey: 'test-explicit-localstorage', + ...refs, + }), + ) + + act(() => { + result.current.saveWidth(450) + }) + + expect(result.current.currentWidth).toBe(450) + expect(localStorage.getItem('test-explicit-localstorage')).toBe('450') + }) + + it('should call custom save function with width and options', () => { + const customSave = vi.fn() + const customPersister: PersistConfig = {persist: customSave} + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: customPersister, + widthStorageKey: 'my-custom-key', + ...refs, + }), + ) + + act(() => { + result.current.saveWidth(450) + }) + + expect(result.current.currentWidth).toBe(450) + expect(customSave).toHaveBeenCalledWith(450, {widthStorageKey: 'my-custom-key'}) + // Should NOT write to localStorage + expect(localStorage.getItem('my-custom-key')).toBeNull() + }) + + it('should handle async custom save function', async () => { + const customSave = vi.fn().mockResolvedValue(undefined) + const customPersister: PersistConfig = {persist: customSave} + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: customPersister, + widthStorageKey: 'test-async', + ...refs, + }), + ) + + act(() => { + result.current.saveWidth(350) + }) + + expect(result.current.currentWidth).toBe(350) + expect(customSave).toHaveBeenCalledWith(350, {widthStorageKey: 'test-async'}) + }) + + it('should handle sync errors from custom save gracefully', () => { + const customSave = vi.fn(() => { + throw new Error('Sync storage error') + }) + const customPersister: PersistConfig = {persist: customSave} + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: customPersister, + widthStorageKey: 'test-sync-error', + ...refs, + }), + ) + + // Should not throw - state should still update + act(() => { + result.current.saveWidth(450) + }) + + expect(result.current.currentWidth).toBe(450) + expect(customSave).toHaveBeenCalledWith(450, {widthStorageKey: 'test-sync-error'}) + }) + + it('should handle async rejection from custom save gracefully', async () => { + const customSave = vi.fn().mockRejectedValue(new Error('Async storage error')) + const customPersister: PersistConfig = {persist: customSave} + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: customPersister, + widthStorageKey: 'test-async-error', + ...refs, + }), + ) + + // Should not throw - state should still update + act(() => { + result.current.saveWidth(450) + }) + + // Wait for promise rejection to be handled + await vi.waitFor(() => { + expect(customSave).toHaveBeenCalledWith(450, {widthStorageKey: 'test-async-error'}) + }) + + expect(result.current.currentWidth).toBe(450) + }) + + it('should not read from localStorage when custom save is provided', () => { + localStorage.setItem('test-pane', '500') + const customPersister: PersistConfig = {persist: vi.fn() as PersistFunction} + const refs = createMockRefs() + + const {result} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: customPersister, + widthStorageKey: 'test-pane', + ...refs, + }), + ) + + // Should use default, not localStorage value + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + }) + }) + + describe('width prop sync (controlled mode)', () => { + it('should sync internal state when width prop changes', () => { + const refs = createMockRefs() + + const {result, rerender} = renderHook( + ({width}: {width: 'small' | 'medium' | 'large'}) => + usePaneWidth({ + width, + minWidth: 256, + resizable: true, + widthStorageKey: 'test-sync', + ...refs, + }), + {initialProps: {width: 'medium' as 'small' | 'medium' | 'large'}}, + ) + + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + + // Change width prop + rerender({width: 'large'}) + + expect(result.current.currentWidth).toBe(defaultPaneWidth.large) + }) + + it('should sync when width changes to custom width', () => { + const refs = createMockRefs() + type WidthType = 'medium' | {min: `${number}px`; default: `${number}px`; max: `${number}px`} + + const {result, rerender} = renderHook( + ({width}: {width: WidthType}) => + usePaneWidth({ + width, + minWidth: 256, + resizable: true, + widthStorageKey: 'test-sync-custom', + ...refs, + }), + {initialProps: {width: 'medium' as WidthType}}, + ) + + expect(result.current.currentWidth).toBe(defaultPaneWidth.medium) + + // Change to custom width + rerender({width: {min: '200px', default: '400px', max: '600px'}}) + + expect(result.current.currentWidth).toBe(400) + }) }) describe('minPaneWidth', () => { @@ -845,3 +1195,74 @@ describe('constants', () => { expect(DEFAULT_MAX_WIDTH_DIFF).toBe(511) }) }) + +describe('type guards', () => { + describe('isResizableEnabled', () => { + it('should return true for boolean true', () => { + expect(isResizableEnabled(true)).toBe(true) + }) + + it('should return false for boolean false', () => { + expect(isResizableEnabled(false)).toBe(false) + }) + + it('should return true for {persist: false} (resizable without persistence)', () => { + expect(isResizableEnabled({persist: false})).toBe(true) + }) + + it('should return true for {persist: "localStorage"}', () => { + expect(isResizableEnabled({persist: 'localStorage'})).toBe(true) + }) + + it('should return true for {persist: fn} (custom persistence)', () => { + expect(isResizableEnabled({persist: () => {}})).toBe(true) + }) + }) + + describe('isPersistConfig', () => { + it('should return true for {persist: false}', () => { + expect(isPersistConfig({persist: false})).toBe(true) + }) + + it('should return true for {persist: "localStorage"}', () => { + expect(isPersistConfig({persist: 'localStorage'})).toBe(true) + }) + + it('should return true for {persist: fn}', () => { + expect(isPersistConfig({persist: () => {}})).toBe(true) + }) + + it('should return false for boolean true', () => { + expect(isPersistConfig(true)).toBe(false) + }) + + it('should return false for boolean false', () => { + expect(isPersistConfig(false)).toBe(false) + }) + + it('should return false for objects without persist property', () => { + // @ts-expect-error - testing runtime behavior with arbitrary object + expect(isPersistConfig({other: 'value'})).toBe(false) + }) + }) + + describe('isCustomPersistFunction', () => { + it('should return true for function', () => { + const fn = () => {} + expect(isCustomPersistFunction(fn)).toBe(true) + }) + + it('should return true for async function', () => { + const fn = async () => {} + expect(isCustomPersistFunction(fn)).toBe(true) + }) + + it('should return false for false', () => { + expect(isCustomPersistFunction(false)).toBe(false) + }) + + it('should return false for "localStorage"', () => { + expect(isCustomPersistFunction('localStorage')).toBe(false) + }) + }) +}) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 4244e94d1e9..392ec91e9de 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -1,5 +1,4 @@ -import React, {startTransition} from 'react' -import {canUseDOM} from '../utils/environment' +import React, {startTransition, useMemo} from 'react' import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' import cssExports from './PageLayout.module.css' @@ -16,10 +15,62 @@ export type CustomWidthOptions = { export type PaneWidth = 'small' | 'medium' | 'large' +/** + * Width value for the pane - defines constraints and defaults only. + * - `PaneWidth`: Preset size ('small' | 'medium' | 'large') + * - `CustomWidthOptions`: Explicit min/default/max constraints + */ +export type PaneWidthValue = PaneWidth | CustomWidthOptions + +/** + * Options passed to custom persist function. + */ +export type SaveOptions = {widthStorageKey: string} + +/** + * Custom persist function type. + */ +export type PersistFunction = (width: number, options: SaveOptions) => void | Promise + +/** + * Configuration object for resizable pane. + * - `width?: number` - Current/controlled width value in pixels (overrides width prop's default) + * - `persist: false` - Enable resizing without any persistence + * - `persist: 'localStorage'` - Enable resizing with localStorage persistence + * - `persist: fn` - Enable resizing with custom persistence function + */ +export type PersistConfig = { + /** Current controlled width value in pixels. When provided, this overrides the default from the width prop. */ + width?: number + persist: false | 'localStorage' | PersistFunction +} + +/** + * Type guard to check if persist value is a custom function + */ +export const isCustomPersistFunction = ( + persist: false | 'localStorage' | PersistFunction, +): persist is PersistFunction => { + return typeof persist === 'function' +} + +/** + * Resizable configuration options. + * - `true`: Enable resizing with default localStorage persistence (may cause hydration mismatch) + * - `false`: Disable resizing + * - `{width?: number, persist: false}`: Enable resizing without any persistence, optionally with controlled width + * - `{width?: number, persist: 'localStorage'}`: Enable resizing with localStorage persistence, optionally with controlled width + * - `{width?: number, persist: fn}`: Enable resizing with custom persistence function, optionally with controlled width + * + * The `width` property in the config object represents the current/controlled width value. + * When provided, it takes precedence over the default width from the `width` prop. + */ +export type ResizableConfig = boolean | PersistConfig + export type UsePaneWidthOptions = { - width: PaneWidth | CustomWidthOptions + width: PaneWidthValue minWidth: number - resizable: boolean + resizable: ResizableConfig widthStorageKey: string paneRef: React.RefObject handleRef: React.RefObject @@ -72,16 +123,15 @@ export const defaultPaneWidth: Record = {small: 256, medium: // ---------------------------------------------------------------------------- // Helper functions -export const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): width is CustomWidthOptions => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - return (width as CustomWidthOptions).default !== undefined +export const isCustomWidthOptions = (width: PaneWidthValue): width is CustomWidthOptions => { + return typeof width === 'object' && 'min' in width && 'default' in width && 'max' in width } -export const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => { +export const isPaneWidth = (width: PaneWidthValue): width is PaneWidth => { return width === 'small' || width === 'medium' || width === 'large' } -export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { +export const getDefaultPaneWidth = (w: PaneWidthValue): number => { if (isPaneWidth(w)) { return defaultPaneWidth[w] } else if (isCustomWidthOptions(w)) { @@ -90,6 +140,21 @@ export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number = return 0 } +/** + * Type guard to check if resizable config is a PersistConfig object + */ +export const isPersistConfig = (config: ResizableConfig): config is PersistConfig => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- config could be null at runtime despite types + return typeof config === 'object' && config !== null && 'persist' in config +} + +/** + * Check if resizing is enabled (boolean true or {persist: ...}) + */ +export const isResizableEnabled = (config: ResizableConfig): boolean => { + return config === true || isPersistConfig(config) +} + /** * Gets the --pane-max-width-diff CSS variable value from a pane element. * This value is set by CSS media queries and controls the max pane width constraint. @@ -116,13 +181,42 @@ export const updateAriaValues = ( } } +const localStoragePersister = { + save: (key: string, width: number) => { + try { + localStorage.setItem(key, width.toString()) + } catch { + // Ignore write errors (private browsing, quota exceeded, etc.) + } + }, + get: (key: string): number | null => { + try { + const storedWidth = localStorage.getItem(key) + if (storedWidth !== null) { + const parsed = Number(storedWidth) + if (!isNaN(parsed) && parsed > 0) { + return parsed + } + } + } catch { + // localStorage unavailable + } + return null + }, +} + // ---------------------------------------------------------------------------- // Hook /** - * Manages pane width state with localStorage persistence and viewport constraints. + * Manages pane width state with storage persistence and viewport constraints. * Handles initialization from storage, clamping on viewport resize, and provides * functions to save and reset width. + * + * Storage behavior: + * - When `resizable` is `true`: Uses localStorage with the provided `widthStorageKey` + * - When `resizable` is `{persist: false}`: Resizable without any persistence + * - When `resizable` is `{save: fn}`: Resizable with custom persistence */ export function usePaneWidth({ width, @@ -138,6 +232,15 @@ export function usePaneWidth({ const minPaneWidth = isCustomWidth ? parseInt(width.min, 10) : minWidth const customMaxWidth = isCustomWidth ? parseInt(width.max, 10) : null + // Refs for stable callbacks - updated in layout effect below + const widthStorageKeyRef = React.useRef(widthStorageKey) + const resizableRef = React.useRef(resizable) + + // Keep refs in sync with props for stable callbacks + useIsomorphicLayoutEffect(() => { + resizableRef.current = resizable + widthStorageKeyRef.current = widthStorageKey + }) // Cache the CSS variable value to avoid getComputedStyle during drag (causes layout thrashing) // Updated on mount and resize when breakpoints might change const maxWidthDiffRef = React.useRef(DEFAULT_MAX_WIDTH_DIFF) @@ -149,58 +252,98 @@ export function usePaneWidth({ return viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiffRef.current) : minPaneWidth }, [customMaxWidth, minPaneWidth]) + const defaultWidth = useMemo(() => getDefaultPaneWidth(width), [width]) // --- State --- // Current width for React renders (ARIA attributes). Updates go through saveWidth() or clamp on resize. - // - // NOTE: We read from localStorage during initial state to avoid a visible resize flicker - // when the stored width differs from the default. This causes a React hydration mismatch - // (server renders default width, client renders stored width), but we handle this with - // suppressHydrationWarning on the Pane element. The mismatch only affects the --pane-width - // CSS variable, not DOM structure or children. + // Priority order for initial width: + // 1. resizable.width (controlled current value) + // 2. localStorage (resizable === true only) + // 3. defaultWidth (from width prop) const [currentWidth, setCurrentWidth] = React.useState(() => { - const defaultWidth = getDefaultPaneWidth(width) - - if (!resizable || !canUseDOM) { - return defaultWidth + // Check if resizable config has a controlled width value + if (isPersistConfig(resizable) && typeof resizable.width === 'number') { + return resizable.width } - - try { - const storedWidth = localStorage.getItem(widthStorageKey) + // Only try localStorage for default persister (resizable === true) + // Read directly here instead of via persister to satisfy react-hooks/refs lint rule + if (resizable === true) { + const storedWidth = localStoragePersister.get(widthStorageKey) if (storedWidth !== null) { - const parsed = Number(storedWidth) - if (!isNaN(parsed) && parsed > 0) { - return parsed - } + return storedWidth } - } catch { - // localStorage unavailable - keep default } - return defaultWidth }) + + // Inline state sync when width prop or resizable.width changes (avoids effect) + // See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [prevDefaultWidth, setPrevDefaultWidth] = React.useState(defaultWidth) + const controlledWidth = isPersistConfig(resizable) ? resizable.width : undefined + const [prevControlledWidth, setPrevControlledWidth] = React.useState(controlledWidth) + + // Handle controlled width changes + const controlledWidthChanged = controlledWidth !== prevControlledWidth + const defaultWidthChanged = defaultWidth !== prevDefaultWidth + + if (controlledWidthChanged) { + setPrevControlledWidth(controlledWidth) + if (typeof controlledWidth === 'number') { + // New controlled width provided + setCurrentWidth(controlledWidth) + } else if (prevControlledWidth !== undefined) { + // Controlled width was removed, fall back to default + setCurrentWidth(defaultWidth) + } + } + + if (defaultWidthChanged) { + setPrevDefaultWidth(defaultWidth) + // Only sync defaultWidth to currentWidth if there's no controlled width + if (controlledWidth === undefined && !controlledWidthChanged) { + setCurrentWidth(defaultWidth) + } + } + // Mutable ref for drag operations - avoids re-renders on every pixel move const currentWidthRef = React.useRef(currentWidth) // Max width for ARIA - SSR uses custom max or a sensible default, updated on mount const [maxPaneWidth, setMaxPaneWidth] = React.useState(() => customMaxWidth ?? SSR_DEFAULT_MAX_WIDTH) + // Keep currentWidthRef in sync with state (ref is used during drag to avoid re-renders) + useIsomorphicLayoutEffect(() => { + currentWidthRef.current = currentWidth + }, [currentWidth]) + // --- Callbacks --- const getDefaultWidth = React.useCallback(() => getDefaultPaneWidth(width), [width]) - const saveWidth = React.useCallback( - (value: number) => { - currentWidthRef.current = value - // Visual update already done via inline styles - React state sync is non-urgent - startTransition(() => { - setCurrentWidth(value) - }) + const saveWidth = React.useCallback((value: number) => { + currentWidthRef.current = value + // Visual update already done via inline styles - React state sync is non-urgent + startTransition(() => { + setCurrentWidth(value) + }) + + const config = resizableRef.current + + // Handle localStorage persistence: resizable === true or {persist: 'localStorage'} + if (config === true || (isPersistConfig(config) && config.persist === 'localStorage')) { + localStoragePersister.save(widthStorageKeyRef.current, value) + } else if (isPersistConfig(config) && isCustomPersistFunction(config.persist)) { try { - localStorage.setItem(widthStorageKey, value.toString()) + const result = config.persist(value, {widthStorageKey: widthStorageKeyRef.current}) + // Handle async rejections silently + if (result instanceof Promise) { + // eslint-disable-next-line github/no-then + result.catch(() => { + // Ignore - consumer should handle their own errors + }) + } } catch { - // Ignore write errors (private browsing, quota exceeded, etc.) + // Ignore sync errors } - }, - [widthStorageKey], - ) + } + }, []) // --- Effects --- // Stable ref to getMaxPaneWidth for use in resize handler without re-subscribing @@ -212,7 +355,7 @@ export function usePaneWidth({ // Update CSS variable, refs, and ARIA on mount and window resize. // Strategy: Only sync when resize stops (debounced) to avoid layout thrashing on large DOMs useIsomorphicLayoutEffect(() => { - if (!resizable) return + if (!isResizableEnabled(resizableRef.current)) return let lastViewportWidth = window.innerWidth @@ -324,7 +467,7 @@ export function usePaneWidth({ endResizeOptimizations() window.removeEventListener('resize', handleResize) } - }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef, contentWrapperRef]) + }, [customMaxWidth, minPaneWidth, paneRef, handleRef]) return { currentWidth,