{
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,