@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",
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.3"
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.3",
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
+ })