diff --git a/.changeset/pagelayout-resizable-persistence.md b/.changeset/pagelayout-resizable-persistence.md new file mode 100644 index 00000000000..2e8cb1f4e83 --- /dev/null +++ b/.changeset/pagelayout-resizable-persistence.md @@ -0,0 +1,67 @@ +--- +'@primer/react': minor +--- + +Add custom persistence options to PageLayout.Pane's `resizable` prop and number support for `width` + +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) +- `{save: fn}` - Enable resizing with custom persistence (e.g., server-side, IndexedDB) + +The `width` prop now accepts numbers in addition to named sizes and custom objects: + +- `'small' | 'medium' | 'large'` - Preset width names (existing behavior) +- `number` - Explicit pixel width (uses `minWidth` prop and viewport-based max) **NEW** +- `{min, default, max}` - Custom width configuration (existing behavior) + +**New types exported:** + +- `NoPersistConfig` - Type for `{persist: false}` configuration +- `CustomPersistConfig` - Type for `{save: fn}` configuration +- `SaveOptions` - Options passed to custom save function (`{widthStorageKey: string}`) +- `ResizableConfig` - Union type for all resizable configurations +- `PaneWidth` - Type for preset width names (`'small' | 'medium' | 'large'`) +- `PaneWidthValue` - Union type for all width values (`PaneWidth | number | 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 + + +// Custom persistence - save to your own storage + { + // Save to server, IndexedDB, sessionStorage, etc. + myStorage.set(widthStorageKey, width) + } + }} +/> + +// Number width - uses minWidth prop and viewport-based max constraints +const [savedWidth, setSavedWidth] = useState(defaultPaneWidth.medium) + setSavedWidth(width) + }} +/> + +// Using defaultPaneWidth for custom width configurations +import {defaultPaneWidth} from '@primer/react' + +const widthConfig = { + min: '256px', + default: `${defaultPaneWidth.medium}px`, + max: '600px' +} + +``` diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index aef5c9346b0..03a6d297e4d 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -1,9 +1,12 @@ 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 type {CustomWidthOptions} from './usePaneWidth' +import {defaultPaneWidth} from './usePaneWidth' export default { title: 'Components/PageLayout/Features', @@ -358,3 +361,118 @@ 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 = (): CustomWidthOptions => { + const base: CustomWidthOptions = {min: '256px', default: `${defaultPaneWidth.medium}px`, max: '600px'} + if (typeof window !== 'undefined') { + const storedWidth = localStorage.getItem(key) + if (storedWidth !== null) { + const parsed = parseFloat(storedWidth) + if (!isNaN(parsed) && parsed > 0) { + return {...base, default: `${parsed}px`} + } + } + } + return base + } + + const [widthConfig, setWidthConfig] = React.useState(getInitialWidth) + useIsomorphicLayoutEffect(() => { + setWidthConfig(getInitialWidth()) + }, []) + return ( + + + + + { + setWidthConfig(prev => ({...prev, default: `${width}px`})) + 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 [width, setWidth] = React.useState(getInitialWidth) + + return ( + + + + + { + setWidth(newWidth) + localStorage.setItem(key, newWidth.toString()) + }, + }} + aria-label="Side pane" + > + + + + + + + + + + ) +} +ResizablePaneWithNumberWidth.storyName = 'Resizable pane with number width' diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 901db1c5120..231e885e09a 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -16,9 +16,10 @@ import { updateAriaValues, isCustomWidthOptions, isPaneWidth, + isNumericWidth, ARROW_KEY_STEP, - type CustomWidthOptions, - type PaneWidth, + type PaneWidthValue, + type ResizableConfig, } from './usePaneWidth' const REGION_ORDER = { @@ -559,9 +560,22 @@ export type PageLayoutPaneProps = { positionWhenNarrow?: 'inherit' | keyof typeof panePositions 'aria-labelledby'?: string 'aria-label'?: string - width?: PaneWidth | CustomWidthOptions + /** + * The width of the pane. + * - Named sizes: `'small'` | `'medium'` | `'large'` + * - Number: explicit pixel width (uses `minWidth` prop and viewport-based max) + * - Custom object: `{min: string, default: string, max: string}` + */ + width?: PaneWidthValue minWidth?: number - resizable?: boolean + /** + * Enable resizable pane behavior. + * - `true`: Enable with default localStorage persistence + * - `false`: Disable resizing + * - `{persist: false}`: Enable without persistence (no hydration issues) + * - `{save: fn}`: Enable with custom persistence (e.g., server-side, IndexedDB) + */ + resizable?: ResizableConfig widthStorageKey?: string padding?: keyof typeof SPACING_MAP divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'> @@ -709,10 +723,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() + }) }) describe('saveWidth', () => { @@ -181,6 +226,176 @@ describe('usePaneWidth', () => { localStorage.setItem = originalSetItem }) + + it('should call custom save function with width and options', () => { + const customSave = vi.fn() + const customPersister: CustomPersistConfig = {save: 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: CustomPersistConfig = {save: 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: CustomPersistConfig = {save: 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: CustomPersistConfig = {save: 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: CustomPersistConfig = {save: vi.fn()} + 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', () => { @@ -769,3 +984,67 @@ 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 {save: fn} (custom persistence)', () => { + expect(isResizableEnabled({save: () => {}})).toBe(true) + }) + }) + + describe('isNoPersistConfig', () => { + it('should return true for {persist: false}', () => { + expect(isNoPersistConfig({persist: false})).toBe(true) + }) + + it('should return false for boolean true', () => { + expect(isNoPersistConfig(true)).toBe(false) + }) + + it('should return false for boolean false', () => { + expect(isNoPersistConfig(false)).toBe(false) + }) + + it('should return false for objects without persist: false', () => { + // @ts-expect-error - testing runtime behavior with arbitrary object + expect(isNoPersistConfig({other: 'value'})).toBe(false) + }) + + it('should return false for custom persist config', () => { + expect(isNoPersistConfig({save: () => {}})).toBe(false) + }) + }) + + describe('isCustomPersistConfig', () => { + it('should return true for objects with save function', () => { + expect(isCustomPersistConfig({save: () => {}})).toBe(true) + expect(isCustomPersistConfig({save: async () => {}})).toBe(true) + }) + + it('should return false for boolean values', () => { + expect(isCustomPersistConfig(true)).toBe(false) + expect(isCustomPersistConfig(false)).toBe(false) + }) + + it('should return false for {persist: false}', () => { + expect(isCustomPersistConfig({persist: false})).toBe(false) + }) + + it('should return false for null', () => { + // @ts-expect-error - testing runtime behavior + expect(isCustomPersistConfig(null)).toBe(false) + }) + }) +}) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 2449901b0a0..eab9c6eccb4 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,54 @@ export type CustomWidthOptions = { export type PaneWidth = 'small' | 'medium' | 'large' +/** + * Width value for the pane. + * - `PaneWidth`: Preset size ('small' | 'medium' | 'large') + * - `number`: Custom width in pixels (uses minWidth prop and viewport-based max) + * - `CustomWidthOptions`: Explicit min/default/max constraints + */ +export type PaneWidthValue = PaneWidth | number | CustomWidthOptions + +/** + * Configuration for resizable without persistence. + * Use this to enable resizing without storing the width anywhere. + */ +export type NoPersistConfig = {persist: false} + +/** + * Options passed to custom save function. + */ +export type SaveOptions = {widthStorageKey: string} + +/** + * Configuration for custom persistence. + * Provide your own save function to persist width to server, IndexedDB, etc. + */ +export type CustomPersistConfig = { + save: (width: number, options: SaveOptions) => void | Promise +} + +/** + * Type guard to check if resizable config has a custom save function + */ +export const isCustomPersistConfig = (config: ResizableConfig): config is CustomPersistConfig => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- config could be null at runtime despite types + return typeof config === 'object' && config !== null && 'save' in config && typeof config.save === 'function' +} + +/** + * Resizable configuration options. + * - `true`: Enable resizing with default localStorage persistence (may cause hydration mismatch) + * - `false`: Disable resizing + * - `{persist: false}`: Enable resizing without any persistence + * - `{save: fn}`: Enable resizing with custom persistence + */ +export type ResizableConfig = boolean | NoPersistConfig | CustomPersistConfig + export type UsePaneWidthOptions = { - width: PaneWidth | CustomWidthOptions + width: PaneWidthValue minWidth: number - resizable: boolean + resizable: ResizableConfig widthStorageKey: string paneRef: React.RefObject handleRef: React.RefObject @@ -71,24 +114,44 @@ 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: PaneWidthValue): width is PaneWidth => { + return typeof width === 'string' && ['small', 'medium', 'large'].includes(width) } -export const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => { - return ['small', 'medium', 'large'].includes(width as PaneWidth) +export const isNumericWidth = (width: PaneWidthValue): width is number => { + return typeof width === 'number' } -export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { +export const getDefaultPaneWidth = (w: PaneWidthValue): number => { if (isPaneWidth(w)) { return defaultPaneWidth[w] + } else if (isNumericWidth(w)) { + return w } else if (isCustomWidthOptions(w)) { return parseInt(w.default, 10) } return 0 } +/** + * Type guard to check if resizable config is {persist: false} + */ +export const isNoPersistConfig = (config: ResizableConfig): config is NoPersistConfig => { + // 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 && config.persist === false +} + +/** + * Check if resizing is enabled (boolean true, {persist: false}, or {save: fn}) + */ +export const isResizableEnabled = (config: ResizableConfig): boolean => { + return config === true || isNoPersistConfig(config) || isCustomPersistConfig(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. @@ -115,13 +178,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, @@ -136,6 +228,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) @@ -147,55 +248,70 @@ 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. + // For resizable=true (localStorage), we read synchronously in initializer to avoid flash on CSR. + // This causes hydration mismatch (server renders default, client reads localStorage) which is + // suppressed via suppressHydrationWarning on the pane element. + // For other resizable configs ({persist: false}), consumer provides initial via `width` prop. const [currentWidth, setCurrentWidth] = React.useState(() => { - const defaultWidth = getDefaultPaneWidth(width) - - if (!resizable || !canUseDOM) { - return defaultWidth - } - - 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 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) + if (defaultWidth !== prevDefaultWidth) { + setPrevDefaultWidth(defaultWidth) + 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 - setCurrentWidth(value) + const saveWidth = React.useCallback((value: number) => { + currentWidthRef.current = value + setCurrentWidth(value) + + const config = resizableRef.current + + // Handle localStorage persistence: resizable === true + if (config === true) { + localStoragePersister.save(widthStorageKeyRef.current, value) + } else if (isCustomPersistConfig(config)) { try { - localStorage.setItem(widthStorageKey, value.toString()) + const result = config.save(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 @@ -209,7 +325,7 @@ export function usePaneWidth({ // 1. Throttled (16ms): Update --pane-max-width CSS variable for immediate visual clamp // 2. Debounced (150ms): Sync refs, ARIA, and React state when resize stops useIsomorphicLayoutEffect(() => { - if (!resizable) return + if (!isResizableEnabled(resizableRef.current)) return let lastViewportWidth = window.innerWidth @@ -309,7 +425,7 @@ export function usePaneWidth({ if (debounceId !== null) clearTimeout(debounceId) window.removeEventListener('resize', handleResize) } - }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef]) + }, [customMaxWidth, minPaneWidth, paneRef, handleRef]) return { currentWidth,