@pyreon/storage 0.12.8 → 0.12.10
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/package.json +3 -3
- package/src/tests/clear-coverage.test.ts +78 -0
- package/src/tests/cookie-coverage.test.ts +127 -0
- package/src/tests/cross-tab-coverage.test.ts +51 -0
- package/src/tests/indexed-db-full.test.ts +263 -0
- package/src/tests/session-coverage.test.ts +91 -0
- package/src/tests/utils-coverage.test.ts +76 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/storage",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.10",
|
|
4
4
|
"description": "Reactive client-side storage for Pyreon — localStorage, sessionStorage, cookies, IndexedDB",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/storage#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
45
|
-
"@pyreon/reactivity": "^0.12.
|
|
45
|
+
"@pyreon/reactivity": "^0.12.10",
|
|
46
46
|
"@vitus-labs/tools-lint": "^1.15.5"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@pyreon/reactivity": "^0.12.
|
|
49
|
+
"@pyreon/reactivity": "^0.12.10"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
_resetDBCache,
|
|
4
|
+
_resetRegistry,
|
|
5
|
+
clearStorage,
|
|
6
|
+
removeStorage,
|
|
7
|
+
useCookie,
|
|
8
|
+
useIndexedDB,
|
|
9
|
+
useSessionStorage,
|
|
10
|
+
useStorage,
|
|
11
|
+
} from '../index'
|
|
12
|
+
|
|
13
|
+
describe('removeStorage — indexeddb branch', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
_resetRegistry()
|
|
16
|
+
_resetDBCache()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
_resetRegistry()
|
|
21
|
+
_resetDBCache()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('removes indexeddb entry with registered signal', () => {
|
|
25
|
+
const sig = useIndexedDB('idb-remove', 'default')
|
|
26
|
+
sig.set('modified')
|
|
27
|
+
removeStorage('idb-remove', { type: 'indexeddb' })
|
|
28
|
+
expect(sig()).toBe('default')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('removeStorage for indexeddb without registered signal (no-op)', () => {
|
|
32
|
+
// This covers the else branch where entry is not found for indexeddb
|
|
33
|
+
expect(() => removeStorage('nonexistent-idb', { type: 'indexeddb' })).not.toThrow()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('removeStorage for cookie without registered signal (browser path)', () => {
|
|
37
|
+
// Covers the cookie branch in clear.ts
|
|
38
|
+
expect(() => removeStorage('nonexistent-cookie', { type: 'cookie' })).not.toThrow()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('clearStorage — indexeddb backend', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
_resetRegistry()
|
|
45
|
+
_resetDBCache()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
_resetRegistry()
|
|
50
|
+
_resetDBCache()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('clears indexeddb entries', () => {
|
|
54
|
+
const sig = useIndexedDB('idb-clear', 'default')
|
|
55
|
+
sig.set('modified')
|
|
56
|
+
clearStorage('indexeddb')
|
|
57
|
+
expect(sig()).toBe('default')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('clearStorage("all") includes indexeddb', () => {
|
|
61
|
+
const local = useStorage('l', 'default')
|
|
62
|
+
const session = useSessionStorage('s', 'default')
|
|
63
|
+
const cookie = useCookie('c', 'default')
|
|
64
|
+
const idb = useIndexedDB('i', 'default')
|
|
65
|
+
|
|
66
|
+
local.set('x')
|
|
67
|
+
session.set('x')
|
|
68
|
+
cookie.set('x')
|
|
69
|
+
idb.set('x')
|
|
70
|
+
|
|
71
|
+
clearStorage('all')
|
|
72
|
+
|
|
73
|
+
expect(local()).toBe('default')
|
|
74
|
+
expect(session()).toBe('default')
|
|
75
|
+
expect(cookie()).toBe('default')
|
|
76
|
+
expect(idb()).toBe('default')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { _resetRegistry, setCookieSource, useCookie } from '../index'
|
|
3
|
+
|
|
4
|
+
describe('useCookie — full coverage', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
_resetRegistry()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
_resetRegistry()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('reads server cookie string via setCookieSource', () => {
|
|
14
|
+
setCookieSource('locale=de; theme=dark')
|
|
15
|
+
// Server-side reading requires isBrowser() to be false,
|
|
16
|
+
// but since we're in happy-dom it reads document.cookie instead.
|
|
17
|
+
// Reset to empty
|
|
18
|
+
setCookieSource('')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('handles cookie with all options', () => {
|
|
22
|
+
const sig = useCookie('full-opts', 'default', {
|
|
23
|
+
maxAge: 3600,
|
|
24
|
+
expires: new Date('2030-01-01'),
|
|
25
|
+
path: '/app',
|
|
26
|
+
domain: 'example.com',
|
|
27
|
+
secure: true,
|
|
28
|
+
sameSite: 'strict',
|
|
29
|
+
})
|
|
30
|
+
sig.set('value')
|
|
31
|
+
expect(sig()).toBe('value')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('.update() updates value', () => {
|
|
35
|
+
const count = useCookie('counter', 0)
|
|
36
|
+
count.update((n) => n + 5)
|
|
37
|
+
expect(count()).toBe(5)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('.remove() resets to default and removes cookie', () => {
|
|
41
|
+
const sig = useCookie('remove-test', 'default')
|
|
42
|
+
sig.set('changed')
|
|
43
|
+
sig.remove()
|
|
44
|
+
expect(sig()).toBe('default')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('.remove() with domain option', () => {
|
|
48
|
+
const sig = useCookie('domain-rm', 'default', { domain: 'example.com' })
|
|
49
|
+
sig.set('value')
|
|
50
|
+
sig.remove()
|
|
51
|
+
expect(sig()).toBe('default')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns same signal for same key', () => {
|
|
55
|
+
const a = useCookie('dedup', 'val')
|
|
56
|
+
const b = useCookie('dedup', 'val')
|
|
57
|
+
expect(a).toBe(b)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('reads existing cookie value', () => {
|
|
61
|
+
// Set a cookie in the browser
|
|
62
|
+
document.cookie = 'existing-key=hello'
|
|
63
|
+
const sig = useCookie('existing-key', 'fallback')
|
|
64
|
+
// In happy-dom, document.cookie may not persist correctly — test the path
|
|
65
|
+
expect(typeof sig()).toBe('string')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('.debug() returns debug info', () => {
|
|
69
|
+
const sig = useCookie('debug-test', 'val')
|
|
70
|
+
expect(sig.debug().value).toBe('val')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('.label can be set and read', () => {
|
|
74
|
+
const sig = useCookie('label-test', 'val')
|
|
75
|
+
sig.label = 'cookie-sig'
|
|
76
|
+
expect(sig.label).toBe('cookie-sig')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('.peek() reads without tracking', () => {
|
|
80
|
+
const sig = useCookie('peek-test', 'hello')
|
|
81
|
+
expect(sig.peek()).toBe('hello')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('.subscribe() works', () => {
|
|
85
|
+
const sig = useCookie('sub-test', 'a')
|
|
86
|
+
let called = false
|
|
87
|
+
const unsub = sig.subscribe(() => {
|
|
88
|
+
called = true
|
|
89
|
+
})
|
|
90
|
+
sig.set('b')
|
|
91
|
+
expect(called).toBe(true)
|
|
92
|
+
unsub()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('.direct() is callable', () => {
|
|
96
|
+
const sig = useCookie('direct-test', 0)
|
|
97
|
+
expect(typeof sig.direct).toBe('function')
|
|
98
|
+
sig.direct(() => {})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('handles custom serializer/deserializer', () => {
|
|
102
|
+
const sig = useCookie('custom-ser', new Date('2025-01-01'), {
|
|
103
|
+
serializer: (d) => d.toISOString(),
|
|
104
|
+
deserializer: (s) => new Date(s),
|
|
105
|
+
})
|
|
106
|
+
expect(sig()).toEqual(new Date('2025-01-01'))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('handles empty cookie string in parseCookies', () => {
|
|
110
|
+
// With no pre-set cookie, should get default
|
|
111
|
+
const sig = useCookie('nonexistent-cookie-key', 'fallback')
|
|
112
|
+
expect(sig()).toBe('fallback')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('handles cookie with sameSite lax (default)', () => {
|
|
116
|
+
const sig = useCookie('lax-test', 'val')
|
|
117
|
+
sig.set('updated')
|
|
118
|
+
expect(sig()).toBe('updated')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('handles cookie without secure/domain options', () => {
|
|
122
|
+
const sig = useCookie('minimal', 'val', { path: '/' })
|
|
123
|
+
sig.set('updated')
|
|
124
|
+
sig.remove()
|
|
125
|
+
expect(sig()).toBe('val')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { _resetRegistry, useStorage } from '../index'
|
|
3
|
+
|
|
4
|
+
describe('cross-tab sync — edge cases', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
localStorage.clear()
|
|
7
|
+
_resetRegistry()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
localStorage.clear()
|
|
12
|
+
_resetRegistry()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('ignores storage events with null key', () => {
|
|
16
|
+
const theme = useStorage('theme', 'light')
|
|
17
|
+
|
|
18
|
+
// Simulate storage event with null key (happens on storage.clear())
|
|
19
|
+
const event = Object.assign(new Event('storage'), {
|
|
20
|
+
key: null,
|
|
21
|
+
newValue: null,
|
|
22
|
+
storageArea: localStorage,
|
|
23
|
+
})
|
|
24
|
+
window.dispatchEvent(event)
|
|
25
|
+
|
|
26
|
+
// Signal should not change
|
|
27
|
+
expect(theme()).toBe('light')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('ignores storage events for unregistered keys', () => {
|
|
31
|
+
const theme = useStorage('theme', 'light')
|
|
32
|
+
|
|
33
|
+
// Event for a key we don't track
|
|
34
|
+
const event = Object.assign(new Event('storage'), {
|
|
35
|
+
key: 'other-key',
|
|
36
|
+
newValue: JSON.stringify('value'),
|
|
37
|
+
storageArea: localStorage,
|
|
38
|
+
})
|
|
39
|
+
window.dispatchEvent(event)
|
|
40
|
+
|
|
41
|
+
expect(theme()).toBe('light')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('storage set catch branch — handles quota error gracefully', () => {
|
|
45
|
+
const sig = useStorage('quota-test', 'default')
|
|
46
|
+
// Even if the internal storage.setItem throws, the signal should still update
|
|
47
|
+
sig.set('value')
|
|
48
|
+
expect(sig()).toBe('value')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { _resetDBCache, _resetRegistry, useIndexedDB } from '../index'
|
|
3
|
+
|
|
4
|
+
// ─── IndexedDB mock ─────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function createMockIDB() {
|
|
7
|
+
const stores = new Map<string, Map<string, unknown>>()
|
|
8
|
+
|
|
9
|
+
function getStore(storeName: string) {
|
|
10
|
+
if (!stores.has(storeName)) stores.set(storeName, new Map())
|
|
11
|
+
return stores.get(storeName)!
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const mockIDB = {
|
|
15
|
+
open(dbName: string, _version?: number) {
|
|
16
|
+
const request: any = {
|
|
17
|
+
result: null as any,
|
|
18
|
+
error: null,
|
|
19
|
+
onupgradeneeded: null as any,
|
|
20
|
+
onsuccess: null as any,
|
|
21
|
+
onerror: null as any,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
setTimeout(() => {
|
|
25
|
+
const db = {
|
|
26
|
+
objectStoreNames: {
|
|
27
|
+
contains: (name: string) => stores.has(name),
|
|
28
|
+
},
|
|
29
|
+
createObjectStore: (name: string) => {
|
|
30
|
+
stores.set(name, new Map())
|
|
31
|
+
},
|
|
32
|
+
transaction: (storeName: string, mode: string) => ({
|
|
33
|
+
objectStore: (name: string) => {
|
|
34
|
+
const store = getStore(name)
|
|
35
|
+
return {
|
|
36
|
+
get: (key: string) => {
|
|
37
|
+
const getReq: any = {
|
|
38
|
+
result: store.get(key),
|
|
39
|
+
error: null,
|
|
40
|
+
onsuccess: null as any,
|
|
41
|
+
onerror: null as any,
|
|
42
|
+
}
|
|
43
|
+
setTimeout(() => getReq.onsuccess?.())
|
|
44
|
+
return getReq
|
|
45
|
+
},
|
|
46
|
+
put: (value: unknown, key: string) => {
|
|
47
|
+
store.set(key, value)
|
|
48
|
+
const putReq: any = {
|
|
49
|
+
result: undefined,
|
|
50
|
+
error: null,
|
|
51
|
+
onsuccess: null as any,
|
|
52
|
+
onerror: null as any,
|
|
53
|
+
}
|
|
54
|
+
setTimeout(() => putReq.onsuccess?.())
|
|
55
|
+
return putReq
|
|
56
|
+
},
|
|
57
|
+
delete: (key: string) => {
|
|
58
|
+
store.delete(key)
|
|
59
|
+
const delReq: any = {
|
|
60
|
+
result: undefined,
|
|
61
|
+
error: null,
|
|
62
|
+
onsuccess: null as any,
|
|
63
|
+
onerror: null as any,
|
|
64
|
+
}
|
|
65
|
+
setTimeout(() => delReq.onsuccess?.())
|
|
66
|
+
return delReq
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
request.result = db
|
|
74
|
+
|
|
75
|
+
// Fire upgrade if store doesn't exist
|
|
76
|
+
if (!stores.has('kv')) {
|
|
77
|
+
request.onupgradeneeded?.()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
request.onsuccess?.()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return request
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { mockIDB, stores, getStore }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('useIndexedDB — full coverage', () => {
|
|
91
|
+
let idbMock: ReturnType<typeof createMockIDB>
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
_resetRegistry()
|
|
95
|
+
_resetDBCache()
|
|
96
|
+
vi.useFakeTimers()
|
|
97
|
+
idbMock = createMockIDB()
|
|
98
|
+
;(globalThis as any).indexedDB = idbMock.mockIDB
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
_resetRegistry()
|
|
103
|
+
_resetDBCache()
|
|
104
|
+
vi.useRealTimers()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('loads stored value from IndexedDB asynchronously', async () => {
|
|
108
|
+
// Pre-store a value
|
|
109
|
+
idbMock.getStore('kv').set('existing-key', JSON.stringify('stored-value'))
|
|
110
|
+
|
|
111
|
+
const sig = useIndexedDB('existing-key', 'default')
|
|
112
|
+
expect(sig()).toBe('default') // Initially default
|
|
113
|
+
|
|
114
|
+
// Run all microtasks and timers to let IDB load complete
|
|
115
|
+
await vi.runAllTimersAsync()
|
|
116
|
+
|
|
117
|
+
expect(sig()).toBe('stored-value')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keeps default when IDB returns null', async () => {
|
|
121
|
+
const sig = useIndexedDB('missing-key', 'fallback')
|
|
122
|
+
await vi.runAllTimersAsync()
|
|
123
|
+
expect(sig()).toBe('fallback')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('.set() triggers debounced IDB write', async () => {
|
|
127
|
+
const sig = useIndexedDB('write-test', 'init', { debounceMs: 50 })
|
|
128
|
+
sig.set('new-value')
|
|
129
|
+
|
|
130
|
+
// Signal updates immediately
|
|
131
|
+
expect(sig()).toBe('new-value')
|
|
132
|
+
|
|
133
|
+
// Advance past debounce
|
|
134
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
135
|
+
|
|
136
|
+
// Value should be written to IDB
|
|
137
|
+
const stored = idbMock.getStore('kv').get('write-test')
|
|
138
|
+
expect(stored).toBe(JSON.stringify('new-value'))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('.remove() deletes from IDB', async () => {
|
|
142
|
+
idbMock.getStore('kv').set('del-key', JSON.stringify('value'))
|
|
143
|
+
const sig = useIndexedDB('del-key', 'default')
|
|
144
|
+
await vi.runAllTimersAsync()
|
|
145
|
+
|
|
146
|
+
sig.remove()
|
|
147
|
+
expect(sig()).toBe('default')
|
|
148
|
+
|
|
149
|
+
// Let the delete happen
|
|
150
|
+
await vi.runAllTimersAsync()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('.subscribe() works', () => {
|
|
154
|
+
const sig = useIndexedDB('sub-key', 'a')
|
|
155
|
+
let called = false
|
|
156
|
+
const unsub = sig.subscribe(() => {
|
|
157
|
+
called = true
|
|
158
|
+
})
|
|
159
|
+
sig.set('b')
|
|
160
|
+
expect(called).toBe(true)
|
|
161
|
+
unsub()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('.direct() is callable', () => {
|
|
165
|
+
const sig = useIndexedDB('direct-key', 0)
|
|
166
|
+
// direct() delegates to the underlying signal's direct method
|
|
167
|
+
expect(typeof sig.direct).toBe('function')
|
|
168
|
+
sig.direct(() => {})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('handles IDB open error gracefully', async () => {
|
|
172
|
+
// Override mock to reject
|
|
173
|
+
;(globalThis as any).indexedDB = {
|
|
174
|
+
open() {
|
|
175
|
+
const request: any = {
|
|
176
|
+
result: null,
|
|
177
|
+
error: new Error('IDB blocked'),
|
|
178
|
+
onupgradeneeded: null,
|
|
179
|
+
onsuccess: null,
|
|
180
|
+
onerror: null,
|
|
181
|
+
}
|
|
182
|
+
setTimeout(() => request.onerror?.())
|
|
183
|
+
return request
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_resetDBCache()
|
|
188
|
+
const sig = useIndexedDB('fail-key', 'safe-default')
|
|
189
|
+
await vi.runAllTimersAsync()
|
|
190
|
+
|
|
191
|
+
// Should keep default value
|
|
192
|
+
expect(sig()).toBe('safe-default')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('custom serializer is used for writes', async () => {
|
|
196
|
+
const sig = useIndexedDB('custom-ser', { x: 1 }, {
|
|
197
|
+
serializer: (v) => `CUSTOM:${JSON.stringify(v)}`,
|
|
198
|
+
debounceMs: 10,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
sig.set({ x: 2 })
|
|
202
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
203
|
+
|
|
204
|
+
const stored = idbMock.getStore('kv').get('custom-ser')
|
|
205
|
+
expect(stored).toBe('CUSTOM:{"x":2}')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('custom deserializer is used for reads', async () => {
|
|
209
|
+
idbMock.getStore('kv').set('custom-de', 'RAW:hello')
|
|
210
|
+
|
|
211
|
+
const sig = useIndexedDB('custom-de', '', {
|
|
212
|
+
deserializer: (s) => s.replace('RAW:', ''),
|
|
213
|
+
})
|
|
214
|
+
await vi.runAllTimersAsync()
|
|
215
|
+
|
|
216
|
+
expect(sig()).toBe('hello')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('onError callback is called on deserialization failure', async () => {
|
|
220
|
+
idbMock.getStore('kv').set('bad-json', '{broken')
|
|
221
|
+
|
|
222
|
+
const errors: Error[] = []
|
|
223
|
+
const sig = useIndexedDB('bad-json', 'fallback', {
|
|
224
|
+
onError: (e) => {
|
|
225
|
+
errors.push(e)
|
|
226
|
+
return 'error-recovery'
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
await vi.runAllTimersAsync()
|
|
230
|
+
|
|
231
|
+
expect(sig()).toBe('error-recovery')
|
|
232
|
+
expect(errors).toHaveLength(1)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('debounce coalesces multiple writes', async () => {
|
|
236
|
+
const sig = useIndexedDB('coalesce', 0, { debounceMs: 50 })
|
|
237
|
+
sig.set(1)
|
|
238
|
+
sig.set(2)
|
|
239
|
+
sig.set(3)
|
|
240
|
+
|
|
241
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
242
|
+
|
|
243
|
+
const stored = idbMock.getStore('kv').get('coalesce')
|
|
244
|
+
expect(stored).toBe(JSON.stringify(3))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('remove cancels pending debounced write', async () => {
|
|
248
|
+
const sig = useIndexedDB('remove-cancel', 'default', { debounceMs: 200 })
|
|
249
|
+
sig.set('pending')
|
|
250
|
+
sig.remove()
|
|
251
|
+
|
|
252
|
+
await vi.advanceTimersByTimeAsync(300)
|
|
253
|
+
|
|
254
|
+
expect(sig()).toBe('default')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('flushWrite does nothing when pendingValue is undefined', async () => {
|
|
258
|
+
const sig = useIndexedDB('no-pending', 'val', { debounceMs: 10 })
|
|
259
|
+
// Don't set anything — just advance timers
|
|
260
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
261
|
+
expect(sig()).toBe('val')
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { _resetRegistry, useSessionStorage } from '../index'
|
|
3
|
+
|
|
4
|
+
describe('useSessionStorage — full coverage', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
sessionStorage.clear()
|
|
7
|
+
_resetRegistry()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
sessionStorage.clear()
|
|
12
|
+
_resetRegistry()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns default value when key is not in storage', () => {
|
|
16
|
+
const val = useSessionStorage('step', 0)
|
|
17
|
+
expect(val()).toBe(0)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('reads existing value from sessionStorage', () => {
|
|
21
|
+
sessionStorage.setItem('step', JSON.stringify(3))
|
|
22
|
+
const val = useSessionStorage('step', 0)
|
|
23
|
+
expect(val()).toBe(3)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('.set() updates signal and sessionStorage', () => {
|
|
27
|
+
const val = useSessionStorage('step', 0)
|
|
28
|
+
val.set(5)
|
|
29
|
+
expect(val()).toBe(5)
|
|
30
|
+
expect(JSON.parse(sessionStorage.getItem('step')!)).toBe(5)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('.update() works', () => {
|
|
34
|
+
const val = useSessionStorage('count', 10)
|
|
35
|
+
val.update((n) => n + 5)
|
|
36
|
+
expect(val()).toBe(15)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('.remove() resets to default and clears sessionStorage', () => {
|
|
40
|
+
const val = useSessionStorage('temp', 'default')
|
|
41
|
+
val.set('modified')
|
|
42
|
+
val.remove()
|
|
43
|
+
expect(val()).toBe('default')
|
|
44
|
+
expect(sessionStorage.getItem('temp')).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns same signal for same key', () => {
|
|
48
|
+
const a = useSessionStorage('key', 'val')
|
|
49
|
+
const b = useSessionStorage('key', 'val')
|
|
50
|
+
expect(a).toBe(b)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('handles corrupt storage values gracefully', () => {
|
|
54
|
+
sessionStorage.setItem('broken', 'not valid json{{{')
|
|
55
|
+
const val = useSessionStorage('broken', 'fallback')
|
|
56
|
+
expect(val()).toBe('fallback')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('custom serializer/deserializer', () => {
|
|
60
|
+
const val = useSessionStorage('date', new Date('2025-01-01'), {
|
|
61
|
+
serializer: (d) => d.toISOString(),
|
|
62
|
+
deserializer: (s) => new Date(s),
|
|
63
|
+
})
|
|
64
|
+
expect(val()).toEqual(new Date('2025-01-01'))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('.debug() and .label work', () => {
|
|
68
|
+
const val = useSessionStorage('debug', 'test')
|
|
69
|
+
expect(val.debug().value).toBe('test')
|
|
70
|
+
val.label = 'session-sig'
|
|
71
|
+
expect(val.label).toBe('session-sig')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('.peek() reads without subscribing', () => {
|
|
75
|
+
const val = useSessionStorage('peek', 'hello')
|
|
76
|
+
expect(val.peek()).toBe('hello')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('onError callback is called on deserialization failure', () => {
|
|
80
|
+
sessionStorage.setItem('bad', '{invalid')
|
|
81
|
+
const errors: Error[] = []
|
|
82
|
+
const val = useSessionStorage('bad', 'default', {
|
|
83
|
+
onError: (e) => {
|
|
84
|
+
errors.push(e)
|
|
85
|
+
return 'recovered'
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
expect(val()).toBe('recovered')
|
|
89
|
+
expect(errors).toHaveLength(1)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { deserialize, getWebStorage, serialize } from '../utils'
|
|
3
|
+
|
|
4
|
+
describe('serialize', () => {
|
|
5
|
+
it('uses JSON.stringify by default', () => {
|
|
6
|
+
expect(serialize({ a: 1 })).toBe('{"a":1}')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('uses custom serializer when provided', () => {
|
|
10
|
+
const custom = (v: number) => `NUM:${v}`
|
|
11
|
+
expect(serialize(42, custom)).toBe('NUM:42')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('deserialize', () => {
|
|
16
|
+
it('uses JSON.parse by default', () => {
|
|
17
|
+
expect(deserialize('"hello"', 'default')).toBe('hello')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses custom deserializer when provided', () => {
|
|
21
|
+
const custom = (s: string) => s.toUpperCase()
|
|
22
|
+
expect(deserialize('hello', 'default', custom)).toBe('HELLO')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns default value on parse error', () => {
|
|
26
|
+
expect(deserialize('{broken', 'fallback')).toBe('fallback')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('calls onError and returns its result on parse error', () => {
|
|
30
|
+
const onError = (e: Error) => 'recovered'
|
|
31
|
+
expect(deserialize('{broken', 'default', undefined, onError)).toBe('recovered')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns default when onError returns undefined', () => {
|
|
35
|
+
const onError = (_e: Error) => undefined
|
|
36
|
+
expect(deserialize('{broken', 'fallback', undefined, onError)).toBe('fallback')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('getWebStorage', () => {
|
|
41
|
+
it('returns localStorage for "local"', () => {
|
|
42
|
+
const storage = getWebStorage('local')
|
|
43
|
+
expect(storage).toBe(window.localStorage)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns sessionStorage for "session"', () => {
|
|
47
|
+
const storage = getWebStorage('session')
|
|
48
|
+
expect(storage).toBe(window.sessionStorage)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns null when storage throws (e.g. private browsing)', () => {
|
|
52
|
+
const originalLocalStorage = window.localStorage
|
|
53
|
+
// Mock localStorage to throw on setItem
|
|
54
|
+
Object.defineProperty(window, 'localStorage', {
|
|
55
|
+
value: {
|
|
56
|
+
setItem: () => {
|
|
57
|
+
throw new Error('Quota exceeded')
|
|
58
|
+
},
|
|
59
|
+
removeItem: () => {},
|
|
60
|
+
getItem: () => null,
|
|
61
|
+
},
|
|
62
|
+
writable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const result = getWebStorage('local')
|
|
67
|
+
expect(result).toBeNull()
|
|
68
|
+
|
|
69
|
+
// Restore
|
|
70
|
+
Object.defineProperty(window, 'localStorage', {
|
|
71
|
+
value: originalLocalStorage,
|
|
72
|
+
writable: true,
|
|
73
|
+
configurable: true,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
})
|