Skip to content

Commit 8a6dc92

Browse files
committed
fix: add console warning for corrupt sessionStorage data
1 parent 0861e10 commit 8a6dc92

4 files changed

Lines changed: 200 additions & 5 deletions

File tree

src/__tests__/TodoContext.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const TestComponent = () => {
3333
};
3434

3535
describe('TodoContext', () => {
36+
afterEach(() => {
37+
window.sessionStorage.clear();
38+
});
3639
it('provides empty todos array initially', () => {
3740
render(
3841
<TodoProvider>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { TodoProvider } from '../contexts/TodoContext';
4+
import { useTodo } from '../hooks/useTodo';
5+
6+
const STORAGE_KEY = 'todos';
7+
8+
afterEach(() => {
9+
window.sessionStorage.clear();
10+
});
11+
12+
const TestComponent = () => {
13+
const { todos, addTodo } = useTodo();
14+
return (
15+
<div>
16+
<button data-testid="add" onClick={() => addTodo('Persisted', 'desc')}>
17+
add
18+
</button>
19+
<div data-testid="count">{todos.length}</div>
20+
</div>
21+
);
22+
};
23+
24+
describe('sessionStorage integration', () => {
25+
it('hydrates from sessionStorage when valid data present', () => {
26+
window.sessionStorage.setItem(
27+
STORAGE_KEY,
28+
JSON.stringify([
29+
{
30+
id: '1',
31+
title: 'stored',
32+
description: 'desc',
33+
completed: false,
34+
createdAt: new Date().toISOString(),
35+
},
36+
])
37+
);
38+
39+
render(
40+
<TodoProvider>
41+
<TestComponent />
42+
</TodoProvider>
43+
);
44+
45+
expect(screen.getByTestId('count').textContent).toBe('1');
46+
});
47+
48+
it('falls back to empty array when corrupt JSON', () => {
49+
window.sessionStorage.setItem(STORAGE_KEY, 'not-json');
50+
render(
51+
<TodoProvider>
52+
<TestComponent />
53+
</TodoProvider>
54+
);
55+
56+
expect(screen.getByTestId('count').textContent).toBe('0');
57+
});
58+
59+
it('persists todos on change', async () => {
60+
const user = userEvent.setup();
61+
render(
62+
<TodoProvider>
63+
<TestComponent />
64+
</TodoProvider>
65+
);
66+
67+
await user.click(screen.getByTestId('add'));
68+
69+
await waitFor(() => {
70+
const stored = JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) || '[]');
71+
expect(stored.length).toBe(1);
72+
});
73+
});
74+
});

src/contexts/TodoContext.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,58 @@
1-
import React, { useState } from 'react';
1+
import React, { useEffect, useState } from 'react';
22
import type { Todo } from '../types/Todo';
33
import { v4 as uuidv4 } from 'uuid';
44
import { TodoContext } from './TodoContextType';
5+
import { loadTodos, saveTodos } from '../utils/sessionStorage';
56

7+
/**
8+
* Provider responsible for Todo state management.
9+
* Adds sessionStorage hydration / persistence with basic error handling.
10+
*/
611
export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
712
const [todos, setTodos] = useState<Todo[]>([]);
13+
const [toastMessage, setToastMessage] = useState<string | null>(null);
814

15+
// -----------------------
16+
// Hydrate from sessionStorage on mount
17+
// -----------------------
18+
useEffect(() => {
19+
const initial = loadTodos();
20+
if (initial.length) {
21+
setTodos(initial);
22+
}
23+
}, []);
24+
25+
// -----------------------
26+
// Persist to sessionStorage on every change
27+
// -----------------------
28+
useEffect(() => {
29+
const { ok, error } = saveTodos(todos);
30+
if (!ok && error) {
31+
/* eslint-disable-next-line no-console */
32+
console.warn('Failed to persist todos to sessionStorage:', error);
33+
if (
34+
(error as { name?: string; code?: number })?.name === 'QuotaExceededError' ||
35+
(error as { name?: string; code?: number })?.code === 22 ||
36+
(error as { name?: string; code?: number })?.code === 1014 // Firefox
37+
) {
38+
triggerToast('Storage quota exceeded – your latest changes may not be saved.');
39+
}
40+
}
41+
// eslint-disable-next-line react-hooks/exhaustive-deps
42+
}, [todos]);
43+
44+
// -----------------------
45+
// Toast helpers
46+
// -----------------------
47+
const triggerToast = (message: string) => {
48+
setToastMessage(message);
49+
// Auto-dismiss after 4s
50+
setTimeout(() => setToastMessage(null), 4000);
51+
};
52+
53+
// -----------------------
54+
// CRUD helpers
55+
// -----------------------
956
const addTodo = (title: string, description: string) => {
1057
const newTodo: Todo = {
1158
id: uuidv4(),
@@ -14,24 +61,44 @@ export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children
1461
completed: false,
1562
createdAt: new Date(),
1663
};
17-
setTodos([...todos, newTodo]);
64+
setTodos(prev => [...prev, newTodo]);
1865
};
1966

2067
const editTodo = (id: string, updates: Partial<Todo>) => {
21-
setTodos(todos.map(todo => (todo.id === id ? { ...todo, ...updates } : todo)));
68+
setTodos(prev => prev.map(todo => (todo.id === id ? { ...todo, ...updates } : todo)));
2269
};
2370

2471
const toggleTodoCompletion = (id: string) => {
25-
setTodos(todos.map(todo => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)));
72+
setTodos(prev =>
73+
prev.map(todo => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
74+
);
2675
};
2776

2877
const deleteTodo = (id: string) => {
29-
setTodos(todos.filter(todo => todo.id !== id));
78+
setTodos(prev => prev.filter(todo => todo.id !== id));
3079
};
3180

3281
return (
3382
<TodoContext.Provider value={{ todos, addTodo, editTodo, toggleTodoCompletion, deleteTodo }}>
3483
{children}
84+
{toastMessage && (
85+
<div
86+
role="alert"
87+
style={{
88+
position: 'fixed',
89+
bottom: '1rem',
90+
left: '50%',
91+
transform: 'translateX(-50%)',
92+
backgroundColor: '#333',
93+
color: '#fff',
94+
padding: '0.75rem 1rem',
95+
borderRadius: '4px',
96+
zIndex: 9999,
97+
}}
98+
>
99+
{toastMessage}
100+
</div>
101+
)}
35102
</TodoContext.Provider>
36103
);
37104
};

src/utils/sessionStorage.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Todo } from '../types/Todo';
2+
3+
const STORAGE_KEY = 'todos';
4+
5+
export const isValidTodos = (data: unknown): data is Todo[] => {
6+
if (!Array.isArray(data)) return false;
7+
return data.every(
8+
item =>
9+
item &&
10+
typeof item.id === 'string' &&
11+
typeof item.title === 'string' &&
12+
typeof item.description === 'string' &&
13+
typeof item.completed === 'boolean' &&
14+
// createdAt may be Date object or ISO string
15+
(item.createdAt instanceof Date || typeof item.createdAt === 'string')
16+
);
17+
};
18+
19+
export const loadTodos = (): Todo[] => {
20+
if (typeof window === 'undefined') return [];
21+
try {
22+
const raw = window.sessionStorage.getItem(STORAGE_KEY);
23+
if (!raw) return [];
24+
const parsed = JSON.parse(raw);
25+
if (!isValidTodos(parsed)) {
26+
window.sessionStorage.removeItem(STORAGE_KEY);
27+
return [];
28+
}
29+
// Map createdAt strings to Date objects
30+
return parsed.map(item => ({
31+
...item,
32+
createdAt: item.createdAt instanceof Date ? item.createdAt : new Date(item.createdAt),
33+
}));
34+
} catch (error) {
35+
/* eslint-disable-next-line no-console */
36+
console.warn('Failed to parse todos from sessionStorage:', error);
37+
// Corrupt JSON, clear storage
38+
window.sessionStorage.removeItem(STORAGE_KEY);
39+
return [];
40+
}
41+
};
42+
43+
export const saveTodos = (todos: Todo[]): { ok: boolean; error?: Error } => {
44+
if (typeof window === 'undefined') return { ok: true };
45+
try {
46+
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
47+
return { ok: true };
48+
} catch (err: unknown) {
49+
return { ok: false, error: err as Error };
50+
}
51+
};

0 commit comments

Comments
 (0)