@pyreon/storage 0.11.3 → 0.11.4
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/storage",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Reactive client-side storage for Pyreon — localStorage, sessionStorage, cookies, IndexedDB",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"lint": "biome check ."
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@pyreon/reactivity": "^0.11.
|
|
44
|
+
"@pyreon/reactivity": "^0.11.4"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@happy-dom/global-registrator": "^20.8.3",
|
|
48
|
-
"@pyreon/reactivity": "^0.11.
|
|
48
|
+
"@pyreon/reactivity": "^0.11.4",
|
|
49
49
|
"@vitus-labs/tools-lint": "^1.11.0"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
_resetRegistry,
|
|
4
|
+
clearStorage,
|
|
5
|
+
removeStorage,
|
|
6
|
+
useCookie,
|
|
7
|
+
useSessionStorage,
|
|
8
|
+
useStorage,
|
|
9
|
+
} from "../index"
|
|
10
|
+
|
|
11
|
+
describe("removeStorage — comprehensive", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
localStorage.clear()
|
|
14
|
+
sessionStorage.clear()
|
|
15
|
+
_resetRegistry()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
localStorage.clear()
|
|
20
|
+
sessionStorage.clear()
|
|
21
|
+
_resetRegistry()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("removes localStorage entry and resets signal to default", () => {
|
|
25
|
+
const theme = useStorage("theme", "light")
|
|
26
|
+
theme.set("dark")
|
|
27
|
+
removeStorage("theme")
|
|
28
|
+
expect(theme()).toBe("light")
|
|
29
|
+
expect(localStorage.getItem("theme")).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("removes sessionStorage entry", () => {
|
|
33
|
+
const step = useSessionStorage("step", 0)
|
|
34
|
+
step.set(5)
|
|
35
|
+
removeStorage("step", { type: "session" })
|
|
36
|
+
expect(step()).toBe(0)
|
|
37
|
+
expect(sessionStorage.getItem("step")).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("removes cookie entry", () => {
|
|
41
|
+
const locale = useCookie("locale", "en")
|
|
42
|
+
locale.set("de")
|
|
43
|
+
removeStorage("locale", { type: "cookie" })
|
|
44
|
+
expect(locale()).toBe("en")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("removes raw localStorage entry without a signal", () => {
|
|
48
|
+
localStorage.setItem("orphan", "value")
|
|
49
|
+
removeStorage("orphan")
|
|
50
|
+
expect(localStorage.getItem("orphan")).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("removes raw sessionStorage entry without a signal", () => {
|
|
54
|
+
sessionStorage.setItem("orphan", "value")
|
|
55
|
+
removeStorage("orphan", { type: "session" })
|
|
56
|
+
expect(sessionStorage.getItem("orphan")).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("removes cookie without a registered signal (no throw)", () => {
|
|
60
|
+
expect(() => removeStorage("nonexistent", { type: "cookie" })).not.toThrow()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("defaults to localStorage when no type specified", () => {
|
|
64
|
+
localStorage.setItem("default-type", "val")
|
|
65
|
+
removeStorage("default-type")
|
|
66
|
+
expect(localStorage.getItem("default-type")).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("after removeStorage, a new useStorage call creates fresh signal", () => {
|
|
70
|
+
const a = useStorage("resettable", "first")
|
|
71
|
+
a.set("modified")
|
|
72
|
+
removeStorage("resettable")
|
|
73
|
+
|
|
74
|
+
const b = useStorage("resettable", "second")
|
|
75
|
+
expect(b()).toBe("second")
|
|
76
|
+
expect(a).not.toBe(b)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("clearStorage — comprehensive", () => {
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
localStorage.clear()
|
|
83
|
+
sessionStorage.clear()
|
|
84
|
+
_resetRegistry()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
localStorage.clear()
|
|
89
|
+
sessionStorage.clear()
|
|
90
|
+
_resetRegistry()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("clears all managed localStorage entries", () => {
|
|
94
|
+
const a = useStorage("a", 1)
|
|
95
|
+
const b = useStorage("b", 2)
|
|
96
|
+
const c = useStorage("c", 3)
|
|
97
|
+
a.set(10)
|
|
98
|
+
b.set(20)
|
|
99
|
+
c.set(30)
|
|
100
|
+
|
|
101
|
+
clearStorage()
|
|
102
|
+
expect(a()).toBe(1)
|
|
103
|
+
expect(b()).toBe(2)
|
|
104
|
+
expect(c()).toBe(3)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("clears all managed sessionStorage entries", () => {
|
|
108
|
+
const a = useSessionStorage("a", "x")
|
|
109
|
+
const b = useSessionStorage("b", "y")
|
|
110
|
+
a.set("modified-a")
|
|
111
|
+
b.set("modified-b")
|
|
112
|
+
|
|
113
|
+
clearStorage("session")
|
|
114
|
+
expect(a()).toBe("x")
|
|
115
|
+
expect(b()).toBe("y")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("clears managed cookie entries", () => {
|
|
119
|
+
const locale = useCookie("locale", "en")
|
|
120
|
+
locale.set("de")
|
|
121
|
+
|
|
122
|
+
clearStorage("cookie")
|
|
123
|
+
expect(locale()).toBe("en")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('clears all backends with "all"', () => {
|
|
127
|
+
const local = useStorage("local-key", "default")
|
|
128
|
+
const session = useSessionStorage("session-key", "default")
|
|
129
|
+
const cookie = useCookie("cookie-key", "default")
|
|
130
|
+
local.set("changed")
|
|
131
|
+
session.set("changed")
|
|
132
|
+
cookie.set("changed")
|
|
133
|
+
|
|
134
|
+
clearStorage("all")
|
|
135
|
+
expect(local()).toBe("default")
|
|
136
|
+
expect(session()).toBe("default")
|
|
137
|
+
expect(cookie()).toBe("default")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("clearStorage with no managed entries does not throw", () => {
|
|
141
|
+
expect(() => clearStorage()).not.toThrow()
|
|
142
|
+
expect(() => clearStorage("session")).not.toThrow()
|
|
143
|
+
expect(() => clearStorage("cookie")).not.toThrow()
|
|
144
|
+
expect(() => clearStorage("indexeddb")).not.toThrow()
|
|
145
|
+
expect(() => clearStorage("all")).not.toThrow()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("clearStorage does not affect unmanaged entries", () => {
|
|
149
|
+
localStorage.setItem("unmanaged", "value")
|
|
150
|
+
const managed = useStorage("managed", "default")
|
|
151
|
+
managed.set("changed")
|
|
152
|
+
|
|
153
|
+
clearStorage()
|
|
154
|
+
expect(managed()).toBe("default")
|
|
155
|
+
// Unmanaged key should still exist (clearStorage only affects registered signals)
|
|
156
|
+
expect(localStorage.getItem("unmanaged")).toBe("value")
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { _resetRegistry, 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 — options", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
_resetRegistry()
|
|
17
|
+
clearAllCookies()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
_resetRegistry()
|
|
22
|
+
clearAllCookies()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("maxAge", () => {
|
|
26
|
+
it("sets cookie with maxAge in seconds", () => {
|
|
27
|
+
const sig = useCookie("session", "token-abc", { maxAge: 3600 })
|
|
28
|
+
sig.set("token-xyz")
|
|
29
|
+
// Cookie should be written — we can verify the signal value
|
|
30
|
+
expect(sig()).toBe("token-xyz")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("maxAge=0 still writes cookie (browser handles expiry)", () => {
|
|
34
|
+
const sig = useCookie("ephemeral", "val", { maxAge: 0 })
|
|
35
|
+
sig.set("updated")
|
|
36
|
+
expect(sig()).toBe("updated")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("large maxAge for long-lived cookies", () => {
|
|
40
|
+
const oneYear = 60 * 60 * 24 * 365
|
|
41
|
+
const sig = useCookie("locale", "en", { maxAge: oneYear })
|
|
42
|
+
sig.set("de")
|
|
43
|
+
expect(sig()).toBe("de")
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("path", () => {
|
|
48
|
+
it("defaults to / when no path specified", () => {
|
|
49
|
+
const sig = useCookie("default-path", "val")
|
|
50
|
+
sig.set("updated")
|
|
51
|
+
expect(sig()).toBe("updated")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("accepts custom path", () => {
|
|
55
|
+
const sig = useCookie("scoped", "val", { path: "/admin" })
|
|
56
|
+
sig.set("updated")
|
|
57
|
+
expect(sig()).toBe("updated")
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe("sameSite", () => {
|
|
62
|
+
it("defaults to lax when not specified", () => {
|
|
63
|
+
const sig = useCookie("lax-default", "val")
|
|
64
|
+
sig.set("updated")
|
|
65
|
+
expect(sig()).toBe("updated")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("accepts strict sameSite", () => {
|
|
69
|
+
const sig = useCookie("strict-cookie", "val", { sameSite: "strict" })
|
|
70
|
+
sig.set("updated")
|
|
71
|
+
expect(sig()).toBe("updated")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("accepts none sameSite", () => {
|
|
75
|
+
const sig = useCookie("none-cookie", "val", { sameSite: "none", secure: true })
|
|
76
|
+
sig.set("updated")
|
|
77
|
+
expect(sig()).toBe("updated")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("accepts lax sameSite explicitly", () => {
|
|
81
|
+
const sig = useCookie("explicit-lax", "val", { sameSite: "lax" })
|
|
82
|
+
sig.set("updated")
|
|
83
|
+
expect(sig()).toBe("updated")
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("combined options", () => {
|
|
88
|
+
it("maxAge + path + sameSite together", () => {
|
|
89
|
+
const sig = useCookie("combined", "default", {
|
|
90
|
+
maxAge: 86400,
|
|
91
|
+
path: "/app",
|
|
92
|
+
sameSite: "strict",
|
|
93
|
+
})
|
|
94
|
+
sig.set("updated")
|
|
95
|
+
expect(sig()).toBe("updated")
|
|
96
|
+
sig.remove()
|
|
97
|
+
expect(sig()).toBe("default")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("expires + domain + secure together", () => {
|
|
101
|
+
const future = new Date(Date.now() + 86400000)
|
|
102
|
+
const sig = useCookie("full-options", "val", {
|
|
103
|
+
expires: future,
|
|
104
|
+
domain: "example.com",
|
|
105
|
+
secure: true,
|
|
106
|
+
sameSite: "none",
|
|
107
|
+
})
|
|
108
|
+
sig.set("updated")
|
|
109
|
+
expect(sig()).toBe("updated")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("all options combined", () => {
|
|
113
|
+
const sig = useCookie("all-opts", "val", {
|
|
114
|
+
maxAge: 7200,
|
|
115
|
+
path: "/dashboard",
|
|
116
|
+
domain: "example.com",
|
|
117
|
+
secure: true,
|
|
118
|
+
sameSite: "strict",
|
|
119
|
+
})
|
|
120
|
+
sig.set("new-val")
|
|
121
|
+
expect(sig()).toBe("new-val")
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("custom serializer/deserializer", () => {
|
|
126
|
+
it("uses custom serializer for cookie writes", () => {
|
|
127
|
+
const sig = useCookie("date-cookie", new Date("2025-01-01"), {
|
|
128
|
+
serializer: (d) => d.toISOString(),
|
|
129
|
+
deserializer: (s) => new Date(s),
|
|
130
|
+
})
|
|
131
|
+
const newDate = new Date("2025-06-15")
|
|
132
|
+
sig.set(newDate)
|
|
133
|
+
expect(sig().toISOString()).toBe("2025-06-15T00:00:00.000Z")
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe("remove with options", () => {
|
|
138
|
+
it("remove() resets signal regardless of cookie options", () => {
|
|
139
|
+
const sig = useCookie("removable", "default", {
|
|
140
|
+
maxAge: 86400,
|
|
141
|
+
path: "/app",
|
|
142
|
+
domain: "example.com",
|
|
143
|
+
})
|
|
144
|
+
sig.set("changed")
|
|
145
|
+
sig.remove()
|
|
146
|
+
expect(sig()).toBe("default")
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("after remove(), a new useCookie call creates a fresh signal", () => {
|
|
150
|
+
const a = useCookie("temp-cookie", "first")
|
|
151
|
+
a.set("modified")
|
|
152
|
+
a.remove()
|
|
153
|
+
|
|
154
|
+
const b = useCookie("temp-cookie", "second")
|
|
155
|
+
expect(b()).toBe("second")
|
|
156
|
+
expect(a).not.toBe(b)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe("onError callback", () => {
|
|
161
|
+
it("calls onError when deserialization fails", () => {
|
|
162
|
+
// Pre-seed a corrupt cookie
|
|
163
|
+
// biome-ignore lint/suspicious/noDocumentCookie: test setup
|
|
164
|
+
document.cookie = `broken-cookie=${encodeURIComponent("{invalid json")}; path=/`
|
|
165
|
+
|
|
166
|
+
const errors: Error[] = []
|
|
167
|
+
const sig = useCookie("broken-cookie", "fallback", {
|
|
168
|
+
onError: (e) => {
|
|
169
|
+
errors.push(e)
|
|
170
|
+
return undefined
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
expect(sig()).toBe("fallback")
|
|
174
|
+
expect(errors).toHaveLength(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("onError can provide custom fallback", () => {
|
|
178
|
+
// biome-ignore lint/suspicious/noDocumentCookie: test setup
|
|
179
|
+
document.cookie = `bad-cookie=${encodeURIComponent("not-json{")}`
|
|
180
|
+
const sig = useCookie("bad-cookie", "default", {
|
|
181
|
+
onError: () => "custom-fallback",
|
|
182
|
+
})
|
|
183
|
+
expect(sig()).toBe("custom-fallback")
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { effect } from "@pyreon/reactivity"
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
3
|
+
import { _resetRegistry, useStorage } from "../index"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for cross-tab synchronization via the native `storage` event.
|
|
7
|
+
* These test the listener that useStorage attaches to `window.addEventListener('storage', ...)`.
|
|
8
|
+
*/
|
|
9
|
+
describe("useStorage — cross-tab sync", () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
localStorage.clear()
|
|
12
|
+
_resetRegistry()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
localStorage.clear()
|
|
17
|
+
_resetRegistry()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("updates signal when storage event fires with a new value", () => {
|
|
21
|
+
const theme = useStorage("theme", "light")
|
|
22
|
+
expect(theme()).toBe("light")
|
|
23
|
+
|
|
24
|
+
window.dispatchEvent(
|
|
25
|
+
Object.assign(new Event("storage"), {
|
|
26
|
+
key: "theme",
|
|
27
|
+
newValue: JSON.stringify("dark"),
|
|
28
|
+
storageArea: localStorage,
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(theme()).toBe("dark")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("resets to default when storage event fires with null newValue (key deleted)", () => {
|
|
36
|
+
const theme = useStorage("theme", "light")
|
|
37
|
+
theme.set("dark")
|
|
38
|
+
expect(theme()).toBe("dark")
|
|
39
|
+
|
|
40
|
+
window.dispatchEvent(
|
|
41
|
+
Object.assign(new Event("storage"), {
|
|
42
|
+
key: "theme",
|
|
43
|
+
newValue: null,
|
|
44
|
+
storageArea: localStorage,
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(theme()).toBe("light")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("ignores storage events for unregistered keys", () => {
|
|
52
|
+
const theme = useStorage("theme", "light")
|
|
53
|
+
|
|
54
|
+
window.dispatchEvent(
|
|
55
|
+
Object.assign(new Event("storage"), {
|
|
56
|
+
key: "unrelated-key",
|
|
57
|
+
newValue: JSON.stringify("value"),
|
|
58
|
+
storageArea: localStorage,
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(theme()).toBe("light")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("ignores storage events with null key", () => {
|
|
66
|
+
const theme = useStorage("theme", "light")
|
|
67
|
+
|
|
68
|
+
window.dispatchEvent(
|
|
69
|
+
Object.assign(new Event("storage"), {
|
|
70
|
+
key: null,
|
|
71
|
+
newValue: null,
|
|
72
|
+
storageArea: localStorage,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(theme()).toBe("light")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("triggers reactive effect on cross-tab update", () => {
|
|
80
|
+
const theme = useStorage("theme", "light")
|
|
81
|
+
const values: string[] = []
|
|
82
|
+
|
|
83
|
+
effect(() => {
|
|
84
|
+
values.push(theme())
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(values).toEqual(["light"])
|
|
88
|
+
|
|
89
|
+
window.dispatchEvent(
|
|
90
|
+
Object.assign(new Event("storage"), {
|
|
91
|
+
key: "theme",
|
|
92
|
+
newValue: JSON.stringify("dark"),
|
|
93
|
+
storageArea: localStorage,
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expect(values).toEqual(["light", "dark"])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("handles corrupt JSON in storage event gracefully (falls back to default)", () => {
|
|
101
|
+
const count = useStorage("count", 0)
|
|
102
|
+
|
|
103
|
+
window.dispatchEvent(
|
|
104
|
+
Object.assign(new Event("storage"), {
|
|
105
|
+
key: "count",
|
|
106
|
+
newValue: "{invalid json",
|
|
107
|
+
storageArea: localStorage,
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// deserialize falls back to defaultValue on parse error
|
|
112
|
+
expect(count()).toBe(0)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("syncs object values across tabs", () => {
|
|
116
|
+
const prefs = useStorage("prefs", { sidebar: true, density: "comfortable" })
|
|
117
|
+
|
|
118
|
+
window.dispatchEvent(
|
|
119
|
+
Object.assign(new Event("storage"), {
|
|
120
|
+
key: "prefs",
|
|
121
|
+
newValue: JSON.stringify({ sidebar: false, density: "compact" }),
|
|
122
|
+
storageArea: localStorage,
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expect(prefs()).toEqual({ sidebar: false, density: "compact" })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("handles multiple rapid storage events", () => {
|
|
130
|
+
const count = useStorage("count", 0)
|
|
131
|
+
|
|
132
|
+
for (let i = 1; i <= 5; i++) {
|
|
133
|
+
window.dispatchEvent(
|
|
134
|
+
Object.assign(new Event("storage"), {
|
|
135
|
+
key: "count",
|
|
136
|
+
newValue: JSON.stringify(i),
|
|
137
|
+
storageArea: localStorage,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect(count()).toBe(5)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("cross-tab sync works independently for different keys", () => {
|
|
146
|
+
const theme = useStorage("theme", "light")
|
|
147
|
+
const lang = useStorage("lang", "en")
|
|
148
|
+
|
|
149
|
+
window.dispatchEvent(
|
|
150
|
+
Object.assign(new Event("storage"), {
|
|
151
|
+
key: "theme",
|
|
152
|
+
newValue: JSON.stringify("dark"),
|
|
153
|
+
storageArea: localStorage,
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(theme()).toBe("dark")
|
|
158
|
+
expect(lang()).toBe("en") // unchanged
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { _resetDBCache, _resetRegistry, useIndexedDB } from "../index"
|
|
3
|
+
|
|
4
|
+
describe("useIndexedDB — debounced writes", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
_resetRegistry()
|
|
7
|
+
_resetDBCache()
|
|
8
|
+
vi.useFakeTimers()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
_resetRegistry()
|
|
13
|
+
_resetDBCache()
|
|
14
|
+
vi.useRealTimers()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("signal updates immediately, IDB write is debounced", () => {
|
|
18
|
+
const draft = useIndexedDB("debounce-test", "initial", { debounceMs: 50 })
|
|
19
|
+
draft.set("updated")
|
|
20
|
+
// Signal is synchronous
|
|
21
|
+
expect(draft()).toBe("updated")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("multiple rapid sets coalesce into one write (only last value)", () => {
|
|
25
|
+
const draft = useIndexedDB("coalesce-test", "", { debounceMs: 100 })
|
|
26
|
+
draft.set("first")
|
|
27
|
+
draft.set("second")
|
|
28
|
+
draft.set("third")
|
|
29
|
+
|
|
30
|
+
// Signal reflects latest immediately
|
|
31
|
+
expect(draft()).toBe("third")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("custom debounceMs is respected", () => {
|
|
35
|
+
const fast = useIndexedDB("fast", "", { debounceMs: 10 })
|
|
36
|
+
fast.set("value")
|
|
37
|
+
expect(fast()).toBe("value")
|
|
38
|
+
|
|
39
|
+
const slow = useIndexedDB("slow", "", { debounceMs: 5000 })
|
|
40
|
+
slow.set("value")
|
|
41
|
+
expect(slow()).toBe("value")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("default debounceMs is 100", () => {
|
|
45
|
+
const sig = useIndexedDB("default-debounce", "initial")
|
|
46
|
+
sig.set("value")
|
|
47
|
+
expect(sig()).toBe("value")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("update() also triggers debounced write", () => {
|
|
51
|
+
const count = useIndexedDB("update-debounce", 0, { debounceMs: 50 })
|
|
52
|
+
count.update((n) => n + 1)
|
|
53
|
+
count.update((n) => n + 1)
|
|
54
|
+
count.update((n) => n + 1)
|
|
55
|
+
expect(count()).toBe(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("remove() cancels pending debounced write", () => {
|
|
59
|
+
const draft = useIndexedDB("remove-cancel", "default", { debounceMs: 200 })
|
|
60
|
+
draft.set("pending-value")
|
|
61
|
+
draft.remove()
|
|
62
|
+
expect(draft()).toBe("default")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("custom dbName and storeName options", () => {
|
|
66
|
+
const sig = useIndexedDB("custom-db-key", "val", {
|
|
67
|
+
dbName: "my-app-db",
|
|
68
|
+
storeName: "my-store",
|
|
69
|
+
})
|
|
70
|
+
sig.set("updated")
|
|
71
|
+
expect(sig()).toBe("updated")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("custom serializer/deserializer with IndexedDB", () => {
|
|
75
|
+
const sig = useIndexedDB("date-idb", new Date("2025-01-01"), {
|
|
76
|
+
serializer: (d) => d.toISOString(),
|
|
77
|
+
deserializer: (s) => new Date(s),
|
|
78
|
+
debounceMs: 50,
|
|
79
|
+
})
|
|
80
|
+
const newDate = new Date("2025-06-15")
|
|
81
|
+
sig.set(newDate)
|
|
82
|
+
expect(sig().toISOString()).toBe("2025-06-15T00:00:00.000Z")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it(".subscribe() works with debounced signals", () => {
|
|
86
|
+
const sig = useIndexedDB("sub-idb", "a", { debounceMs: 50 })
|
|
87
|
+
let called = false
|
|
88
|
+
const unsub = sig.subscribe(() => {
|
|
89
|
+
called = true
|
|
90
|
+
})
|
|
91
|
+
sig.set("b")
|
|
92
|
+
expect(called).toBe(true)
|
|
93
|
+
unsub()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { effect } from "@pyreon/reactivity"
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
3
|
+
import { _resetRegistry, createStorage, useMemoryStorage } from "../index"
|
|
4
|
+
|
|
5
|
+
describe("useMemoryStorage", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
_resetRegistry()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
_resetRegistry()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("works without any browser storage APIs (SSR-safe)", () => {
|
|
15
|
+
const sig = useMemoryStorage("ssr-key", "default")
|
|
16
|
+
expect(sig()).toBe("default")
|
|
17
|
+
sig.set("updated")
|
|
18
|
+
expect(sig()).toBe("updated")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("persists values within the same session", () => {
|
|
22
|
+
const sig = useMemoryStorage("persist-key", "initial")
|
|
23
|
+
sig.set("saved")
|
|
24
|
+
expect(sig()).toBe("saved")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("deduplicates signals for same key", () => {
|
|
28
|
+
const a = useMemoryStorage("dedup", "val")
|
|
29
|
+
const b = useMemoryStorage("dedup", "val")
|
|
30
|
+
expect(a).toBe(b)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("returns different signals for different keys", () => {
|
|
34
|
+
const a = useMemoryStorage("key-a", "val")
|
|
35
|
+
const b = useMemoryStorage("key-b", "val")
|
|
36
|
+
expect(a).not.toBe(b)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it(".remove() resets to default", () => {
|
|
40
|
+
const sig = useMemoryStorage("removable", "default")
|
|
41
|
+
sig.set("changed")
|
|
42
|
+
sig.remove()
|
|
43
|
+
expect(sig()).toBe("default")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("after remove, a new call creates a fresh signal", () => {
|
|
47
|
+
const a = useMemoryStorage("fresh", "first")
|
|
48
|
+
a.set("modified")
|
|
49
|
+
a.remove()
|
|
50
|
+
|
|
51
|
+
const b = useMemoryStorage("fresh", "second")
|
|
52
|
+
expect(b()).toBe("second")
|
|
53
|
+
expect(a).not.toBe(b)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it(".update() works", () => {
|
|
57
|
+
const count = useMemoryStorage("count", 0)
|
|
58
|
+
count.update((n) => n + 1)
|
|
59
|
+
count.update((n) => n + 1)
|
|
60
|
+
expect(count()).toBe(2)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it(".peek() reads without subscribing", () => {
|
|
64
|
+
const sig = useMemoryStorage("peek-key", "val")
|
|
65
|
+
expect(sig.peek()).toBe("val")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("is reactive in effects", () => {
|
|
69
|
+
const sig = useMemoryStorage("reactive", "a")
|
|
70
|
+
const values: string[] = []
|
|
71
|
+
|
|
72
|
+
effect(() => {
|
|
73
|
+
values.push(sig())
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
sig.set("b")
|
|
77
|
+
sig.set("c")
|
|
78
|
+
|
|
79
|
+
expect(values).toEqual(["a", "b", "c"])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("works with complex objects", () => {
|
|
83
|
+
const sig = useMemoryStorage("obj", { name: "", items: [] as string[] })
|
|
84
|
+
sig.set({ name: "test", items: ["a", "b"] })
|
|
85
|
+
expect(sig()).toEqual({ name: "test", items: ["a", "b"] })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("works with arrays", () => {
|
|
89
|
+
const sig = useMemoryStorage("arr", [1, 2, 3])
|
|
90
|
+
sig.set([4, 5, 6])
|
|
91
|
+
expect(sig()).toEqual([4, 5, 6])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("works with booleans", () => {
|
|
95
|
+
const sig = useMemoryStorage("bool", false)
|
|
96
|
+
sig.set(true)
|
|
97
|
+
expect(sig()).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("works with null default", () => {
|
|
101
|
+
const sig = useMemoryStorage<string | null>("nullable", null)
|
|
102
|
+
expect(sig()).toBeNull()
|
|
103
|
+
sig.set("value")
|
|
104
|
+
expect(sig()).toBe("value")
|
|
105
|
+
sig.remove()
|
|
106
|
+
expect(sig()).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it(".debug() returns debug info", () => {
|
|
110
|
+
const sig = useMemoryStorage("debug", "test")
|
|
111
|
+
expect(sig.debug().value).toBe("test")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it(".label can be set and read", () => {
|
|
115
|
+
const sig = useMemoryStorage("label", "val")
|
|
116
|
+
sig.label = "my-memory-signal"
|
|
117
|
+
expect(sig.label).toBe("my-memory-signal")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it(".subscribe() works", () => {
|
|
121
|
+
const sig = useMemoryStorage("sub", "a")
|
|
122
|
+
let called = false
|
|
123
|
+
const unsub = sig.subscribe(() => {
|
|
124
|
+
called = true
|
|
125
|
+
})
|
|
126
|
+
sig.set("b")
|
|
127
|
+
expect(called).toBe(true)
|
|
128
|
+
unsub()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it(".direct() works", () => {
|
|
132
|
+
const sig = useMemoryStorage("dir", "a")
|
|
133
|
+
let called = false
|
|
134
|
+
const unsub = sig.direct(() => {
|
|
135
|
+
called = true
|
|
136
|
+
})
|
|
137
|
+
sig.set("b")
|
|
138
|
+
expect(called).toBe(true)
|
|
139
|
+
unsub()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe("createStorage — custom backends", () => {
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
_resetRegistry()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
_resetRegistry()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("creates storage with a simple Map backend", () => {
|
|
153
|
+
const store = new Map<string, string>()
|
|
154
|
+
const useCustom = createStorage({
|
|
155
|
+
get: (k) => store.get(k) ?? null,
|
|
156
|
+
set: (k, v) => store.set(k, v),
|
|
157
|
+
remove: (k) => store.delete(k),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const sig = useCustom("key", "default")
|
|
161
|
+
expect(sig()).toBe("default")
|
|
162
|
+
|
|
163
|
+
sig.set("value")
|
|
164
|
+
expect(sig()).toBe("value")
|
|
165
|
+
expect(store.get("key")).toBe(JSON.stringify("value"))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("creates a named backend for separate deduplication", () => {
|
|
169
|
+
const storeA = new Map<string, string>()
|
|
170
|
+
const storeB = new Map<string, string>()
|
|
171
|
+
|
|
172
|
+
const useBackendA = createStorage(
|
|
173
|
+
{
|
|
174
|
+
get: (k) => storeA.get(k) ?? null,
|
|
175
|
+
set: (k, v) => storeA.set(k, v),
|
|
176
|
+
remove: (k) => storeA.delete(k),
|
|
177
|
+
},
|
|
178
|
+
"backend-a",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const useBackendB = createStorage(
|
|
182
|
+
{
|
|
183
|
+
get: (k) => storeB.get(k) ?? null,
|
|
184
|
+
set: (k, v) => storeB.set(k, v),
|
|
185
|
+
remove: (k) => storeB.delete(k),
|
|
186
|
+
},
|
|
187
|
+
"backend-b",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const sigA = useBackendA("key", "a-default")
|
|
191
|
+
const sigB = useBackendB("key", "b-default")
|
|
192
|
+
|
|
193
|
+
// Different backends, same key — should be different signals
|
|
194
|
+
expect(sigA).not.toBe(sigB)
|
|
195
|
+
expect(sigA()).toBe("a-default")
|
|
196
|
+
expect(sigB()).toBe("b-default")
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("backend that transforms values (encryption-like)", () => {
|
|
200
|
+
// Simple "encryption" — just base64 encode/decode
|
|
201
|
+
const store = new Map<string, string>()
|
|
202
|
+
const useEncrypted = createStorage({
|
|
203
|
+
get: (k) => {
|
|
204
|
+
const v = store.get(k)
|
|
205
|
+
return v ? atob(v) : null
|
|
206
|
+
},
|
|
207
|
+
set: (k, v) => store.set(k, btoa(v)),
|
|
208
|
+
remove: (k) => store.delete(k),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const sig = useEncrypted("secret", "default")
|
|
212
|
+
sig.set("my-secret-value")
|
|
213
|
+
|
|
214
|
+
expect(sig()).toBe("my-secret-value")
|
|
215
|
+
// Raw stored value should be base64 encoded
|
|
216
|
+
const raw = store.get("secret")
|
|
217
|
+
expect(raw).toBe(btoa(JSON.stringify("my-secret-value")))
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("handles backend.remove() errors gracefully", () => {
|
|
221
|
+
const useCustom = createStorage({
|
|
222
|
+
get: () => null,
|
|
223
|
+
set: () => {
|
|
224
|
+
// no-op
|
|
225
|
+
},
|
|
226
|
+
remove: () => {
|
|
227
|
+
throw new Error("remove failed")
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const sig = useCustom("key", "default")
|
|
232
|
+
sig.set("value")
|
|
233
|
+
// remove() should not throw
|
|
234
|
+
sig.remove()
|
|
235
|
+
expect(sig()).toBe("default")
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("multiple hooks on same custom backend share deduplication", () => {
|
|
239
|
+
const store = new Map<string, string>()
|
|
240
|
+
const useCustom = createStorage(
|
|
241
|
+
{
|
|
242
|
+
get: (k) => store.get(k) ?? null,
|
|
243
|
+
set: (k, v) => store.set(k, v),
|
|
244
|
+
remove: (k) => store.delete(k),
|
|
245
|
+
},
|
|
246
|
+
"shared",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const sig1 = useCustom("shared-key", "default")
|
|
250
|
+
sig1.set("updated")
|
|
251
|
+
|
|
252
|
+
const sig2 = useCustom("shared-key", "default")
|
|
253
|
+
expect(sig1).toBe(sig2) // Same instance
|
|
254
|
+
expect(sig2()).toBe("updated")
|
|
255
|
+
})
|
|
256
|
+
})
|