@pyreon/storage 0.15.0 → 0.16.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +71 -44
- package/package.json +4 -4
- package/src/cookie.ts +3 -14
- package/src/custom.ts +3 -14
- package/src/indexed-db.ts +3 -14
- package/src/local.ts +6 -15
- package/src/tests/bind-text-compat.test.ts +104 -0
- package/src/wrap-base-signal.ts +88 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"1fd55a13-1","name":"registry.ts"},{"uid":"1fd55a13-3","name":"utils.ts"},{"uid":"1fd55a13-5","name":"wrap-base-signal.ts"},{"uid":"1fd55a13-7","name":"cookie.ts"},{"uid":"1fd55a13-9","name":"custom.ts"},{"uid":"1fd55a13-11","name":"indexed-db.ts"},{"uid":"1fd55a13-13","name":"local.ts"},{"uid":"1fd55a13-15","name":"session.ts"},{"uid":"1fd55a13-17","name":"clear.ts"},{"uid":"1fd55a13-19","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"1fd55a13-1":{"renderedLength":1076,"gzipLength":468,"brotliLength":0,"metaUid":"1fd55a13-0"},"1fd55a13-3":{"renderedLength":1231,"gzipLength":565,"brotliLength":0,"metaUid":"1fd55a13-2"},"1fd55a13-5":{"renderedLength":2580,"gzipLength":1289,"brotliLength":0,"metaUid":"1fd55a13-4"},"1fd55a13-7":{"renderedLength":2914,"gzipLength":1091,"brotliLength":0,"metaUid":"1fd55a13-6"},"1fd55a13-9":{"renderedLength":1931,"gzipLength":789,"brotliLength":0,"metaUid":"1fd55a13-8"},"1fd55a13-11":{"renderedLength":4242,"gzipLength":1437,"brotliLength":0,"metaUid":"1fd55a13-10"},"1fd55a13-13":{"renderedLength":2916,"gzipLength":1041,"brotliLength":0,"metaUid":"1fd55a13-12"},"1fd55a13-15":{"renderedLength":932,"gzipLength":464,"brotliLength":0,"metaUid":"1fd55a13-14"},"1fd55a13-17":{"renderedLength":1489,"gzipLength":604,"brotliLength":0,"metaUid":"1fd55a13-16"},"1fd55a13-19":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"1fd55a13-18"}},"nodeMetas":{"1fd55a13-0":{"id":"/src/registry.ts","moduleParts":{"index.js":"1fd55a13-1"},"imported":[],"importedBy":[{"uid":"1fd55a13-18"},{"uid":"1fd55a13-6"},{"uid":"1fd55a13-8"},{"uid":"1fd55a13-10"},{"uid":"1fd55a13-12"},{"uid":"1fd55a13-14"},{"uid":"1fd55a13-16"}]},"1fd55a13-2":{"id":"/src/utils.ts","moduleParts":{"index.js":"1fd55a13-3"},"imported":[],"importedBy":[{"uid":"1fd55a13-6"},{"uid":"1fd55a13-8"},{"uid":"1fd55a13-10"},{"uid":"1fd55a13-12"},{"uid":"1fd55a13-14"},{"uid":"1fd55a13-16"}]},"1fd55a13-4":{"id":"/src/wrap-base-signal.ts","moduleParts":{"index.js":"1fd55a13-5"},"imported":[],"importedBy":[{"uid":"1fd55a13-6"},{"uid":"1fd55a13-8"},{"uid":"1fd55a13-10"},{"uid":"1fd55a13-12"}]},"1fd55a13-6":{"id":"/src/cookie.ts","moduleParts":{"index.js":"1fd55a13-7"},"imported":[{"uid":"1fd55a13-20"},{"uid":"1fd55a13-0"},{"uid":"1fd55a13-2"},{"uid":"1fd55a13-4"}],"importedBy":[{"uid":"1fd55a13-18"}]},"1fd55a13-8":{"id":"/src/custom.ts","moduleParts":{"index.js":"1fd55a13-9"},"imported":[{"uid":"1fd55a13-20"},{"uid":"1fd55a13-0"},{"uid":"1fd55a13-2"},{"uid":"1fd55a13-4"}],"importedBy":[{"uid":"1fd55a13-18"}]},"1fd55a13-10":{"id":"/src/indexed-db.ts","moduleParts":{"index.js":"1fd55a13-11"},"imported":[{"uid":"1fd55a13-20"},{"uid":"1fd55a13-0"},{"uid":"1fd55a13-2"},{"uid":"1fd55a13-4"}],"importedBy":[{"uid":"1fd55a13-18"}]},"1fd55a13-12":{"id":"/src/local.ts","moduleParts":{"index.js":"1fd55a13-13"},"imported":[{"uid":"1fd55a13-20"},{"uid":"1fd55a13-0"},{"uid":"1fd55a13-2"},{"uid":"1fd55a13-4"}],"importedBy":[{"uid":"1fd55a13-18"},{"uid":"1fd55a13-14"}]},"1fd55a13-14":{"id":"/src/session.ts","moduleParts":{"index.js":"1fd55a13-15"},"imported":[{"uid":"1fd55a13-20"},{"uid":"1fd55a13-12"},{"uid":"1fd55a13-0"},{"uid":"1fd55a13-2"}],"importedBy":[{"uid":"1fd55a13-18"}]},"1fd55a13-16":{"id":"/src/clear.ts","moduleParts":{"index.js":"1fd55a13-17"},"imported":[{"uid":"1fd55a13-0"},{"uid":"1fd55a13-2"}],"importedBy":[{"uid":"1fd55a13-18"}]},"1fd55a13-18":{"id":"/src/index.ts","moduleParts":{"index.js":"1fd55a13-19"},"imported":[{"uid":"1fd55a13-6"},{"uid":"1fd55a13-8"},{"uid":"1fd55a13-10"},{"uid":"1fd55a13-12"},{"uid":"1fd55a13-14"},{"uid":"1fd55a13-16"},{"uid":"1fd55a13-0"}],"importedBy":[],"isEntry":true},"1fd55a13-20":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"1fd55a13-6"},{"uid":"1fd55a13-8"},{"uid":"1fd55a13-10"},{"uid":"1fd55a13-12"},{"uid":"1fd55a13-14"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -94,6 +94,73 @@ function getWebStorage(type) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/wrap-base-signal.ts
|
|
99
|
+
/**
|
|
100
|
+
* Wrap a base `signal()` from `@pyreon/reactivity` with a callable that
|
|
101
|
+
* fully participates in Pyreon's reactivity, including the compiler-
|
|
102
|
+
* emitted DOM-binding fast paths (`_bindText` / `_bindDirect`).
|
|
103
|
+
*
|
|
104
|
+
* The wrapper:
|
|
105
|
+
* - Is callable: `wrapper()` returns `sig()` (read + subscribe).
|
|
106
|
+
* - Delegates `.peek` / `.subscribe` / `.direct` / `.debug` to the
|
|
107
|
+
* underlying signal — methods, not state, so re-binding is safe.
|
|
108
|
+
* - Forwards `.label` (getter + setter) to the underlying signal so
|
|
109
|
+
* dev-time naming carries through.
|
|
110
|
+
* - Forwards the internal `_v` field via getter so the compiler's
|
|
111
|
+
* `_bindText(wrapper, textNode)` fast path reads the live value.
|
|
112
|
+
* Without this, the binding writes `String(undefined)` → `''` on
|
|
113
|
+
* initial render AND every subscriber notification (the bug class
|
|
114
|
+
* fixed in PR #546 and now caught by the
|
|
115
|
+
* `pyreon/storage-signal-v-forwarding` lint rule).
|
|
116
|
+
*
|
|
117
|
+
* The wrapper is RETURNED as `signal()` minus the methods callers
|
|
118
|
+
* typically OVERRIDE (`.set`, `.update`, `.remove`, plus any factory-
|
|
119
|
+
* specific extras). Each storage factory layers its persistence
|
|
120
|
+
* behavior on top by assigning these fields to the returned wrapper
|
|
121
|
+
* before returning to the user.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```ts
|
|
125
|
+
* const sig = signal<T>(initialValue)
|
|
126
|
+
* const storageSig = wrapBaseSignal(sig) as StorageSignal<T>
|
|
127
|
+
* storageSig.set = (value: T) => {
|
|
128
|
+
* sig.set(value)
|
|
129
|
+
* localStorage.setItem(key, serialize(value))
|
|
130
|
+
* }
|
|
131
|
+
* storageSig.remove = () => { ... }
|
|
132
|
+
* return storageSig
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* **Why this exists**: pre-2026-05-13 the same wrapper shape was
|
|
136
|
+
* duplicated across 4 factories (~30 lines × 4 sites). The duplication
|
|
137
|
+
* structurally enabled the `_v` forwarding bug — only `local.ts:createStorageSignal`
|
|
138
|
+
* was shared between local + session; cookie / custom / indexed-db each had
|
|
139
|
+
* their own factory body. Forgetting `_v` in one site went unnoticed for
|
|
140
|
+
* ~9 months. This helper makes the contract single-source: every backend
|
|
141
|
+
* gets the same wrapper, and field additions to the signal protocol land
|
|
142
|
+
* in one place.
|
|
143
|
+
*/
|
|
144
|
+
function wrapBaseSignal(sig) {
|
|
145
|
+
const wrapper = (() => sig());
|
|
146
|
+
wrapper.peek = () => sig.peek();
|
|
147
|
+
wrapper.subscribe = (listener) => sig.subscribe(listener);
|
|
148
|
+
wrapper.direct = (updater) => sig.direct(updater);
|
|
149
|
+
wrapper.debug = () => sig.debug();
|
|
150
|
+
Object.defineProperty(wrapper, "label", {
|
|
151
|
+
get: () => sig.label,
|
|
152
|
+
set: (v) => {
|
|
153
|
+
sig.label = v;
|
|
154
|
+
},
|
|
155
|
+
configurable: true
|
|
156
|
+
});
|
|
157
|
+
Object.defineProperty(wrapper, "_v", {
|
|
158
|
+
get: () => sig._v,
|
|
159
|
+
configurable: true
|
|
160
|
+
});
|
|
161
|
+
return wrapper;
|
|
162
|
+
}
|
|
163
|
+
|
|
97
164
|
//#endregion
|
|
98
165
|
//#region src/cookie.ts
|
|
99
166
|
let serverCookieString = "";
|
|
@@ -169,17 +236,7 @@ function useCookie(key, defaultValue, options = {}) {
|
|
|
169
236
|
if (existing) return existing.signal;
|
|
170
237
|
const raw = readCookie(key);
|
|
171
238
|
const sig = signal(raw !== null ? deserialize(raw, defaultValue, options.deserializer, options.onError) : defaultValue);
|
|
172
|
-
const storageSig = (
|
|
173
|
-
storageSig.peek = () => sig.peek();
|
|
174
|
-
storageSig.subscribe = (listener) => sig.subscribe(listener);
|
|
175
|
-
storageSig.direct = (updater) => sig.direct(updater);
|
|
176
|
-
storageSig.debug = () => sig.debug();
|
|
177
|
-
Object.defineProperty(storageSig, "label", {
|
|
178
|
-
get: () => sig.label,
|
|
179
|
-
set: (v) => {
|
|
180
|
-
sig.label = v;
|
|
181
|
-
}
|
|
182
|
-
});
|
|
239
|
+
const storageSig = wrapBaseSignal(sig);
|
|
183
240
|
storageSig.set = (value) => {
|
|
184
241
|
sig.set(value);
|
|
185
242
|
writeCookie(key, value, options);
|
|
@@ -225,17 +282,7 @@ function createStorage(backend, backendName) {
|
|
|
225
282
|
if (raw !== null) initialValue = deserialize(raw, defaultValue, options?.deserializer, options?.onError);
|
|
226
283
|
} catch {}
|
|
227
284
|
const sig = signal(initialValue);
|
|
228
|
-
const storageSig = (
|
|
229
|
-
storageSig.peek = () => sig.peek();
|
|
230
|
-
storageSig.subscribe = (listener) => sig.subscribe(listener);
|
|
231
|
-
storageSig.direct = (updater) => sig.direct(updater);
|
|
232
|
-
storageSig.debug = () => sig.debug();
|
|
233
|
-
Object.defineProperty(storageSig, "label", {
|
|
234
|
-
get: () => sig.label,
|
|
235
|
-
set: (v) => {
|
|
236
|
-
sig.label = v;
|
|
237
|
-
}
|
|
238
|
-
});
|
|
285
|
+
const storageSig = wrapBaseSignal(sig);
|
|
239
286
|
storageSig.set = (value) => {
|
|
240
287
|
sig.set(value);
|
|
241
288
|
try {
|
|
@@ -363,17 +410,7 @@ function useIndexedDB(key, defaultValue, options = {}) {
|
|
|
363
410
|
if (writeTimer !== null) clearTimeout(writeTimer);
|
|
364
411
|
writeTimer = setTimeout(flushWrite, debounceMs);
|
|
365
412
|
}
|
|
366
|
-
const storageSig = (
|
|
367
|
-
storageSig.peek = () => sig.peek();
|
|
368
|
-
storageSig.subscribe = (listener) => sig.subscribe(listener);
|
|
369
|
-
storageSig.direct = (updater) => sig.direct(updater);
|
|
370
|
-
storageSig.debug = () => sig.debug();
|
|
371
|
-
Object.defineProperty(storageSig, "label", {
|
|
372
|
-
get: () => sig.label,
|
|
373
|
-
set: (v) => {
|
|
374
|
-
sig.label = v;
|
|
375
|
-
}
|
|
376
|
-
});
|
|
413
|
+
const storageSig = wrapBaseSignal(sig);
|
|
377
414
|
storageSig.set = (value) => {
|
|
378
415
|
sig.set(value);
|
|
379
416
|
scheduleWrite(value);
|
|
@@ -472,17 +509,7 @@ function useStorage(key, defaultValue, options) {
|
|
|
472
509
|
*/
|
|
473
510
|
function createStorageSignal(sig, key, defaultValue, backend, options) {
|
|
474
511
|
const storage = getWebStorage(backend);
|
|
475
|
-
const storageSig = (
|
|
476
|
-
storageSig.peek = () => sig.peek();
|
|
477
|
-
storageSig.subscribe = (listener) => sig.subscribe(listener);
|
|
478
|
-
storageSig.direct = (updater) => sig.direct(updater);
|
|
479
|
-
storageSig.debug = () => sig.debug();
|
|
480
|
-
Object.defineProperty(storageSig, "label", {
|
|
481
|
-
get: () => sig.label,
|
|
482
|
-
set: (v) => {
|
|
483
|
-
sig.label = v;
|
|
484
|
-
}
|
|
485
|
-
});
|
|
512
|
+
const storageSig = wrapBaseSignal(sig);
|
|
486
513
|
storageSig.set = (value) => {
|
|
487
514
|
sig.set(value);
|
|
488
515
|
if (storage) try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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": {
|
|
@@ -44,11 +44,11 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
46
46
|
"@pyreon/manifest": "0.13.1",
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
47
|
+
"@pyreon/reactivity": "^0.16.0",
|
|
48
48
|
"@vitus-labs/tools-lint": "^2.3.0",
|
|
49
49
|
"bun-types": "^1.3.12"
|
|
50
50
|
},
|
|
51
|
-
"
|
|
52
|
-
"@pyreon/reactivity": "^0.
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@pyreon/reactivity": "^0.16.0"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/cookie.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { signal } from '@pyreon/reactivity'
|
|
|
2
2
|
import { getEntry, removeEntry, setEntry } from './registry'
|
|
3
3
|
import type { CookieOptions, StorageSignal } from './types'
|
|
4
4
|
import { deserialize, isBrowser, serialize } from './utils'
|
|
5
|
+
import { wrapBaseSignal } from './wrap-base-signal'
|
|
5
6
|
|
|
6
7
|
// ─── Server-side cookie source ───────────────────────────────────────────────
|
|
7
8
|
|
|
@@ -122,20 +123,8 @@ export function useCookie<T>(
|
|
|
122
123
|
|
|
123
124
|
const sig = signal<T>(initialValue)
|
|
124
125
|
|
|
125
|
-
//
|
|
126
|
-
const storageSig = (
|
|
127
|
-
|
|
128
|
-
storageSig.peek = () => sig.peek()
|
|
129
|
-
storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
130
|
-
storageSig.direct = (updater: () => void) => sig.direct(updater)
|
|
131
|
-
storageSig.debug = () => sig.debug()
|
|
132
|
-
|
|
133
|
-
Object.defineProperty(storageSig, 'label', {
|
|
134
|
-
get: () => sig.label,
|
|
135
|
-
set: (v: string | undefined) => {
|
|
136
|
-
sig.label = v
|
|
137
|
-
},
|
|
138
|
-
})
|
|
126
|
+
// Shared base wrapper — see `wrap-base-signal.ts` for the full contract.
|
|
127
|
+
const storageSig = wrapBaseSignal(sig) as unknown as StorageSignal<T>
|
|
139
128
|
|
|
140
129
|
storageSig.set = (value: T) => {
|
|
141
130
|
sig.set(value)
|
package/src/custom.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { signal } from '@pyreon/reactivity'
|
|
|
2
2
|
import { getEntry, removeEntry, setEntry } from './registry'
|
|
3
3
|
import type { StorageBackend, StorageOptions, StorageSignal } from './types'
|
|
4
4
|
import { deserialize, serialize } from './utils'
|
|
5
|
+
import { wrapBaseSignal } from './wrap-base-signal'
|
|
5
6
|
|
|
6
7
|
// ─── createStorage ───────────────────────────────────────────────────────────
|
|
7
8
|
|
|
@@ -48,20 +49,8 @@ export function createStorage(
|
|
|
48
49
|
|
|
49
50
|
const sig = signal<T>(initialValue)
|
|
50
51
|
|
|
51
|
-
//
|
|
52
|
-
const storageSig = (
|
|
53
|
-
|
|
54
|
-
storageSig.peek = () => sig.peek()
|
|
55
|
-
storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
56
|
-
storageSig.direct = (updater: () => void) => sig.direct(updater)
|
|
57
|
-
storageSig.debug = () => sig.debug()
|
|
58
|
-
|
|
59
|
-
Object.defineProperty(storageSig, 'label', {
|
|
60
|
-
get: () => sig.label,
|
|
61
|
-
set: (v: string | undefined) => {
|
|
62
|
-
sig.label = v
|
|
63
|
-
},
|
|
64
|
-
})
|
|
52
|
+
// Shared base wrapper — see `wrap-base-signal.ts` for the full contract.
|
|
53
|
+
const storageSig = wrapBaseSignal(sig) as unknown as StorageSignal<T>
|
|
65
54
|
|
|
66
55
|
storageSig.set = (value: T) => {
|
|
67
56
|
sig.set(value)
|
package/src/indexed-db.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { signal } from '@pyreon/reactivity'
|
|
|
2
2
|
import { getEntry, removeEntry, setEntry } from './registry'
|
|
3
3
|
import type { IndexedDBOptions, StorageSignal } from './types'
|
|
4
4
|
import { deserialize, isBrowser, serialize } from './utils'
|
|
5
|
+
import { wrapBaseSignal } from './wrap-base-signal'
|
|
5
6
|
|
|
6
7
|
const __DEV__: boolean = process.env.NODE_ENV !== 'production'
|
|
7
8
|
|
|
@@ -140,20 +141,8 @@ export function useIndexedDB<T>(
|
|
|
140
141
|
writeTimer = setTimeout(flushWrite, debounceMs)
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
//
|
|
144
|
-
const storageSig = (
|
|
145
|
-
|
|
146
|
-
storageSig.peek = () => sig.peek()
|
|
147
|
-
storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
148
|
-
storageSig.direct = (updater: () => void) => sig.direct(updater)
|
|
149
|
-
storageSig.debug = () => sig.debug()
|
|
150
|
-
|
|
151
|
-
Object.defineProperty(storageSig, 'label', {
|
|
152
|
-
get: () => sig.label,
|
|
153
|
-
set: (v: string | undefined) => {
|
|
154
|
-
sig.label = v
|
|
155
|
-
},
|
|
156
|
-
})
|
|
144
|
+
// Shared base wrapper — see `wrap-base-signal.ts` for the full contract.
|
|
145
|
+
const storageSig = wrapBaseSignal(sig) as unknown as StorageSignal<T>
|
|
157
146
|
|
|
158
147
|
storageSig.set = (value: T) => {
|
|
159
148
|
sig.set(value)
|
package/src/local.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { signal } from '@pyreon/reactivity'
|
|
|
2
2
|
import { getEntry, removeEntry, setEntry } from './registry'
|
|
3
3
|
import type { StorageOptions, StorageSignal } from './types'
|
|
4
4
|
import { deserialize, getWebStorage, isBrowser, serialize } from './utils'
|
|
5
|
+
import { wrapBaseSignal } from './wrap-base-signal'
|
|
5
6
|
|
|
6
7
|
// ─── Cross-tab sync ──────────────────────────────────────────────────────────
|
|
7
8
|
|
|
@@ -119,21 +120,11 @@ export function createStorageSignal<T>(
|
|
|
119
120
|
): StorageSignal<T> {
|
|
120
121
|
const storage = getWebStorage(backend)
|
|
121
122
|
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
storageSig
|
|
127
|
-
storageSig.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
128
|
-
storageSig.direct = (updater: () => void) => sig.direct(updater)
|
|
129
|
-
storageSig.debug = () => sig.debug()
|
|
130
|
-
|
|
131
|
-
Object.defineProperty(storageSig, 'label', {
|
|
132
|
-
get: () => sig.label,
|
|
133
|
-
set: (v: string | undefined) => {
|
|
134
|
-
sig.label = v
|
|
135
|
-
},
|
|
136
|
-
})
|
|
123
|
+
// Shared base wrapper — callable + `.peek` / `.subscribe` / `.direct` /
|
|
124
|
+
// `.debug` / `.label` / forwarded `_v`. See `wrap-base-signal.ts` for
|
|
125
|
+
// the full contract (including why `_v` forwarding is load-bearing for
|
|
126
|
+
// the compiler-emitted `_bindText` fast path).
|
|
127
|
+
const storageSig = wrapBaseSignal(sig) as unknown as StorageSignal<T>
|
|
137
128
|
|
|
138
129
|
// Override set to persist
|
|
139
130
|
storageSig.set = (value: T) => {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: storage signals didn't forward the internal `_v` field that
|
|
3
|
+
* the compiler-emitted `_bindText` / `_bindDirect` fast paths read.
|
|
4
|
+
*
|
|
5
|
+
* Bug shape: when JSX `{() => theme()}` (where `theme` was declared via
|
|
6
|
+
* `useStorage`) lands in a text binding, the compiler optimizes to
|
|
7
|
+
* `_bindText(theme, textNode)` instead of `_bindText(() => theme(), textNode)`.
|
|
8
|
+
* `_bindText`'s fast path reads `source._v` directly (skipping the function
|
|
9
|
+
* call) AND registers the text-update closure via `source.direct(...)`.
|
|
10
|
+
*
|
|
11
|
+
* Storage signals delegated `.direct` (so subscribe-on-change worked) but
|
|
12
|
+
* forgot `._v` — the text-update read undefined → wrote empty string. The
|
|
13
|
+
* symptom: SSR rendered `<strong>light</strong>` correctly but post-
|
|
14
|
+
* hydration the textNode went empty and stayed empty even after
|
|
15
|
+
* `theme.set('dark')` (the binding fired but read the missing `_v` again).
|
|
16
|
+
*
|
|
17
|
+
* Fix: forward `_v` via getter so storage signals honor the same
|
|
18
|
+
* structural contract as base signals.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
21
|
+
import {
|
|
22
|
+
_resetRegistry,
|
|
23
|
+
_resetStorageListener,
|
|
24
|
+
useCookie,
|
|
25
|
+
useMemoryStorage,
|
|
26
|
+
useSessionStorage,
|
|
27
|
+
useStorage,
|
|
28
|
+
} from '../index'
|
|
29
|
+
|
|
30
|
+
interface SignalLike<T> {
|
|
31
|
+
_v: T
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const internal = <T,>(sig: unknown): SignalLike<T> => sig as SignalLike<T>
|
|
35
|
+
|
|
36
|
+
describe('storage signals — _bindText / _bindDirect compat (`_v` forwarding)', () => {
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
_resetRegistry()
|
|
39
|
+
_resetStorageListener()
|
|
40
|
+
try {
|
|
41
|
+
localStorage.clear()
|
|
42
|
+
sessionStorage.clear()
|
|
43
|
+
} catch {
|
|
44
|
+
// Cross-origin / disabled — skip.
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('useStorage signal forwards `_v` to the underlying base signal', () => {
|
|
49
|
+
const theme = useStorage('test-theme-v', 'light')
|
|
50
|
+
// Bug-shape: pre-fix this is `undefined`.
|
|
51
|
+
expect(internal<string>(theme)._v).toBe('light')
|
|
52
|
+
|
|
53
|
+
// After set, both the public read and `_v` reflect the new value.
|
|
54
|
+
theme.set('dark')
|
|
55
|
+
expect(theme()).toBe('dark')
|
|
56
|
+
expect(internal<string>(theme)._v).toBe('dark')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('useStorage `_v` reflects values read from localStorage on init', () => {
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem('test-init', JSON.stringify('preloaded'))
|
|
62
|
+
} catch {
|
|
63
|
+
// Skip if storage unavailable.
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const sig = useStorage('test-init', 'fallback')
|
|
67
|
+
expect(sig()).toBe('preloaded')
|
|
68
|
+
expect(internal<string>(sig)._v).toBe('preloaded')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('useSessionStorage signal forwards `_v`', () => {
|
|
72
|
+
const step = useSessionStorage('test-step-v', 1)
|
|
73
|
+
expect(internal<number>(step)._v).toBe(1)
|
|
74
|
+
step.set(3)
|
|
75
|
+
expect(internal<number>(step)._v).toBe(3)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('useCookie signal forwards `_v`', () => {
|
|
79
|
+
const locale = useCookie('test-locale-v', 'en')
|
|
80
|
+
expect(internal<string>(locale)._v).toBe('en')
|
|
81
|
+
locale.set('de')
|
|
82
|
+
expect(internal<string>(locale)._v).toBe('de')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('useMemoryStorage signal forwards `_v`', () => {
|
|
86
|
+
const note = useMemoryStorage('test-note-v', '')
|
|
87
|
+
expect(internal<string>(note)._v).toBe('')
|
|
88
|
+
note.set('hello')
|
|
89
|
+
expect(internal<string>(note)._v).toBe('hello')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('`_v` getter tracks the underlying signal even when the wrapper is held across mutations', () => {
|
|
93
|
+
// The wrapper is captured ONCE; the getter must read the LIVE value
|
|
94
|
+
// from the underlying signal each access. Pre-fix this is undefined
|
|
95
|
+
// forever. Post-fix it tracks the underlying `sig._v` on every read.
|
|
96
|
+
const theme = useStorage('test-live-v', 'a')
|
|
97
|
+
const captured = theme
|
|
98
|
+
expect(internal<string>(captured)._v).toBe('a')
|
|
99
|
+
theme.set('b')
|
|
100
|
+
expect(internal<string>(captured)._v).toBe('b')
|
|
101
|
+
theme.set('c')
|
|
102
|
+
expect(internal<string>(captured)._v).toBe('c')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { signal } from '@pyreon/reactivity'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a base `signal()` from `@pyreon/reactivity` with a callable that
|
|
5
|
+
* fully participates in Pyreon's reactivity, including the compiler-
|
|
6
|
+
* emitted DOM-binding fast paths (`_bindText` / `_bindDirect`).
|
|
7
|
+
*
|
|
8
|
+
* The wrapper:
|
|
9
|
+
* - Is callable: `wrapper()` returns `sig()` (read + subscribe).
|
|
10
|
+
* - Delegates `.peek` / `.subscribe` / `.direct` / `.debug` to the
|
|
11
|
+
* underlying signal — methods, not state, so re-binding is safe.
|
|
12
|
+
* - Forwards `.label` (getter + setter) to the underlying signal so
|
|
13
|
+
* dev-time naming carries through.
|
|
14
|
+
* - Forwards the internal `_v` field via getter so the compiler's
|
|
15
|
+
* `_bindText(wrapper, textNode)` fast path reads the live value.
|
|
16
|
+
* Without this, the binding writes `String(undefined)` → `''` on
|
|
17
|
+
* initial render AND every subscriber notification (the bug class
|
|
18
|
+
* fixed in PR #546 and now caught by the
|
|
19
|
+
* `pyreon/storage-signal-v-forwarding` lint rule).
|
|
20
|
+
*
|
|
21
|
+
* The wrapper is RETURNED as `signal()` minus the methods callers
|
|
22
|
+
* typically OVERRIDE (`.set`, `.update`, `.remove`, plus any factory-
|
|
23
|
+
* specific extras). Each storage factory layers its persistence
|
|
24
|
+
* behavior on top by assigning these fields to the returned wrapper
|
|
25
|
+
* before returning to the user.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* const sig = signal<T>(initialValue)
|
|
30
|
+
* const storageSig = wrapBaseSignal(sig) as StorageSignal<T>
|
|
31
|
+
* storageSig.set = (value: T) => {
|
|
32
|
+
* sig.set(value)
|
|
33
|
+
* localStorage.setItem(key, serialize(value))
|
|
34
|
+
* }
|
|
35
|
+
* storageSig.remove = () => { ... }
|
|
36
|
+
* return storageSig
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* **Why this exists**: pre-2026-05-13 the same wrapper shape was
|
|
40
|
+
* duplicated across 4 factories (~30 lines × 4 sites). The duplication
|
|
41
|
+
* structurally enabled the `_v` forwarding bug — only `local.ts:createStorageSignal`
|
|
42
|
+
* was shared between local + session; cookie / custom / indexed-db each had
|
|
43
|
+
* their own factory body. Forgetting `_v` in one site went unnoticed for
|
|
44
|
+
* ~9 months. This helper makes the contract single-source: every backend
|
|
45
|
+
* gets the same wrapper, and field additions to the signal protocol land
|
|
46
|
+
* in one place.
|
|
47
|
+
*/
|
|
48
|
+
export function wrapBaseSignal<T>(sig: ReturnType<typeof signal<T>>): {
|
|
49
|
+
(): T
|
|
50
|
+
peek(): T
|
|
51
|
+
subscribe(listener: () => void): () => void
|
|
52
|
+
direct(updater: () => void): () => void
|
|
53
|
+
debug(): unknown
|
|
54
|
+
label: string | undefined
|
|
55
|
+
} {
|
|
56
|
+
type Wrapper = {
|
|
57
|
+
(): T
|
|
58
|
+
peek(): T
|
|
59
|
+
subscribe(listener: () => void): () => void
|
|
60
|
+
direct(updater: () => void): () => void
|
|
61
|
+
debug(): unknown
|
|
62
|
+
label: string | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const wrapper = (() => sig()) as unknown as Wrapper
|
|
66
|
+
|
|
67
|
+
wrapper.peek = () => sig.peek()
|
|
68
|
+
wrapper.subscribe = (listener: () => void) => sig.subscribe(listener)
|
|
69
|
+
wrapper.direct = (updater: () => void) => sig.direct(updater)
|
|
70
|
+
wrapper.debug = () => sig.debug()
|
|
71
|
+
|
|
72
|
+
Object.defineProperty(wrapper, 'label', {
|
|
73
|
+
get: () => sig.label,
|
|
74
|
+
set: (v: string | undefined) => {
|
|
75
|
+
sig.label = v
|
|
76
|
+
},
|
|
77
|
+
configurable: true,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Forward `_v` so the compiler-emitted `_bindText(wrapper, textNode)`
|
|
81
|
+
// fast path reads the live value. See file header for context.
|
|
82
|
+
Object.defineProperty(wrapper, '_v', {
|
|
83
|
+
get: () => (sig as unknown as { _v: T })._v,
|
|
84
|
+
configurable: true,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return wrapper
|
|
88
|
+
}
|