Skip to content

Commit 4b16a9a

Browse files
Add optional validate guard to loadFromStorage and unit tests\n\nCo-authored-by: Jake Ruesink <[email protected]>
1 parent 60bd6cf commit 4b16a9a

File tree

2 files changed

+99
-3
lines changed

2 files changed

+99
-3
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
3+
import { loadFromStorage, type StorageLike } from '../storage';
4+
5+
// We'll provide a fake storage to bypass getStorage() SSR/test guard by injecting
6+
// directly via stubbing global window.localStorage used by our helpers.
7+
8+
declare global {
9+
interface Window {
10+
__fakeStorage?: StorageLike;
11+
}
12+
}
13+
14+
// Helper to temporarily lift the test guard by stubbing process.env and window
15+
function withStorage<T>(fake: StorageLike, run: () => T): T {
16+
const origNodeEnv = process.env.NODE_ENV;
17+
const globalRef = globalThis as unknown as { window?: { localStorage: StorageLike } };
18+
const origWindow = globalRef.window;
19+
20+
// Trick: temporarily change NODE_ENV so getStorage doesn't early-return
21+
process.env.NODE_ENV = 'production';
22+
globalRef.window = { localStorage: fake };
23+
24+
try {
25+
return run();
26+
} finally {
27+
// restore
28+
process.env.NODE_ENV = origNodeEnv;
29+
if (origWindow === undefined) {
30+
// Avoid using delete operator per lint rules
31+
(globalThis as unknown as { window?: { localStorage: StorageLike } }).window = undefined;
32+
} else {
33+
globalRef.window = origWindow;
34+
}
35+
}
36+
}
37+
38+
describe('storage helpers', () => {
39+
beforeEach(() => {
40+
vi.restoreAllMocks();
41+
});
42+
43+
it('returns parsed value when JSON is valid', () => {
44+
const fake: StorageLike = {
45+
getItem: (k: string) => (k === 'key' ? JSON.stringify({ a: 1 }) : null),
46+
setItem: () => undefined,
47+
removeItem: () => undefined,
48+
};
49+
50+
const result = withStorage(fake, () =>
51+
loadFromStorage<{ a: number }>('key', { a: 0 })
52+
);
53+
54+
expect(result).toEqual({ a: 1 });
55+
});
56+
57+
it('falls back when JSON is malformed', () => {
58+
const fake: StorageLike = {
59+
getItem: (_: string) => '{"a":', // malformed
60+
setItem: () => undefined,
61+
removeItem: () => undefined,
62+
};
63+
64+
const result = withStorage(fake, () =>
65+
loadFromStorage<{ a: number }>('key', { a: 0 })
66+
);
67+
68+
expect(result).toEqual({ a: 0 });
69+
});
70+
71+
it('uses fallback when validate guard rejects', () => {
72+
const fake: StorageLike = {
73+
getItem: (_: string) => JSON.stringify({ a: 'oops' }),
74+
setItem: () => undefined,
75+
removeItem: () => undefined,
76+
};
77+
78+
const isNumberA = (v: unknown): v is { a: number } => {
79+
if (typeof v !== 'object' || v === null) return false;
80+
const obj = v as Record<string, unknown>;
81+
return typeof obj.a === 'number';
82+
};
83+
84+
const result = withStorage(fake, () =>
85+
loadFromStorage('key', { a: 0 }, isNumberA)
86+
);
87+
88+
expect(result).toEqual({ a: 0 });
89+
});
90+
});

packages/utils/src/storage.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,20 @@ function getStorage(): StorageLike | null {
1313
}
1414
}
1515

16-
export function loadFromStorage<T>(key: string, fallback: T): T {
16+
export function loadFromStorage<T>(
17+
key: string,
18+
fallback: T,
19+
validate?: (value: unknown) => value is T
20+
): T {
1721
const storage = getStorage();
1822
if (!storage) return fallback;
1923
try {
2024
const raw = storage.getItem(key);
2125
if (!raw) return fallback;
22-
return JSON.parse(raw) as T;
26+
const parsed = JSON.parse(raw);
27+
// If a validator is provided and it fails, return fallback
28+
if (validate && !validate(parsed)) return fallback;
29+
return parsed as T;
2330
} catch {
2431
return fallback;
2532
}
@@ -44,4 +51,3 @@ export function removeFromStorage(key: string): void {
4451
// ignore
4552
}
4653
}
47-

0 commit comments

Comments
 (0)