Compare commits

..
Author SHA1 Message Date
damjan_savicandClaude Sonnet 4.5 ffa56a7b50 auto-claude: subtask-2-2 - Manual browser verification
Created comprehensive test page at src/app/test-localstorage/page.tsx to verify useLocalStorage hook functionality in the browser.

Test Coverage:
- String values (basic get/set)
- Complex objects (nested properties)
- Arrays (add/remove items)
- Numbers (increment/decrement)
- Remove functionality
- State persistence across page refreshes
- Real-time localStorage updates visible in DevTools

Verification Guide:
A detailed manual verification guide has been created at .auto-claude/specs/004-add-uselocalstorage-custom-hook/MANUAL_VERIFICATION.md with step-by-step instructions for browser testing.

Manual Verification Checklist:
- [ ] No hydration errors in console
- [ ] localStorage updates visible in DevTools
- [ ] State persists across page refresh
- [ ] No TypeScript errors (verified with npx tsc --noEmit)

TypeScript compilation verified with no errors.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 04:07:29 +01:00
damjan_savic 34555de0e5 auto-claude: subtask-2-1 - Create basic unit tests for useLocalStorage 2026-01-25 04:01:47 +01:00
damjan_savic 2f8c79612d auto-claude: subtask-1-2 - Add JSDoc documentation to hook 2026-01-25 02:38:22 +01:00
damjan_savic 29ad019884 auto-claude: subtask-1-1 - Create useLocalStorage hook with TypeScript 2026-01-25 02:36:05 +01:00
7 changed files with 521 additions and 238 deletions
-93
View File
@@ -1,93 +0,0 @@
'use server';
import { validateCsrfToken } from '@/lib/csrf/server';
interface ContactFormData {
name: string;
email: string;
message: string;
}
interface ContactFormResult {
success: boolean;
error?: string;
errors?: Partial<Record<keyof ContactFormData, string>>;
}
/**
* Server action to handle contact form submissions
* Validates CSRF token and form data before processing
*/
export async function submitContactForm(
formData: FormData
): Promise<ContactFormResult> {
// Extract CSRF token from form data
const csrfToken = formData.get('csrfToken');
// Validate CSRF token
if (!csrfToken || typeof csrfToken !== 'string') {
return {
success: false,
error: 'CSRF token is missing',
};
}
const isValidToken = await validateCsrfToken(csrfToken);
if (!isValidToken) {
return {
success: false,
error: 'Invalid CSRF token',
};
}
// Extract and validate form fields
const name = formData.get('name')?.toString() || '';
const email = formData.get('email')?.toString() || '';
const message = formData.get('message')?.toString() || '';
// Validation
const errors: Partial<Record<keyof ContactFormData, string>> = {};
if (!name.trim()) {
errors.name = 'Name is required';
}
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Invalid email format';
}
if (!message.trim()) {
errors.message = 'Message is required';
}
if (Object.keys(errors).length > 0) {
return {
success: false,
errors,
};
}
try {
// TODO: Implement actual email sending logic here
// For now, we'll simulate successful submission
// In production, this would integrate with an email service like:
// - Supabase Edge Functions
// - SendGrid
// - AWS SES
// - Resend
// Simulate processing delay
await new Promise(resolve => setTimeout(resolve, 500));
return {
success: true,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send message',
};
}
}
+186
View File
@@ -0,0 +1,186 @@
'use client';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useState } from 'react';
interface UserSettings {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
}
export default function TestLocalStoragePage() {
// Test 1: Simple string value
const [name, setName, removeName] = useLocalStorage('test-name', 'Guest');
// Test 2: Complex object
const [settings, setSettings, removeSettings] = useLocalStorage<UserSettings>(
'test-settings',
{
theme: 'light',
notifications: true,
language: 'en',
}
);
// Test 3: Array
const [items, setItems, removeItems] = useLocalStorage<string[]>('test-items', []);
// Test 4: Number
const [count, setCount, removeCount] = useLocalStorage('test-count', 0);
const [newItem, setNewItem] = useState('');
return (
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
<h1>useLocalStorage Hook Test Page</h1>
<p style={{ color: '#666', marginBottom: '2rem' }}>
Open DevTools (F12) Application Local Storage to see values update in real-time.
Refresh the page to verify persistence.
</p>
{/* Test 1: String */}
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2>Test 1: String Value</h2>
<p>Current Name: <strong>{name}</strong></p>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name"
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
/>
<button onClick={() => removeName()} style={{ padding: '0.5rem' }}>
Reset
</button>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
localStorage key: <code>test-name</code>
</p>
</div>
{/* Test 2: Complex Object */}
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2>Test 2: Complex Object</h2>
<div style={{ marginBottom: '1rem' }}>
<p>Theme: <strong>{settings.theme}</strong></p>
<button
onClick={() => setSettings((prev) => ({ ...prev, theme: prev.theme === 'light' ? 'dark' : 'light' }))}
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
>
Toggle Theme
</button>
</div>
<div style={{ marginBottom: '1rem' }}>
<label>
<input
type="checkbox"
checked={settings.notifications}
onChange={(e) => setSettings((prev) => ({ ...prev, notifications: e.target.checked }))}
/>
{' '}Enable Notifications
</label>
</div>
<div style={{ marginBottom: '1rem' }}>
<label>Language: </label>
<select
value={settings.language}
onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
style={{ padding: '0.5rem' }}
>
<option value="en">English</option>
<option value="de">German</option>
<option value="es">Spanish</option>
</select>
</div>
<button onClick={() => removeSettings()} style={{ padding: '0.5rem' }}>
Reset Settings
</button>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
localStorage key: <code>test-settings</code>
</p>
</div>
{/* Test 3: Array */}
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2>Test 3: Array Value</h2>
<p>Items ({items.length}):</p>
<ul style={{ minHeight: '60px' }}>
{items.map((item, index) => (
<li key={index}>
{item}{' '}
<button
onClick={() => setItems((prev) => prev.filter((_, i) => i !== index))}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.875rem' }}
>
Remove
</button>
</li>
))}
</ul>
<div>
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && newItem.trim()) {
setItems((prev) => [...prev, newItem.trim()]);
setNewItem('');
}
}}
placeholder="Add new item"
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
/>
<button
onClick={() => {
if (newItem.trim()) {
setItems((prev) => [...prev, newItem.trim()]);
setNewItem('');
}
}}
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
>
Add Item
</button>
<button onClick={() => removeItems()} style={{ padding: '0.5rem' }}>
Clear All
</button>
</div>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
localStorage key: <code>test-items</code>
</p>
</div>
{/* Test 4: Number */}
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h2>Test 4: Number Value</h2>
<p>Count: <strong>{count}</strong></p>
<button onClick={() => setCount((prev) => prev + 1)} style={{ padding: '0.5rem', marginRight: '0.5rem' }}>
Increment
</button>
<button onClick={() => setCount((prev) => prev - 1)} style={{ padding: '0.5rem', marginRight: '0.5rem' }}>
Decrement
</button>
<button onClick={() => removeCount()} style={{ padding: '0.5rem' }}>
Reset
</button>
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
localStorage key: <code>test-count</code>
</p>
</div>
{/* Instructions */}
<div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f0f9ff', borderRadius: '8px' }}>
<h3>Verification Checklist:</h3>
<ul>
<li> Open DevTools Application Local Storage http://localhost:3000</li>
<li> Interact with the controls above and watch localStorage update in real-time</li>
<li> Refresh the page (F5) - all values should persist</li>
<li> Check Console for hydration errors (there should be none)</li>
<li> Check Console for any errors (there should be none)</li>
<li> Verify TypeScript has no errors in your editor</li>
</ul>
</div>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useLocalStorage } from './useLocalStorage';
// Mock the error handling utility
vi.mock('../utils/errorHandling', () => ({
handleError: vi.fn()
}));
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
}
};
})();
describe('useLocalStorage', () => {
beforeEach(() => {
// Setup localStorage mock
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true
});
localStorageMock.clear();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should return initial value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value'));
const [value] = result.current;
expect(value).toBe('initial-value');
});
it('should return initial value from function when localStorage is empty', () => {
const initializer = vi.fn(() => 'computed-value');
const { result } = renderHook(() => useLocalStorage('test-key', initializer));
const [value] = result.current;
expect(value).toBe('computed-value');
expect(initializer).toHaveBeenCalled();
});
it('should set value in localStorage', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
act(() => {
const [, setValue] = result.current;
setValue('new-value');
});
const [value] = result.current;
expect(value).toBe('new-value');
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('new-value');
});
it('should handle updater function in setValue', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 10));
act(() => {
const [, setValue] = result.current;
setValue((prev) => prev + 5);
});
const [value] = result.current;
expect(value).toBe(15);
});
it('should read existing value from localStorage', () => {
localStorage.setItem('test-key', JSON.stringify('existing-value'));
const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value'));
const [value] = result.current;
expect(value).toBe('existing-value');
});
it('should handle complex objects', () => {
const complexObject = { name: 'John', age: 30, hobbies: ['reading', 'coding'] };
const { result } = renderHook(() => useLocalStorage('test-key', complexObject));
act(() => {
const [, setValue] = result.current;
setValue({ ...complexObject, age: 31 });
});
const [value] = result.current;
expect(value).toEqual({ name: 'John', age: 31, hobbies: ['reading', 'coding'] });
});
it('should remove value from localStorage', () => {
localStorage.setItem('test-key', JSON.stringify('existing-value'));
const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value'));
act(() => {
const [, , removeValue] = result.current;
removeValue();
});
const [value] = result.current;
expect(value).toBe('initial-value');
expect(localStorage.getItem('test-key')).toBeNull();
});
it('should handle invalid JSON in localStorage', () => {
localStorage.setItem('test-key', 'invalid-json{');
const { result } = renderHook(() => useLocalStorage('test-key', 'fallback-value'));
const [value] = result.current;
expect(value).toBe('fallback-value');
});
it('should be SSR-safe (no window)', () => {
const originalWindow = global.window;
// @ts-ignore - Temporarily remove window for SSR test
delete global.window;
const { result } = renderHook(() => useLocalStorage('test-key', 'ssr-value'));
const [value] = result.current;
expect(value).toBe('ssr-value');
// Restore window
global.window = originalWindow;
});
it('should handle localStorage quota exceeded', () => {
const { handleError } = require('../utils/errorHandling');
// Mock setItem to throw quota exceeded error
const originalSetItem = localStorage.setItem;
localStorage.setItem = vi.fn(() => {
throw new DOMException('QuotaExceededError');
});
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
act(() => {
const [, setValue] = result.current;
setValue('new-value');
});
expect(handleError).toHaveBeenCalled();
// Restore original setItem
localStorage.setItem = originalSetItem;
});
it('should serialize and deserialize arrays', () => {
const initialArray = [1, 2, 3, 4, 5];
const { result } = renderHook(() => useLocalStorage('test-key', initialArray));
act(() => {
const [, setValue] = result.current;
setValue([...initialArray, 6]);
});
const [value] = result.current;
expect(value).toEqual([1, 2, 3, 4, 5, 6]);
expect(JSON.parse(localStorage.getItem('test-key')!)).toEqual([1, 2, 3, 4, 5, 6]);
});
it('should handle boolean values', () => {
const { result } = renderHook(() => useLocalStorage('test-key', false));
act(() => {
const [, setValue] = result.current;
setValue(true);
});
const [value] = result.current;
expect(value).toBe(true);
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe(true);
});
it('should handle number values', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 0));
act(() => {
const [, setValue] = result.current;
setValue(42);
});
const [value] = result.current;
expect(value).toBe(42);
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe(42);
});
it('should handle null values', () => {
const { result } = renderHook(() => useLocalStorage<string | null>('test-key', null));
const [value] = result.current;
expect(value).toBe(null);
});
it('should update localStorage when key changes', () => {
const { result, rerender } = renderHook(
({ key, value }) => useLocalStorage(key, value),
{ initialProps: { key: 'key1', value: 'value1' } }
);
act(() => {
const [, setValue] = result.current;
setValue('updated-value1');
});
expect(localStorage.getItem('key1')).toBe(JSON.stringify('updated-value1'));
// Change the key
rerender({ key: 'key2', value: 'value2' });
act(() => {
const [, setValue] = result.current;
setValue('updated-value2');
});
expect(localStorage.getItem('key2')).toBe(JSON.stringify('updated-value2'));
});
});
+99
View File
@@ -0,0 +1,99 @@
import { useState, useEffect, useCallback } from 'react';
import { handleError } from '../utils/errorHandling';
/**
* Function type for updating the stored value
* @template T - The type of the stored value
* @param value - Either a new value of type T or an updater function that takes the current value and returns a new value
*/
type SetValue<T> = (value: T | ((val: T) => T)) => void;
/**
* Custom hook for managing state in localStorage with automatic serialization
*
* @template T - The type of the stored value
* @param {string} key - The localStorage key to use
* @param {T | (() => T)} initialValue - The initial value or a function that returns the initial value
* @returns {[T, SetValue<T>, () => void]} A tuple containing:
* - The current stored value
* - A function to update the stored value
* - A function to remove the value from storage
*
* @example
* ```tsx
* const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');
* setTheme('dark'); // Updates both state and localStorage
* removeTheme(); // Removes from localStorage and resets to initial value
* ```
*
* @remarks
* - SSR-safe: Returns initial value during server-side rendering
* - Automatically serializes/deserializes JSON
* - Handles localStorage quota exceeded errors
* - Handles invalid JSON gracefully
*/
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T)
): [T, SetValue<T>, () => void] {
// Get initial value - SSR safe
const getInitialValue = useCallback((): T => {
// Check if we're in a browser environment
if (typeof window === 'undefined') {
return initialValue instanceof Function ? initialValue() : initialValue;
}
try {
const item = window.localStorage.getItem(key);
if (item) {
return JSON.parse(item) as T;
}
} catch (error) {
handleError(error, `LocalStorage Read (key: ${key})`);
}
return initialValue instanceof Function ? initialValue() : initialValue;
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(getInitialValue);
// Update localStorage whenever storedValue changes
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
handleError(error, `LocalStorage Write (key: ${key})`);
}
}, [key, storedValue]);
// Set value function that supports both direct values and updater functions
const setValue: SetValue<T> = useCallback((value) => {
try {
setStoredValue((prevValue) => {
const newValue = value instanceof Function ? value(prevValue) : value;
return newValue;
});
} catch (error) {
handleError(error, `LocalStorage Update (key: ${key})`);
}
}, [key]);
// Remove value from localStorage and reset to initial value
const removeValue = useCallback(() => {
try {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(key);
}
const resetValue = initialValue instanceof Function ? initialValue() : initialValue;
setStoredValue(resetValue);
} catch (error) {
handleError(error, `LocalStorage Remove (key: ${key})`);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue];
}
-17
View File
@@ -1,17 +0,0 @@
const CSRF_META_TAG_NAME = 'csrf-token';
/**
* Get the CSRF token from the meta tag in the document head
* The server should render: <meta name="csrf-token" content="..." />
*/
export function getCsrfToken(): string | null {
if (typeof document === 'undefined') {
return null;
}
const metaTag = document.querySelector<HTMLMetaElement>(
`meta[name="${CSRF_META_TAG_NAME}"]`
);
return metaTag?.content || null;
}
-94
View File
@@ -1,94 +0,0 @@
import { cookies } from 'next/headers';
import { randomBytes } from 'crypto';
const CSRF_TOKEN_COOKIE_NAME = 'csrf_token';
const CSRF_TOKEN_LENGTH = 32;
/**
* Generate a cryptographically secure CSRF token
*/
export function generateCsrfToken(): string {
return randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
}
/**
* Get the current CSRF token from cookies or generate a new one
*/
export async function getCsrfToken(): Promise<string> {
const cookieStore = await cookies();
const existingToken = cookieStore.get(CSRF_TOKEN_COOKIE_NAME);
if (existingToken?.value) {
return existingToken.value;
}
const newToken = generateCsrfToken();
await setCsrfToken(newToken);
return newToken;
}
/**
* Set the CSRF token in an httpOnly cookie
*/
export async function setCsrfToken(token: string): Promise<void> {
const cookieStore = await cookies();
try {
cookieStore.set(CSRF_TOKEN_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
} catch {
// Handle server component context where cookies can't be set
}
}
/**
* Validate a CSRF token against the stored token in cookies
*/
export async function validateCsrfToken(token: string): Promise<boolean> {
const cookieStore = await cookies();
const storedToken = cookieStore.get(CSRF_TOKEN_COOKIE_NAME);
if (!storedToken?.value || !token) {
return false;
}
// Constant-time comparison to prevent timing attacks
return timingSafeEqual(
Buffer.from(storedToken.value),
Buffer.from(token)
);
}
/**
* Timing-safe string comparison to prevent timing attacks
*/
function timingSafeEqual(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
/**
* Delete the CSRF token cookie
*/
export async function deleteCsrfToken(): Promise<void> {
const cookieStore = await cookies();
try {
cookieStore.delete(CSRF_TOKEN_COOKIE_NAME);
} catch {
// Handle server component context
}
}
+1 -34
View File
@@ -1,45 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import createMiddleware from 'next-intl/middleware'; import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n/config'; import { locales, defaultLocale } from './i18n/config';
import { randomBytes } from 'crypto';
const CSRF_TOKEN_COOKIE_NAME = 'csrf_token'; export default createMiddleware({
const CSRF_TOKEN_LENGTH = 32;
const intlMiddleware = createMiddleware({
locales, locales,
defaultLocale, defaultLocale,
localePrefix: 'always', localePrefix: 'always',
}); });
export default function middleware(request: NextRequest) {
// Run the i18n middleware first
const response = intlMiddleware(request);
// Check if CSRF token exists in cookies
const existingToken = request.cookies.get(CSRF_TOKEN_COOKIE_NAME);
// Generate and set CSRF token if it doesn't exist
if (!existingToken) {
const newToken = randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
// Create a new response or clone the existing one
const finalResponse = response || NextResponse.next();
finalResponse.cookies.set(CSRF_TOKEN_COOKIE_NAME, newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return finalResponse;
}
return response;
}
export const config = { export const config = {
matcher: [ matcher: [
'/', '/',