diff --git a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx
index 06403f0..70c919f 100644
--- a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx
+++ b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, act, waitFor } from '@testing-library/react';
import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context';
import type { Todo, TodoFilter } from '@todo-starter/utils';
import { removeFromStorage, saveToStorage } from '@todo-starter/utils';
@@ -86,6 +86,33 @@ vi.mock('@todo-starter/utils', async importOriginal => {
});
describe('todo-context', () => {
+ const STORAGE_KEY = 'todo-app/state@v1';
+ const ORIGINAL_ENV = process.env.NODE_ENV;
+
+ beforeEach(() => {
+ // Opt-in to using real localStorage inside tests for this suite
+ Object.defineProperty(globalThis, '__ALLOW_STORAGE_IN_TESTS__', { value: true, configurable: true });
+ // allow storage helpers to operate by switching env off 'test' for these tests
+ process.env.NODE_ENV = 'development';
+ try {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ /* ignore */
+ }
+ });
+
+ afterEach(() => {
+ // restore jsdom localStorage cleanliness and env
+ process.env.NODE_ENV = ORIGINAL_ENV;
+ // Remove opt-in flag after each test to avoid cross-suite leakage
+ Object.defineProperty(globalThis, '__ALLOW_STORAGE_IN_TESTS__', { value: undefined, configurable: true });
+ try {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ /* ignore */
+ }
+ });
+
describe('TodoProvider and useTodoStore', () => {
beforeEach(() => {
// Ensure no persisted state bleeds across tests
@@ -253,4 +280,66 @@ describe('todo-context', () => {
expect(filtered[0].completed).toBe(true);
});
});
+
+ it('hydrates and revives date instances on mount when persisted state exists', () => {
+ const seeded = {
+ todos: [
+ {
+ id: 'x',
+ text: 'seed',
+ completed: false,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString()
+ }
+ ],
+ filter: 'all' as const
+ };
+ // Use storage helper (mocked in this suite) to seed persisted state
+ saveToStorage(STORAGE_KEY, seeded);
+
+ renderWithProvider();
+
+ // Access via UI to ensure hydration occurred
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('1');
+ });
+
+ it('persists on addTodo, toggleTodo, setFilter', async () => {
+ const utils = await import('@todo-starter/utils');
+ const spy = vi.spyOn(utils, 'saveToStorage');
+
+ renderWithProvider();
+
+ act(() => {
+ screen.getByTestId('add-todo').click();
+ });
+ act(() => {
+ screen.getByTestId('toggle-todo').click();
+ });
+ act(() => {
+ screen.getByTestId('set-filter').click();
+ });
+
+ // Called via utils wrapper (effects may be scheduled)
+ await waitFor(() => expect(spy).toHaveBeenCalled());
+
+ spy.mockRestore();
+ });
+
+ it('no SSR errors when window/localStorage not available (guarded in utils)', () => {
+ // Simulate storage access throwing
+ const original = window.localStorage;
+ // @ts-ignore - override for test
+ Object.defineProperty(window, 'localStorage', {
+ get() {
+ throw new Error('unavailable');
+ },
+ configurable: true
+ });
+
+ // Should not throw during render/mount due to guard
+ expect(() => renderWithProvider()).not.toThrow();
+
+ // restore
+ Object.defineProperty(window, 'localStorage', { value: original, configurable: true });
+ });
});
diff --git a/apps/todo-app/app/lib/todo-context.tsx b/apps/todo-app/app/lib/todo-context.tsx
index 79e7439..6cdd4b3 100644
--- a/apps/todo-app/app/lib/todo-context.tsx
+++ b/apps/todo-app/app/lib/todo-context.tsx
@@ -66,9 +66,7 @@ function todoReducer(state: TodoState, action: TodoAction): TodoState {
return {
...state,
todos: state.todos.map(todo =>
- todo.id === action.payload
- ? { ...todo, completed: !todo.completed, updatedAt: new Date() }
- : todo
+ todo.id === action.payload ? { ...todo, completed: !todo.completed, updatedAt: new Date() } : todo
)
};
case 'DELETE_TODO':
@@ -80,9 +78,7 @@ function todoReducer(state: TodoState, action: TodoAction): TodoState {
return {
...state,
todos: state.todos.map(todo =>
- todo.id === action.payload.id
- ? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() }
- : todo
+ todo.id === action.payload.id ? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() } : todo
)
};
case 'SET_FILTER':
@@ -151,17 +147,12 @@ export function TodoProvider({ children }: { children: ReactNode }) {
addTodo: (text: string) => dispatch({ type: 'ADD_TODO', payload: text }),
toggleTodo: (id: string) => dispatch({ type: 'TOGGLE_TODO', payload: id }),
deleteTodo: (id: string) => dispatch({ type: 'DELETE_TODO', payload: id }),
- updateTodo: (id: string, text: string) =>
- dispatch({ type: 'UPDATE_TODO', payload: { id, text } }),
+ updateTodo: (id: string, text: string) => dispatch({ type: 'UPDATE_TODO', payload: { id, text } }),
setFilter: (filter: TodoFilter) => dispatch({ type: 'SET_FILTER', payload: filter }),
clearCompleted: () => dispatch({ type: 'CLEAR_COMPLETED' })
};
- return (
-
- {children}
-
- );
+ return {children};
}
// Custom hook to use the todo context
diff --git a/apps/todo-app/tsconfig.json b/apps/todo-app/tsconfig.json
index 2c1ade1..83c49b9 100644
--- a/apps/todo-app/tsconfig.json
+++ b/apps/todo-app/tsconfig.json
@@ -2,6 +2,11 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
+ "types": [
+ "vitest/globals",
+ "@testing-library/jest-dom",
+ "vite/client"
+ ],
"paths": {
"~/*": ["./app/*"],
"@todo-starter/ui": ["../../packages/ui/src"],
@@ -21,8 +26,6 @@
"exclude": [
"node_modules",
"build",
- "dist",
- "**/*.test.ts",
- "**/*.test.tsx"
+ "dist"
]
}
diff --git a/packages/utils/src/__tests__/storage.test.ts b/packages/utils/src/__tests__/storage.test.ts
new file mode 100644
index 0000000..2ab74a9
--- /dev/null
+++ b/packages/utils/src/__tests__/storage.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { loadFromStorage, saveToStorage, removeFromStorage } from '@todo-starter/utils';
+
+const KEY = 'test/storage@v1';
+
+// Save original env to restore between tests
+const ORIGINAL_ENV = process.env.NODE_ENV;
+
+describe('storage utils', () => {
+ function ensureWindowWithLocalStorage() {
+ // Ensure a Window-like global for Node environment
+ if (typeof window === 'undefined') {
+ Object.defineProperty(globalThis, 'window', {
+ // unknown avoids explicit any; cast to Window shape for tests
+ value: {} as unknown as Window & typeof globalThis,
+ configurable: true
+ });
+ }
+ // Polyfill localStorage if missing
+ if (!('localStorage' in window)) {
+ const store = new Map();
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ }
+ },
+ configurable: true
+ });
+ }
+ }
+
+ beforeEach(() => {
+ // Ensure clean slate
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ // ignore
+ }
+ });
+
+ afterEach(() => {
+ process.env.NODE_ENV = ORIGINAL_ENV;
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ // ignore
+ }
+ });
+
+ it('SSR/test guard disables storage (returns fallback in test env)', () => {
+ // In vitest, NODE_ENV is "test" by default. Verify guard path returns fallback.
+ window.localStorage.setItem(KEY, JSON.stringify({ value: 123 }));
+ const result = loadFromStorage(KEY, { value: 999 });
+ expect(result).toEqual({ value: 999 });
+ });
+
+ it('Malformed JSON returns fallback', () => {
+ // Enable storage access by switching to a non-test env for this test
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+ window.localStorage.setItem(KEY, '{not json');
+ const result = loadFromStorage(KEY, { good: true });
+ expect(result).toEqual({ good: true });
+ });
+
+ it('save/remove round-trip behavior works', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+
+ const value = { a: 1, b: 'two' };
+ saveToStorage(KEY, value);
+
+ const loaded = loadFromStorage(KEY, null);
+ expect(loaded).toEqual(value);
+
+ removeFromStorage(KEY);
+ const afterRemove = loadFromStorage(KEY, null);
+ expect(afterRemove).toBeNull();
+ });
+
+ it('validate guard: rejects invalid shape and returns fallback', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+
+ window.localStorage.setItem(KEY, JSON.stringify({ nope: true }));
+
+ const fallback = { ok: true };
+ const result = loadFromStorage(
+ KEY,
+ fallback,
+ (v): v is typeof fallback =>
+ typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean'
+ );
+ expect(result).toEqual(fallback);
+ });
+
+ it('validate guard: accepts valid shape', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+
+ const value = { ok: true };
+ window.localStorage.setItem(KEY, JSON.stringify(value));
+
+ const result = loadFromStorage(
+ KEY,
+ { ok: false },
+ (v): v is typeof value =>
+ typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean'
+ );
+ expect(result).toEqual(value);
+ });
+});
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 176d12f..e3de15e 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -2,3 +2,5 @@ export { cn } from './cn';
export type { Todo, TodoFilter, TodoStore } from './types';
export { loadFromStorage, saveToStorage, removeFromStorage } from './storage';
export type { StorageLike } from './storage';
+// Re-export type for validator usage in tests and apps
+export type { } from './storage';
diff --git a/packages/utils/src/storage.ts b/packages/utils/src/storage.ts
index 0829cef..9778326 100644
--- a/packages/utils/src/storage.ts
+++ b/packages/utils/src/storage.ts
@@ -3,8 +3,13 @@
export type StorageLike = Pick;
function getStorage(): StorageLike | null {
- // Disable in test environments to keep tests deterministic
- if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test') return null;
+ // Allow tests to opt-in to real storage by setting a runtime flag
+ const allowInTests =
+ typeof globalThis !== 'undefined' &&
+ // Use `unknown` and index signature to avoid `any`
+ (globalThis as unknown as Record).__ALLOW_STORAGE_IN_TESTS__ === true;
+ // Disable in test environments unless explicitly allowed
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test' && !allowInTests) return null;
if (typeof window === 'undefined') return null;
try {
return window.localStorage;
@@ -13,13 +18,17 @@ function getStorage(): StorageLike | null {
}
}
-export function loadFromStorage(key: string, fallback: T): T {
+export function loadFromStorage(key: string, fallback: T): T;
+export function loadFromStorage(key: string, fallback: T, validate: (value: unknown) => value is T | boolean): T;
+export function loadFromStorage(key: string, fallback: T, validate?: (value: unknown) => value is T | boolean): T {
const storage = getStorage();
if (!storage) return fallback;
try {
const raw = storage.getItem(key);
if (!raw) return fallback;
- return JSON.parse(raw) as T;
+ const parsed = JSON.parse(raw) as unknown;
+ if (validate && !validate(parsed)) return fallback; // Add optional validation guard
+ return parsed as T;
} catch {
return fallback;
}
@@ -44,4 +53,3 @@ export function removeFromStorage(key: string): void {
// ignore
}
}
-
diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts
index 414d975..d88b63c 100644
--- a/packages/utils/vitest.config.ts
+++ b/packages/utils/vitest.config.ts
@@ -2,7 +2,15 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- globals: true,
- environment: 'node'
- }
-});
+ // Default to jsdom for React + DOM/localStorage tests
+ environment: 'jsdom',
+
+ // Optional: run Node env for server-only utils tests
+ environmentMatchGlobs: [
+ ['packages/utils/**', 'node'],
+ ],
+
+ // Optional (we already import from 'vitest')
+ // globals: true,
+ },
+});
\ No newline at end of file