diff --git a/README.md b/README.md
index 68aae4d6..bccac9a5 100644
--- a/README.md
+++ b/README.md
@@ -200,6 +200,7 @@ The divider is fully keyboard accessible:
- **Shift + Arrow**: Resize by larger step (default: 50px)
- **Home**: Minimize left/top pane
- **End**: Maximize left/top pane
+- **Escape**: Restore pane sizes to initial state
- **Tab**: Navigate between dividers
## API Reference
@@ -342,112 +343,7 @@ A subtle single-pixel divider:
## Tailwind CSS & shadcn/ui
-React Split Pane works seamlessly with Tailwind CSS and shadcn/ui. The component uses plain CSS and inline styles (no CSS-in-JS), so there are no conflicts with utility-first frameworks.
-
-### Using Tailwind Classes
-
-Apply Tailwind classes directly via `className` props. Skip importing the default stylesheet for full Tailwind control:
-
-```tsx
-import { SplitPane, Pane } from 'react-split-pane';
-// Don't import 'react-split-pane/styles.css' if using Tailwind
-
-
-
-
-
-
-
-
-
-```
-
-### shadcn/ui Integration
-
-Use shadcn's CSS variables and utilities for consistent theming:
-
-```tsx
-import { SplitPane, Pane } from 'react-split-pane';
-
-
-
-
-
-
-
-
-
-```
-
-### Custom Divider with shadcn
-
-Create a themed divider component using shadcn's `cn` utility:
-
-```tsx
-import { cn } from '@/lib/utils';
-import type { DividerProps } from 'react-split-pane';
-
-function ThemedDivider({ direction, isDragging, disabled, ...props }: DividerProps) {
- return (
-
(
...style,
};
- const combinedClassName = [DEFAULT_CLASSNAME, className]
- .filter(Boolean)
- .join(' ');
+ const combinedClassName = cn(DEFAULT_CLASSNAME, className);
return (
{
diff --git a/src/hooks/useResizer.ts b/src/hooks/useResizer.ts
index 23312df4..e11b49e5 100644
--- a/src/hooks/useResizer.ts
+++ b/src/hooks/useResizer.ts
@@ -83,14 +83,17 @@ export function useResizer(options: UseResizerOptions): UseResizerResult {
const onResizeEndRef = useRef(onResizeEnd);
onResizeEndRef.current = onResizeEnd;
- // Update current sizes when prop sizes change (only when not dragging)
- if (
- !isDragging &&
- sizes !== currentSizes &&
- JSON.stringify(sizes) !== JSON.stringify(currentSizes)
- ) {
- setCurrentSizes(sizes);
- }
+ // Sync sizes from props when not dragging (React 19 compatible)
+ const sizesRef = useRef(sizes);
+ useEffect(() => {
+ if (
+ !isDragging &&
+ JSON.stringify(sizes) !== JSON.stringify(sizesRef.current)
+ ) {
+ sizesRef.current = sizes;
+ setCurrentSizes(sizes);
+ }
+ }, [sizes, isDragging]);
// Track mounted state for RAF cleanup
useEffect(() => {
diff --git a/src/index.ts b/src/index.ts
index d154e8c4..d1cce943 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,7 +9,6 @@ export type {
Direction,
Size,
ResizeEvent,
- PaneState,
} from './types';
// Re-export hooks for advanced usage
diff --git a/src/persistence.test.ts b/src/persistence.test.ts
new file mode 100644
index 00000000..e6778e0d
--- /dev/null
+++ b/src/persistence.test.ts
@@ -0,0 +1,255 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { usePersistence } from './persistence';
+
+describe('usePersistence', () => {
+ let mockStorage: Storage;
+
+ beforeEach(() => {
+ mockStorage = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+ length: 0,
+ key: vi.fn(),
+ };
+ });
+
+ describe('initialization', () => {
+ it('returns empty array when storage is empty', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ expect(result.current[0]).toEqual([]);
+ });
+
+ it('parses and returns stored sizes', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(
+ '[300, 500]'
+ );
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ expect(result.current[0]).toEqual([300, 500]);
+ });
+
+ it('returns empty array on parse error', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(
+ 'invalid json'
+ );
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ expect(result.current[0]).toEqual([]);
+ });
+
+ it('handles storage access error gracefully', () => {
+ (mockStorage.getItem as ReturnType).mockImplementation(
+ () => {
+ throw new Error('Storage access denied');
+ }
+ );
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ expect(result.current[0]).toEqual([]);
+ });
+ });
+
+ describe('setSizes', () => {
+ it('updates sizes state', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ expect(result.current[0]).toEqual([400, 600]);
+ });
+
+ it('persists sizes to storage after debounce', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage, debounce: 100 })
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ // Before debounce
+ expect(mockStorage.setItem).not.toHaveBeenCalled();
+
+ // After debounce
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(mockStorage.setItem).toHaveBeenCalledWith('test-key', '[400,600]');
+ });
+
+ it('uses default debounce of 300ms', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(299);
+ });
+ expect(mockStorage.setItem).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(mockStorage.setItem).toHaveBeenCalled();
+ });
+
+ it('does not persist empty sizes array', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+
+ expect(mockStorage.setItem).not.toHaveBeenCalled();
+ });
+
+ it('handles storage write error gracefully', () => {
+ const consoleWarnSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {});
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+ (mockStorage.setItem as ReturnType).mockImplementation(
+ () => {
+ throw new Error('Storage quota exceeded');
+ }
+ );
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage })
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'Failed to persist pane sizes:',
+ expect.any(Error)
+ );
+
+ consoleWarnSpy.mockRestore();
+ });
+ });
+
+ describe('debounce behavior', () => {
+ it('cancels pending save on new update', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage, debounce: 100 })
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+
+ act(() => {
+ result.current[1]([500, 500]);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(mockStorage.setItem).toHaveBeenCalledTimes(1);
+ expect(mockStorage.setItem).toHaveBeenCalledWith('test-key', '[500,500]');
+ });
+
+ it('cleans up timeout on unmount', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result, unmount } = renderHook(() =>
+ usePersistence({ key: 'test-key', storage: mockStorage, debounce: 100 })
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ unmount();
+
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(mockStorage.setItem).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('key changes', () => {
+ it('uses new key for storage operations', () => {
+ (mockStorage.getItem as ReturnType).mockReturnValue(null);
+
+ const { result, rerender } = renderHook(
+ ({ key }) => usePersistence({ key, storage: mockStorage }),
+ { initialProps: { key: 'key-1' } }
+ );
+
+ act(() => {
+ result.current[1]([400, 600]);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(mockStorage.setItem).toHaveBeenCalledWith('key-1', '[400,600]');
+
+ rerender({ key: 'key-2' });
+
+ act(() => {
+ result.current[1]([200, 800]);
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(mockStorage.setItem).toHaveBeenCalledWith('key-2', '[200,800]');
+ });
+ });
+});
diff --git a/src/types/index.ts b/src/types/index.ts
index 20732836..bfd9f0ca 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -116,10 +116,3 @@ export interface DividerProps {
/** Custom content */
children?: ReactNode | undefined;
}
-
-export interface PaneState {
- size: number;
- minSize: number;
- maxSize: number;
- defaultSize: number;
-}
diff --git a/src/utils/accessibility.test.ts b/src/utils/accessibility.test.ts
index 4a0d61f2..ac33b816 100644
--- a/src/utils/accessibility.test.ts
+++ b/src/utils/accessibility.test.ts
@@ -17,6 +17,20 @@ describe('announce', () => {
document.body.innerHTML = '';
});
+ it('is SSR-safe and does nothing when document is undefined', () => {
+ const originalDocument = globalThis.document;
+
+ // Simulate SSR environment
+ // @ts-expect-error - Intentionally setting document to undefined for SSR test
+ delete globalThis.document;
+
+ // Should not throw
+ expect(() => announce('Test message')).not.toThrow();
+
+ // Restore document
+ globalThis.document = originalDocument;
+ });
+
it('creates an announcement element with correct attributes', () => {
announce('Test message');
diff --git a/src/utils/accessibility.ts b/src/utils/accessibility.ts
index 28ce5ae7..ef11f3f2 100644
--- a/src/utils/accessibility.ts
+++ b/src/utils/accessibility.ts
@@ -1,10 +1,16 @@
/**
- * Announce a message to screen readers
+ * Announce a message to screen readers.
+ * SSR-safe: no-op when document is not available.
*/
export function announce(
message: string,
priority: 'polite' | 'assertive' = 'polite'
): void {
+ // SSR safety check
+ if (typeof document === 'undefined') {
+ return;
+ }
+
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', priority);
diff --git a/src/utils/classNames.test.ts b/src/utils/classNames.test.ts
new file mode 100644
index 00000000..1e743e1f
--- /dev/null
+++ b/src/utils/classNames.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { cn } from './classNames';
+
+describe('cn', () => {
+ it('combines multiple class names', () => {
+ expect(cn('foo', 'bar')).toBe('foo bar');
+ });
+
+ it('filters out false values', () => {
+ const shouldInclude = false;
+ expect(cn('foo', shouldInclude && 'bar', 'baz')).toBe('foo baz');
+ });
+
+ it('filters out undefined values', () => {
+ expect(cn('foo', undefined, 'bar')).toBe('foo bar');
+ });
+
+ it('filters out null values', () => {
+ expect(cn('foo', null, 'bar')).toBe('foo bar');
+ });
+
+ it('filters out empty strings', () => {
+ expect(cn('foo', '', 'bar')).toBe('foo bar');
+ });
+
+ it('returns empty string when no valid classes', () => {
+ expect(cn(false, undefined, null)).toBe('');
+ });
+
+ it('handles single class name', () => {
+ expect(cn('foo')).toBe('foo');
+ });
+
+ it('handles conditional class names', () => {
+ const isActive = true;
+ const isDisabled = false;
+ expect(cn('base', isActive && 'active', isDisabled && 'disabled')).toBe(
+ 'base active'
+ );
+ });
+});
diff --git a/src/utils/classNames.ts b/src/utils/classNames.ts
new file mode 100644
index 00000000..8d013b93
--- /dev/null
+++ b/src/utils/classNames.ts
@@ -0,0 +1,19 @@
+/**
+ * Combines class names, filtering out falsy values.
+ * Similar to the popular `clsx` or `classnames` packages.
+ *
+ * @param classes - Class names to combine (strings, undefined, null, false)
+ * @returns Combined class name string
+ *
+ * @example
+ * ```ts
+ * cn('foo', 'bar') // 'foo bar'
+ * cn('foo', false && 'bar', 'baz') // 'foo baz'
+ * cn('foo', undefined, 'bar') // 'foo bar'
+ * ```
+ */
+export function cn(
+ ...classes: (string | boolean | undefined | null)[]
+): string {
+ return classes.filter(Boolean).join(' ');
+}