@pyreon/storage 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +552 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +540 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +220 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +50 -0
- package/src/clear.ts +72 -0
- package/src/cookie.ts +165 -0
- package/src/custom.ts +128 -0
- package/src/index.ts +51 -0
- package/src/indexed-db.ts +205 -0
- package/src/local.ts +143 -0
- package/src/registry.ts +66 -0
- package/src/session.ts +58 -0
- package/src/tests/clear.test.ts +104 -0
- package/src/tests/cookie.test.ts +149 -0
- package/src/tests/custom.test.ts +207 -0
- package/src/tests/indexed-db.test.ts +74 -0
- package/src/tests/local.test.ts +215 -0
- package/src/tests/session.test.ts +67 -0
- package/src/types.ts +86 -0
- package/src/utils.ts +66 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { _resetRegistry, setCookieSource, useCookie } from '../index'
|
|
3
|
+
|
|
4
|
+
function clearAllCookies(): void {
|
|
5
|
+
for (const cookie of document.cookie.split(';')) {
|
|
6
|
+
const name = cookie.split('=')[0]?.trim()
|
|
7
|
+
if (name) {
|
|
8
|
+
// biome-ignore lint/suspicious/noDocumentCookie: test cleanup requires direct cookie access
|
|
9
|
+
document.cookie = `${name}=; max-age=0; path=/`
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('useCookie', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
_resetRegistry()
|
|
17
|
+
clearAllCookies()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
_resetRegistry()
|
|
22
|
+
clearAllCookies()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns default value when cookie does not exist', () => {
|
|
26
|
+
const locale = useCookie('locale', 'en')
|
|
27
|
+
expect(locale()).toBe('en')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('reads existing cookie value', () => {
|
|
31
|
+
// biome-ignore lint/suspicious/noDocumentCookie: test setup requires direct cookie access
|
|
32
|
+
document.cookie = `locale=${encodeURIComponent(JSON.stringify('de'))}; path=/`
|
|
33
|
+
const locale = useCookie('locale', 'en')
|
|
34
|
+
expect(locale()).toBe('de')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('.set() updates signal and writes cookie', () => {
|
|
38
|
+
const locale = useCookie('locale', 'en')
|
|
39
|
+
locale.set('fr')
|
|
40
|
+
expect(locale()).toBe('fr')
|
|
41
|
+
expect(document.cookie).toContain('locale')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('.remove() deletes cookie and resets to default', () => {
|
|
45
|
+
const locale = useCookie('locale', 'en')
|
|
46
|
+
locale.set('fr')
|
|
47
|
+
locale.remove()
|
|
48
|
+
expect(locale()).toBe('en')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns same signal instance for same key', () => {
|
|
52
|
+
const a = useCookie('locale', 'en')
|
|
53
|
+
const b = useCookie('locale', 'en')
|
|
54
|
+
expect(a).toBe(b)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('works with objects', () => {
|
|
58
|
+
const prefs = useCookie('prefs', { dark: false })
|
|
59
|
+
prefs.set({ dark: true })
|
|
60
|
+
expect(prefs()).toEqual({ dark: true })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('.update() works', () => {
|
|
64
|
+
const count = useCookie('count', 0)
|
|
65
|
+
count.update((n) => n + 1)
|
|
66
|
+
expect(count()).toBe(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('.peek() reads without subscribing', () => {
|
|
70
|
+
const locale = useCookie('locale', 'en')
|
|
71
|
+
expect(locale.peek()).toBe('en')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('respects maxAge option', () => {
|
|
75
|
+
const locale = useCookie('locale', 'en', { maxAge: 3600 })
|
|
76
|
+
locale.set('de')
|
|
77
|
+
expect(document.cookie).toContain('locale')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('respects secure option', () => {
|
|
81
|
+
const token = useCookie('token', '', { secure: true })
|
|
82
|
+
token.set('abc123')
|
|
83
|
+
expect(token()).toBe('abc123')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('respects expires option', () => {
|
|
87
|
+
const future = new Date(Date.now() + 86400000)
|
|
88
|
+
const sig = useCookie('exp', 'val', { expires: future })
|
|
89
|
+
sig.set('updated')
|
|
90
|
+
expect(sig()).toBe('updated')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('respects domain option on set and remove', () => {
|
|
94
|
+
const sig = useCookie('dom', 'val', { domain: 'example.com' })
|
|
95
|
+
sig.set('updated')
|
|
96
|
+
expect(sig()).toBe('updated')
|
|
97
|
+
sig.remove()
|
|
98
|
+
expect(sig()).toBe('val')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('.subscribe() works', () => {
|
|
102
|
+
const sig = useCookie('sub', 'a')
|
|
103
|
+
let called = false
|
|
104
|
+
const unsub = sig.subscribe(() => {
|
|
105
|
+
called = true
|
|
106
|
+
})
|
|
107
|
+
sig.set('b')
|
|
108
|
+
expect(called).toBe(true)
|
|
109
|
+
unsub()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('.direct() works', () => {
|
|
113
|
+
const sig = useCookie('dir', 'a')
|
|
114
|
+
let called = false
|
|
115
|
+
const unsub = sig.direct(() => {
|
|
116
|
+
called = true
|
|
117
|
+
})
|
|
118
|
+
sig.set('b')
|
|
119
|
+
expect(called).toBe(true)
|
|
120
|
+
unsub()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('.debug() returns debug info', () => {
|
|
124
|
+
const sig = useCookie('dbg', 'test')
|
|
125
|
+
expect(sig.debug().value).toBe('test')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('.label can be set and read', () => {
|
|
129
|
+
const sig = useCookie('lbl', 'val')
|
|
130
|
+
sig.label = 'my-cookie'
|
|
131
|
+
expect(sig.label).toBe('my-cookie')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('setCookieSource (SSR)', () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
_resetRegistry()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
_resetRegistry()
|
|
142
|
+
setCookieSource('')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('stores server cookie string without throwing', () => {
|
|
146
|
+
setCookieSource('locale=de; theme=dark')
|
|
147
|
+
expect(() => setCookieSource('foo=bar')).not.toThrow()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { _resetRegistry, createStorage, useMemoryStorage } from '../index'
|
|
3
|
+
|
|
4
|
+
describe('createStorage', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
_resetRegistry()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
_resetRegistry()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('creates a working storage hook from a custom backend', () => {
|
|
14
|
+
const store = new Map<string, string>()
|
|
15
|
+
const useCustom = createStorage({
|
|
16
|
+
get: (k) => store.get(k) ?? null,
|
|
17
|
+
set: (k, v) => store.set(k, v),
|
|
18
|
+
remove: (k) => store.delete(k),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const sig = useCustom('key', 'default')
|
|
22
|
+
expect(sig()).toBe('default')
|
|
23
|
+
|
|
24
|
+
sig.set('updated')
|
|
25
|
+
expect(sig()).toBe('updated')
|
|
26
|
+
expect(store.get('key')).toBe(JSON.stringify('updated'))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reads existing values from the backend', () => {
|
|
30
|
+
const store = new Map<string, string>()
|
|
31
|
+
store.set('key', JSON.stringify('existing'))
|
|
32
|
+
|
|
33
|
+
const useCustom = createStorage({
|
|
34
|
+
get: (k) => store.get(k) ?? null,
|
|
35
|
+
set: (k, v) => store.set(k, v),
|
|
36
|
+
remove: (k) => store.delete(k),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const sig = useCustom('key', 'default')
|
|
40
|
+
expect(sig()).toBe('existing')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('.remove() clears from backend and resets signal', () => {
|
|
44
|
+
const store = new Map<string, string>()
|
|
45
|
+
const useCustom = createStorage({
|
|
46
|
+
get: (k) => store.get(k) ?? null,
|
|
47
|
+
set: (k, v) => store.set(k, v),
|
|
48
|
+
remove: (k) => store.delete(k),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const sig = useCustom('key', 'default')
|
|
52
|
+
sig.set('updated')
|
|
53
|
+
sig.remove()
|
|
54
|
+
|
|
55
|
+
expect(sig()).toBe('default')
|
|
56
|
+
expect(store.has('key')).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('deduplicates signals for same backend + key', () => {
|
|
60
|
+
const store = new Map<string, string>()
|
|
61
|
+
const useCustom = createStorage(
|
|
62
|
+
{
|
|
63
|
+
get: (k) => store.get(k) ?? null,
|
|
64
|
+
set: (k, v) => store.set(k, v),
|
|
65
|
+
remove: (k) => store.delete(k),
|
|
66
|
+
},
|
|
67
|
+
'test-backend',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const a = useCustom('key', 'default')
|
|
71
|
+
const b = useCustom('key', 'default')
|
|
72
|
+
expect(a).toBe(b)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('handles backend read errors gracefully', () => {
|
|
76
|
+
const useCustom = createStorage({
|
|
77
|
+
get: () => {
|
|
78
|
+
throw new Error('read failed')
|
|
79
|
+
},
|
|
80
|
+
set: () => {
|
|
81
|
+
// intentional no-op for error test
|
|
82
|
+
},
|
|
83
|
+
remove: () => {
|
|
84
|
+
// intentional no-op for error test
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const sig = useCustom('key', 'fallback')
|
|
89
|
+
expect(sig()).toBe('fallback')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('handles backend write errors gracefully', () => {
|
|
93
|
+
const useCustom = createStorage({
|
|
94
|
+
get: () => null,
|
|
95
|
+
set: () => {
|
|
96
|
+
throw new Error('write failed')
|
|
97
|
+
},
|
|
98
|
+
remove: () => {
|
|
99
|
+
// intentional no-op for error test
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const sig = useCustom('key', 'default')
|
|
104
|
+
// Should not throw — signal still updates
|
|
105
|
+
sig.set('new-value')
|
|
106
|
+
expect(sig()).toBe('new-value')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('.subscribe() delegates to underlying signal', () => {
|
|
110
|
+
const store = new Map<string, string>()
|
|
111
|
+
const useCustom = createStorage({
|
|
112
|
+
get: (k) => store.get(k) ?? null,
|
|
113
|
+
set: (k, v) => store.set(k, v),
|
|
114
|
+
remove: (k) => store.delete(k),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const sig = useCustom('sub-key', 'a')
|
|
118
|
+
let called = false
|
|
119
|
+
const unsub = sig.subscribe(() => {
|
|
120
|
+
called = true
|
|
121
|
+
})
|
|
122
|
+
sig.set('b')
|
|
123
|
+
expect(called).toBe(true)
|
|
124
|
+
unsub()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('.direct() delegates to underlying signal', () => {
|
|
128
|
+
const store = new Map<string, string>()
|
|
129
|
+
const useCustom = createStorage({
|
|
130
|
+
get: (k) => store.get(k) ?? null,
|
|
131
|
+
set: (k, v) => store.set(k, v),
|
|
132
|
+
remove: (k) => store.delete(k),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const sig = useCustom('dir-key', 'a')
|
|
136
|
+
let called = false
|
|
137
|
+
const unsub = sig.direct(() => {
|
|
138
|
+
called = true
|
|
139
|
+
})
|
|
140
|
+
sig.set('b')
|
|
141
|
+
expect(called).toBe(true)
|
|
142
|
+
unsub()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('.debug() and .label work', () => {
|
|
146
|
+
const store = new Map<string, string>()
|
|
147
|
+
const useCustom = createStorage({
|
|
148
|
+
get: (k) => store.get(k) ?? null,
|
|
149
|
+
set: (k, v) => store.set(k, v),
|
|
150
|
+
remove: (k) => store.delete(k),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const sig = useCustom('debug-key', 'test')
|
|
154
|
+
sig.label = 'my-signal'
|
|
155
|
+
expect(sig.label).toBe('my-signal')
|
|
156
|
+
expect(sig.debug().value).toBe('test')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('supports custom serializer/deserializer', () => {
|
|
160
|
+
const store = new Map<string, string>()
|
|
161
|
+
const useCustom = createStorage({
|
|
162
|
+
get: (k) => store.get(k) ?? null,
|
|
163
|
+
set: (k, v) => store.set(k, v),
|
|
164
|
+
remove: (k) => store.delete(k),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const date = useCustom('date', new Date('2025-01-01'), {
|
|
168
|
+
serializer: (d) => d.toISOString(),
|
|
169
|
+
deserializer: (s) => new Date(s),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const newDate = new Date('2025-06-15')
|
|
173
|
+
date.set(newDate)
|
|
174
|
+
expect(date().toISOString()).toBe('2025-06-15T00:00:00.000Z')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('useMemoryStorage', () => {
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
_resetRegistry()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
_resetRegistry()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('works as an in-memory storage', () => {
|
|
188
|
+
const sig = useMemoryStorage('key', 'default')
|
|
189
|
+
expect(sig()).toBe('default')
|
|
190
|
+
|
|
191
|
+
sig.set('updated')
|
|
192
|
+
expect(sig()).toBe('updated')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('deduplicates signals', () => {
|
|
196
|
+
const a = useMemoryStorage('key', 'default')
|
|
197
|
+
const b = useMemoryStorage('key', 'default')
|
|
198
|
+
expect(a).toBe(b)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('.remove() resets to default', () => {
|
|
202
|
+
const sig = useMemoryStorage('temp', 'initial')
|
|
203
|
+
sig.set('changed')
|
|
204
|
+
sig.remove()
|
|
205
|
+
expect(sig()).toBe('initial')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { _resetDBCache, _resetRegistry, useIndexedDB } from '../index'
|
|
3
|
+
|
|
4
|
+
describe('useIndexedDB', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
_resetRegistry()
|
|
7
|
+
_resetDBCache()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
_resetRegistry()
|
|
12
|
+
_resetDBCache()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns default value initially', () => {
|
|
16
|
+
const draft = useIndexedDB('draft', { title: '', body: '' })
|
|
17
|
+
// Initially returns default (IDB load is async)
|
|
18
|
+
expect(draft()).toEqual({ title: '', body: '' })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('.set() updates signal immediately', () => {
|
|
22
|
+
const draft = useIndexedDB('draft', { title: '', body: '' })
|
|
23
|
+
draft.set({ title: 'Hello', body: 'World' })
|
|
24
|
+
expect(draft()).toEqual({ title: 'Hello', body: 'World' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('.update() updates signal', () => {
|
|
28
|
+
const count = useIndexedDB('count', 0)
|
|
29
|
+
count.update((n) => n + 1)
|
|
30
|
+
expect(count()).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('.peek() reads without subscribing', () => {
|
|
34
|
+
const draft = useIndexedDB('draft', 'default')
|
|
35
|
+
expect(draft.peek()).toBe('default')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('.remove() resets to default', () => {
|
|
39
|
+
const draft = useIndexedDB('draft', 'default')
|
|
40
|
+
draft.set('modified')
|
|
41
|
+
draft.remove()
|
|
42
|
+
expect(draft()).toBe('default')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns same signal for same key', () => {
|
|
46
|
+
const a = useIndexedDB('key', 'value')
|
|
47
|
+
const b = useIndexedDB('key', 'value')
|
|
48
|
+
expect(a).toBe(b)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns different signals for different keys', () => {
|
|
52
|
+
const a = useIndexedDB('key1', 'a')
|
|
53
|
+
const b = useIndexedDB('key2', 'b')
|
|
54
|
+
expect(a).not.toBe(b)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('.debug() returns debug info', () => {
|
|
58
|
+
const draft = useIndexedDB('draft', 'test')
|
|
59
|
+
expect(draft.debug().value).toBe('test')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('.label can be set', () => {
|
|
63
|
+
const draft = useIndexedDB('draft', '')
|
|
64
|
+
draft.label = 'draft-signal'
|
|
65
|
+
expect(draft.label).toBe('draft-signal')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('set updates signal synchronously even though IDB write is async', () => {
|
|
69
|
+
const draft = useIndexedDB('sync-test', 'default', { debounceMs: 10 })
|
|
70
|
+
draft.set('immediate')
|
|
71
|
+
// Signal updates immediately — no need to wait for IDB
|
|
72
|
+
expect(draft()).toBe('immediate')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { effect } from '@pyreon/reactivity'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { _resetRegistry, useStorage } from '../index'
|
|
4
|
+
|
|
5
|
+
describe('useStorage (localStorage)', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
localStorage.clear()
|
|
8
|
+
_resetRegistry()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
localStorage.clear()
|
|
13
|
+
_resetRegistry()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns default value when key is not in storage', () => {
|
|
17
|
+
const theme = useStorage('theme', 'light')
|
|
18
|
+
expect(theme()).toBe('light')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('reads existing value from localStorage', () => {
|
|
22
|
+
localStorage.setItem('theme', JSON.stringify('dark'))
|
|
23
|
+
const theme = useStorage('theme', 'light')
|
|
24
|
+
expect(theme()).toBe('dark')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('.set() updates signal and localStorage', () => {
|
|
28
|
+
const theme = useStorage('theme', 'light')
|
|
29
|
+
theme.set('dark')
|
|
30
|
+
expect(theme()).toBe('dark')
|
|
31
|
+
expect(JSON.parse(localStorage.getItem('theme')!)).toBe('dark')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('.update() updates signal and localStorage', () => {
|
|
35
|
+
const count = useStorage('count', 0)
|
|
36
|
+
count.update((n) => n + 1)
|
|
37
|
+
expect(count()).toBe(1)
|
|
38
|
+
expect(JSON.parse(localStorage.getItem('count')!)).toBe(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('.peek() reads without subscribing', () => {
|
|
42
|
+
const theme = useStorage('theme', 'light')
|
|
43
|
+
expect(theme.peek()).toBe('light')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('.remove() clears from storage and resets to default', () => {
|
|
47
|
+
const theme = useStorage('theme', 'light')
|
|
48
|
+
theme.set('dark')
|
|
49
|
+
expect(theme()).toBe('dark')
|
|
50
|
+
|
|
51
|
+
theme.remove()
|
|
52
|
+
expect(theme()).toBe('light')
|
|
53
|
+
expect(localStorage.getItem('theme')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns same signal instance for same key (deduplication)', () => {
|
|
57
|
+
const a = useStorage('theme', 'light')
|
|
58
|
+
const b = useStorage('theme', 'light')
|
|
59
|
+
expect(a).toBe(b)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns different signals for different keys', () => {
|
|
63
|
+
const a = useStorage('theme', 'light')
|
|
64
|
+
const b = useStorage('lang', 'en')
|
|
65
|
+
expect(a).not.toBe(b)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('works with objects', () => {
|
|
69
|
+
const prefs = useStorage('prefs', { sidebar: true, density: 'comfortable' })
|
|
70
|
+
expect(prefs()).toEqual({ sidebar: true, density: 'comfortable' })
|
|
71
|
+
|
|
72
|
+
prefs.set({ sidebar: false, density: 'compact' })
|
|
73
|
+
expect(prefs()).toEqual({ sidebar: false, density: 'compact' })
|
|
74
|
+
expect(JSON.parse(localStorage.getItem('prefs')!)).toEqual({
|
|
75
|
+
sidebar: false,
|
|
76
|
+
density: 'compact',
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('works with arrays', () => {
|
|
81
|
+
const items = useStorage('items', [1, 2, 3])
|
|
82
|
+
expect(items()).toEqual([1, 2, 3])
|
|
83
|
+
|
|
84
|
+
items.set([4, 5])
|
|
85
|
+
expect(items()).toEqual([4, 5])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('works with booleans', () => {
|
|
89
|
+
const flag = useStorage('flag', false)
|
|
90
|
+
flag.set(true)
|
|
91
|
+
expect(flag()).toBe(true)
|
|
92
|
+
expect(JSON.parse(localStorage.getItem('flag')!)).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('works with numbers', () => {
|
|
96
|
+
const count = useStorage('count', 42)
|
|
97
|
+
expect(count()).toBe(42)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('handles corrupt storage values gracefully', () => {
|
|
101
|
+
localStorage.setItem('broken', 'not valid json{{{')
|
|
102
|
+
const value = useStorage('broken', 'fallback')
|
|
103
|
+
expect(value()).toBe('fallback')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('calls onError when deserialization fails', () => {
|
|
107
|
+
localStorage.setItem('broken', '{invalid')
|
|
108
|
+
const errors: Error[] = []
|
|
109
|
+
const value = useStorage('broken', 'default', {
|
|
110
|
+
onError: (e) => {
|
|
111
|
+
errors.push(e)
|
|
112
|
+
return undefined
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
expect(value()).toBe('default')
|
|
116
|
+
expect(errors).toHaveLength(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('onError can return a custom fallback', () => {
|
|
120
|
+
localStorage.setItem('broken', '{invalid')
|
|
121
|
+
const value = useStorage('broken', 'default', {
|
|
122
|
+
onError: () => 'custom-fallback',
|
|
123
|
+
})
|
|
124
|
+
expect(value()).toBe('custom-fallback')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('custom serializer/deserializer work', () => {
|
|
128
|
+
const date = useStorage('date', new Date('2025-01-01'), {
|
|
129
|
+
serializer: (d) => d.toISOString(),
|
|
130
|
+
deserializer: (s) => new Date(s),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(date()).toEqual(new Date('2025-01-01'))
|
|
134
|
+
|
|
135
|
+
const newDate = new Date('2025-06-15')
|
|
136
|
+
date.set(newDate)
|
|
137
|
+
expect(localStorage.getItem('date')).toBe('2025-06-15T00:00:00.000Z')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('is reactive — works in effects', () => {
|
|
141
|
+
const theme = useStorage('theme', 'light')
|
|
142
|
+
const values: string[] = []
|
|
143
|
+
|
|
144
|
+
effect(() => {
|
|
145
|
+
values.push(theme())
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(values).toEqual(['light'])
|
|
149
|
+
|
|
150
|
+
theme.set('dark')
|
|
151
|
+
expect(values).toEqual(['light', 'dark'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('.subscribe() works', () => {
|
|
155
|
+
const theme = useStorage('theme', 'light')
|
|
156
|
+
let callCount = 0
|
|
157
|
+
const unsub = theme.subscribe(() => {
|
|
158
|
+
callCount++
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
theme.set('dark')
|
|
162
|
+
expect(callCount).toBeGreaterThanOrEqual(1)
|
|
163
|
+
unsub()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('.debug() returns debug info', () => {
|
|
167
|
+
const theme = useStorage('theme', 'light')
|
|
168
|
+
const info = theme.debug()
|
|
169
|
+
expect(info.value).toBe('light')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('.label can be set and read', () => {
|
|
173
|
+
const theme = useStorage('theme', 'light')
|
|
174
|
+
theme.label = 'theme-signal'
|
|
175
|
+
expect(theme.label).toBe('theme-signal')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('cross-tab sync via storage event', () => {
|
|
179
|
+
const theme = useStorage('theme', 'light')
|
|
180
|
+
|
|
181
|
+
// Simulate storage event from another tab
|
|
182
|
+
const event = Object.assign(new Event('storage'), {
|
|
183
|
+
key: 'theme',
|
|
184
|
+
newValue: JSON.stringify('dark'),
|
|
185
|
+
storageArea: localStorage,
|
|
186
|
+
})
|
|
187
|
+
window.dispatchEvent(event)
|
|
188
|
+
|
|
189
|
+
expect(theme()).toBe('dark')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('cross-tab sync with null newValue resets to default', () => {
|
|
193
|
+
const theme = useStorage('theme', 'light')
|
|
194
|
+
theme.set('dark')
|
|
195
|
+
|
|
196
|
+
const event = Object.assign(new Event('storage'), {
|
|
197
|
+
key: 'theme',
|
|
198
|
+
newValue: null,
|
|
199
|
+
storageArea: localStorage,
|
|
200
|
+
})
|
|
201
|
+
window.dispatchEvent(event)
|
|
202
|
+
|
|
203
|
+
expect(theme()).toBe('light')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('after remove(), a new useStorage call creates a fresh signal', () => {
|
|
207
|
+
const a = useStorage('temp', 'first')
|
|
208
|
+
a.set('modified')
|
|
209
|
+
a.remove()
|
|
210
|
+
|
|
211
|
+
const b = useStorage('temp', 'second')
|
|
212
|
+
expect(b()).toBe('second')
|
|
213
|
+
expect(a).not.toBe(b)
|
|
214
|
+
})
|
|
215
|
+
})
|