Skip to content

Commit a470ca6

Browse files
add localstorage-writable (#81)
* add localstorage-writable * Update src/lib/utils/localstorage-writable.ts Co-authored-by: Benjamin Strasser <55442329+benjaminstrasser@users.noreply.github.com> * added unit test for localstorage-writable and fixed s small bug Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> * added test for JSON.parse errors and updated the code to handle errors Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> --------- Signed-off-by: Benjamin Strasser <bp.strasser@gmail.com> Co-authored-by: Benjamin Strasser <55442329+benjaminstrasser@users.noreply.github.com> Co-authored-by: Benjamin Strasser <bp.strasser@gmail.com>
1 parent a545890 commit a470ca6

File tree

2 files changed

+212
-0
lines changed

2 files changed

+212
-0
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { browser } from '$app/environment'
2+
import { type Updater, type Writable, get, writable } from 'svelte/store'
3+
4+
export type LocalStorageWritable<T> = Writable<T | null> & {
5+
wipe: () => void
6+
}
7+
8+
/**
9+
* A standard svelte-compatible writable with persistent values backed up in localStorage
10+
* @param localStorageKey The key used for local-storage
11+
* @param defaultValue The default value in case there is none found in localstorage
12+
* @returns A standard writable that persists all value changes to localstorage
13+
*/
14+
export const localStorageWritable = <T>(
15+
localStorageKey: string,
16+
defaultValue?: T
17+
): LocalStorageWritable<T> => {
18+
const storedValue = browser ? localStorage.getItem(localStorageKey) : null
19+
const localStorageValue = (() => {
20+
try {
21+
return storedValue !== null ? JSON.parse(storedValue) : null
22+
} catch {
23+
return null
24+
}
25+
})()
26+
27+
const initialValue: T | null = localStorageValue ?? defaultValue ?? null
28+
29+
const { set, subscribe } = writable<T | null>(null)
30+
31+
const setAndStore = (value: T | null) => {
32+
if (browser) {
33+
localStorage.setItem(localStorageKey, JSON.stringify(value))
34+
}
35+
36+
set(value)
37+
}
38+
39+
const wipe = () => {
40+
if (browser) {
41+
localStorage.removeItem(localStorageKey)
42+
}
43+
44+
set(null)
45+
}
46+
47+
setAndStore(initialValue)
48+
49+
return {
50+
subscribe,
51+
set: setAndStore,
52+
wipe,
53+
update: (updater: Updater<T | null>) => setAndStore(updater(get({ subscribe })))
54+
}
55+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { localStorageWritable } from './localstorage-writable'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { get } from 'svelte/store'
4+
5+
const mockedBrowser = vi.hoisted(() => {
6+
return vi.fn()
7+
})
8+
9+
vi.mock('$app/environment', () => {
10+
return {
11+
get browser() {
12+
return mockedBrowser()
13+
}
14+
}
15+
})
16+
17+
const localStorageMockFactory = () => {
18+
const store: Record<string, string> = {}
19+
20+
return {
21+
getItem: (key: string) => store[key] || null,
22+
setItem: (key: string, value: string) => {
23+
store[key] = value
24+
},
25+
removeItem: (key: string) => {
26+
delete store[key]
27+
}
28+
}
29+
}
30+
31+
describe('localStorageWritable', () => {
32+
beforeEach(() => {
33+
mockedBrowser.mockReturnValue(true)
34+
vi.stubGlobal('localStorage', localStorageMockFactory())
35+
})
36+
37+
afterEach(() => {
38+
vi.resetAllMocks()
39+
})
40+
41+
it('should initialize with default value if no value in localStorage', () => {
42+
const defaultValue = 'default'
43+
44+
const store = localStorageWritable('testKey', defaultValue)
45+
46+
expect(get(store)).toBe(defaultValue)
47+
expect(localStorage.getItem('testKey')).toBe(JSON.stringify(defaultValue))
48+
})
49+
50+
it('should initialize with value from localStorage if present', () => {
51+
localStorage.setItem('testKey', JSON.stringify('storedValue'))
52+
53+
const store = localStorageWritable('testKey')
54+
55+
expect(get(store)).toBe('storedValue')
56+
})
57+
58+
it('should prioritize value from local storage over default', () => {
59+
localStorage.setItem('testKey', JSON.stringify('storedValue'))
60+
61+
const store = localStorageWritable('testKey', 'defaultValue')
62+
63+
expect(get(store)).toBe('storedValue')
64+
expect(localStorage.getItem('testKey')).toBe(JSON.stringify('storedValue'))
65+
})
66+
67+
it('should store value in localStorage when set', () => {
68+
const store = localStorageWritable('testKey')
69+
store.set('newValue')
70+
71+
expect(localStorage.getItem('testKey')).toBe(JSON.stringify('newValue'))
72+
expect(get(store)).toBe('newValue')
73+
})
74+
75+
it('should remove value from localStorage when wipe is called', () => {
76+
localStorage.setItem('testKey', JSON.stringify('storedValue'))
77+
78+
const store = localStorageWritable('testKey')
79+
store.wipe()
80+
81+
expect(localStorage.getItem('testKey')).toBe(null)
82+
expect(get(store)).toBe(null)
83+
})
84+
85+
it('should update the value correctly using update method', () => {
86+
const store = localStorageWritable<number>('testKey', 1)
87+
store.update((n) => n! + 1)
88+
89+
expect(localStorage.getItem('testKey')).toBe(JSON.stringify(2))
90+
expect(get(store)).toBe(2)
91+
})
92+
93+
it('should initialize with default value if non-valid JSON is present in localStorage', () => {
94+
localStorage.setItem('testKey', 'invalid-json')
95+
96+
const defaultValue = 'default'
97+
const store = localStorageWritable('testKey', defaultValue)
98+
99+
expect(get(store)).toBe(defaultValue)
100+
expect(localStorage.getItem('testKey')).toBe(JSON.stringify(defaultValue))
101+
})
102+
103+
it('should initialize with null if no default value and non-valid JSON is present in localStorage', () => {
104+
localStorage.setItem('testKey', 'invalid-json')
105+
106+
const store = localStorageWritable('testKey')
107+
108+
expect(get(store)).toBe(null)
109+
expect(localStorage.getItem('testKey')).toBe(JSON.stringify(null))
110+
})
111+
})
112+
113+
describe('localStorageWritable with no browser', () => {
114+
beforeEach(() => {
115+
mockedBrowser.mockReturnValue(false)
116+
vi.stubGlobal('localStorage', undefined)
117+
})
118+
119+
afterEach(() => {
120+
vi.resetAllMocks()
121+
})
122+
123+
it('should initialize with default value', () => {
124+
const defaultValue = 'default'
125+
const store = localStorageWritable('testKey', defaultValue)
126+
127+
expect(get(store)).toBe(defaultValue)
128+
})
129+
130+
it('should initialize with null', () => {
131+
const store = localStorageWritable('testKey')
132+
133+
expect(get(store)).toBe(null)
134+
})
135+
136+
it('should set value', () => {
137+
const store = localStorageWritable('testKey')
138+
store.set('newValue')
139+
140+
expect(get(store)).toBe('newValue')
141+
})
142+
143+
it('should remove value', () => {
144+
const store = localStorageWritable('testKey')
145+
store.set('newValue')
146+
store.wipe()
147+
148+
expect(get(store)).toBe(null)
149+
})
150+
151+
it('should update the value correctly using update method', () => {
152+
const store = localStorageWritable<number>('testKey', 1)
153+
store.update((n) => n! + 1)
154+
155+
expect(get(store)).toBe(2)
156+
})
157+
})

0 commit comments

Comments
 (0)