@zerolith/state 0.0.0-canary.20261107.g95d90a0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +32 -0
- package/dist/history.d.ts +42 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +78 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/production/history.js +2 -0
- package/dist/production/history.js.map +1 -0
- package/dist/production/index.js +2 -0
- package/dist/production/index.js.map +1 -0
- package/dist/production/selector.js +2 -0
- package/dist/production/selector.js.map +1 -0
- package/dist/production/stored.js +2 -0
- package/dist/production/stored.js.map +1 -0
- package/dist/production/timing.js +2 -0
- package/dist/production/timing.js.map +1 -0
- package/dist/selector.d.ts +18 -0
- package/dist/selector.d.ts.map +1 -0
- package/dist/selector.js +48 -0
- package/dist/selector.js.map +1 -0
- package/dist/stored.d.ts +46 -0
- package/dist/stored.d.ts.map +1 -0
- package/dist/stored.js +158 -0
- package/dist/stored.js.map +1 -0
- package/dist/timing.d.ts +21 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +70 -0
- package/dist/timing.js.map +1 -0
- package/package.json +52 -0
- package/src/history.ts +116 -0
- package/src/index.ts +16 -0
- package/src/selector.ts +57 -0
- package/src/stored.ts +216 -0
- package/src/timing.ts +82 -0
package/src/stored.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A signal kept in the browser's storage under a key, as JSON, and in step
|
|
3
|
+
* across tabs through the `storage` event.
|
|
4
|
+
*/
|
|
5
|
+
import { type Signal, diagnostic, effect, hasOwner, onDispose, report, signal } from "zerolith";
|
|
6
|
+
|
|
7
|
+
/** Options for `stored`. */
|
|
8
|
+
export interface StoredOptions<T>
|
|
9
|
+
{
|
|
10
|
+
/** Which storage keeps the value: `localStorage`, shared across tabs and kept across sessions, or `sessionStorage`, this tab's alone. Defaults to local. */
|
|
11
|
+
storage?: "local" | "session";
|
|
12
|
+
/** When two values count as the same, so a set of an equal value changes nothing; the signal's own option. */
|
|
13
|
+
equals?: (previous: T, next: T) => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a kept value, once parsed, is one the signal can hold. A value
|
|
16
|
+
* it refuses, or throws on, is read as `initial` and reported as
|
|
17
|
+
* `state_storage_unreadable`, on creation and when another tab writes the
|
|
18
|
+
* key, so data of another shape, such as another app's under the same
|
|
19
|
+
* key, never reaches the page.
|
|
20
|
+
*/
|
|
21
|
+
validate?: (value: unknown) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The storage kinds reported unavailable on this page, each once. */
|
|
25
|
+
const unavailable = new Set<string>();
|
|
26
|
+
|
|
27
|
+
/** How many stored signals are live for each key of each storage, so a second one for a key is reported. */
|
|
28
|
+
const live = new Map<string, number>();
|
|
29
|
+
|
|
30
|
+
/** What runs after a stored signal takes a value another tab wrote, keyed by the signal. */
|
|
31
|
+
const outside = new WeakMap<Signal<unknown>, Set<() => void>>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs `listener` each time `source` takes a value another tab wrote, which
|
|
35
|
+
* only a stored signal does, and returns the function that stops it. The
|
|
36
|
+
* history uses it; the package does not export it.
|
|
37
|
+
*/
|
|
38
|
+
export function onOutsideWrite(source: Signal<unknown>, listener: () => void): () => void
|
|
39
|
+
{
|
|
40
|
+
let listeners = outside.get(source);
|
|
41
|
+
if (listeners === undefined)
|
|
42
|
+
{
|
|
43
|
+
listeners = new Set();
|
|
44
|
+
outside.set(source, listeners);
|
|
45
|
+
}
|
|
46
|
+
listeners.add(listener);
|
|
47
|
+
return () =>
|
|
48
|
+
{
|
|
49
|
+
listeners.delete(listener);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The storage of a kind, or undefined where the page has none or refuses it, which is reported once per kind with the first key asked for. */
|
|
54
|
+
function storageOf(kind: "local" | "session", key: string): Storage | undefined
|
|
55
|
+
{
|
|
56
|
+
try
|
|
57
|
+
{
|
|
58
|
+
const store = kind === "session" ? globalThis.sessionStorage : globalThis.localStorage;
|
|
59
|
+
store.getItem("");
|
|
60
|
+
return store;
|
|
61
|
+
}
|
|
62
|
+
catch
|
|
63
|
+
{
|
|
64
|
+
if (!unavailable.has(kind))
|
|
65
|
+
{
|
|
66
|
+
unavailable.add(kind);
|
|
67
|
+
report(diagnostic("state_storage_unavailable", "warning", { storage: kind, key }));
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The value kept as `raw` under `key`, or `initial` where nothing is kept, or where what is kept does not parse or `validate` refuses it, which is reported. */
|
|
74
|
+
function parse<T>(raw: string | null, key: string, initial: T, validate: ((value: unknown) => boolean) | undefined): T
|
|
75
|
+
{
|
|
76
|
+
if (raw === null)
|
|
77
|
+
{
|
|
78
|
+
return initial;
|
|
79
|
+
}
|
|
80
|
+
let value: unknown;
|
|
81
|
+
let accepted: boolean;
|
|
82
|
+
try
|
|
83
|
+
{
|
|
84
|
+
value = JSON.parse(raw);
|
|
85
|
+
accepted = validate === undefined || validate(value);
|
|
86
|
+
}
|
|
87
|
+
catch
|
|
88
|
+
{
|
|
89
|
+
accepted = false;
|
|
90
|
+
}
|
|
91
|
+
if (!accepted)
|
|
92
|
+
{
|
|
93
|
+
report(diagnostic("state_storage_unreadable", "warning", { key }));
|
|
94
|
+
return initial;
|
|
95
|
+
}
|
|
96
|
+
return value as T;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The JSON for `next`, or undefined for a value JSON cannot hold: a BigInt or a cycle, which throws, and a function or a symbol, which it leaves out. */
|
|
100
|
+
function toJson(next: unknown): string | undefined
|
|
101
|
+
{
|
|
102
|
+
try
|
|
103
|
+
{
|
|
104
|
+
return JSON.stringify(next);
|
|
105
|
+
}
|
|
106
|
+
catch
|
|
107
|
+
{
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A writable signal kept in storage under `key` as JSON: read back on
|
|
114
|
+
* creation, written on every set, and set again when another tab writes
|
|
115
|
+
* the key, so every tab shows the same value. A missing value takes
|
|
116
|
+
* `initial` quietly; one that does not parse, or that the `validate`
|
|
117
|
+
* option refuses, takes `initial` and reports `state_storage_unreadable`. Setting `undefined` removes the key, so it
|
|
118
|
+
* reads back as `initial`; a value JSON cannot hold, such as a BigInt,
|
|
119
|
+
* stays in memory and reports `state_value_not_json`. Where the page has
|
|
120
|
+
* no storage, as on the server, the signal is a plain one and
|
|
121
|
+
* `state_storage_unavailable` is reported once, as it is for each write
|
|
122
|
+
* the storage refuses. Create each key once and share the signal: two
|
|
123
|
+
* stored signals for one key in the same tab do not hear each other,
|
|
124
|
+
* since the storage event tells other tabs alone, and the second reports
|
|
125
|
+
* `state_key_duplicate`. The write and the listener for other tabs are
|
|
126
|
+
* disposed with the enclosing owner when there is one, after which the
|
|
127
|
+
* signal keeps its value in memory alone; created in plain code, they
|
|
128
|
+
* live for the page.
|
|
129
|
+
*/
|
|
130
|
+
export function stored<T>(key: string, initial: T, options: StoredOptions<T> = {}): Signal<T>
|
|
131
|
+
{
|
|
132
|
+
const kind = options.storage ?? "local";
|
|
133
|
+
const store = storageOf(kind, key);
|
|
134
|
+
const value = signal<T>(store === undefined ? initial : parse(store.getItem(key), key, initial, options.validate), options.equals === undefined ? undefined : { equals: options.equals });
|
|
135
|
+
if (store === undefined)
|
|
136
|
+
{
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
const id = `${kind}:${key}`;
|
|
140
|
+
const count = (live.get(id) ?? 0) + 1;
|
|
141
|
+
live.set(id, count);
|
|
142
|
+
if (count > 1)
|
|
143
|
+
{
|
|
144
|
+
report(diagnostic("state_key_duplicate", "warning", { storage: kind, key }));
|
|
145
|
+
}
|
|
146
|
+
let first = true;
|
|
147
|
+
let echo = false;
|
|
148
|
+
effect(() =>
|
|
149
|
+
{
|
|
150
|
+
const next = value.get();
|
|
151
|
+
if (first || echo)
|
|
152
|
+
{
|
|
153
|
+
first = false;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const json = toJson(next);
|
|
157
|
+
if (json === undefined && next !== undefined)
|
|
158
|
+
{
|
|
159
|
+
report(diagnostic("state_value_not_json", "warning", { key }));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
try
|
|
163
|
+
{
|
|
164
|
+
if (json === undefined)
|
|
165
|
+
{
|
|
166
|
+
store.removeItem(key);
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
{
|
|
170
|
+
store.setItem(key, json);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch
|
|
174
|
+
{
|
|
175
|
+
report(diagnostic("state_storage_unavailable", "warning", { storage: kind, key }));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const onStorage = (event: StorageEvent): void =>
|
|
179
|
+
{
|
|
180
|
+
if (event.storageArea !== store || (event.key !== null && event.key !== key))
|
|
181
|
+
{
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
echo = true;
|
|
185
|
+
try
|
|
186
|
+
{
|
|
187
|
+
value.set(parse(event.newValue, key, initial, options.validate));
|
|
188
|
+
}
|
|
189
|
+
finally
|
|
190
|
+
{
|
|
191
|
+
echo = false;
|
|
192
|
+
}
|
|
193
|
+
for (const listener of outside.get(value as Signal<unknown>) ?? [])
|
|
194
|
+
{
|
|
195
|
+
listener();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
addEventListener("storage", onStorage);
|
|
199
|
+
if (hasOwner())
|
|
200
|
+
{
|
|
201
|
+
onDispose(() =>
|
|
202
|
+
{
|
|
203
|
+
removeEventListener("storage", onStorage);
|
|
204
|
+
const left = (live.get(id) ?? 1) - 1;
|
|
205
|
+
if (left > 0)
|
|
206
|
+
{
|
|
207
|
+
live.set(id, left);
|
|
208
|
+
}
|
|
209
|
+
else
|
|
210
|
+
{
|
|
211
|
+
live.delete(id);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return value;
|
|
216
|
+
}
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signals that follow another in their own time: after a quiet spell, or
|
|
3
|
+
* at most so often.
|
|
4
|
+
*/
|
|
5
|
+
import { type ReadableSignal, effect, hasOwner, onDispose, signal, untracked } from "zerolith";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A signal that follows `source` once it has been quiet for `ms`
|
|
9
|
+
* milliseconds, starting at its current value, so a search runs for the
|
|
10
|
+
* text a reader stopped typing rather than for every keystroke. The
|
|
11
|
+
* pending change is dropped with the enclosing owner when there is one.
|
|
12
|
+
*/
|
|
13
|
+
export function debounced<T>(source: ReadableSignal<T>, ms: number): ReadableSignal<T>
|
|
14
|
+
{
|
|
15
|
+
const value = signal(untracked(() => source.get()));
|
|
16
|
+
let first = true;
|
|
17
|
+
effect(() =>
|
|
18
|
+
{
|
|
19
|
+
const next = source.get();
|
|
20
|
+
if (first)
|
|
21
|
+
{
|
|
22
|
+
first = false;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const timer = setTimeout(() => value.set(next), ms);
|
|
26
|
+
return () => clearTimeout(timer);
|
|
27
|
+
});
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A signal that follows `source` at most once per `ms` milliseconds: the
|
|
33
|
+
* first change in a window shows at once, and the last one in it shows
|
|
34
|
+
* when the window ends, so a scroll position drives work at a steady rate.
|
|
35
|
+
* A change still pending is dropped when a newer one shows after the
|
|
36
|
+
* window, and with the enclosing owner when there is one.
|
|
37
|
+
*/
|
|
38
|
+
export function throttled<T>(source: ReadableSignal<T>, ms: number): ReadableSignal<T>
|
|
39
|
+
{
|
|
40
|
+
const value = signal(untracked(() => source.get()));
|
|
41
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
let pending: T;
|
|
44
|
+
let first = true;
|
|
45
|
+
const clear = (): void =>
|
|
46
|
+
{
|
|
47
|
+
if (timer !== undefined)
|
|
48
|
+
{
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
timer = undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
effect(() =>
|
|
54
|
+
{
|
|
55
|
+
const next = source.get();
|
|
56
|
+
if (first)
|
|
57
|
+
{
|
|
58
|
+
first = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (now - last >= ms)
|
|
63
|
+
{
|
|
64
|
+
clear();
|
|
65
|
+
last = now;
|
|
66
|
+
value.set(next);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
pending = next;
|
|
70
|
+
timer ??= setTimeout(() =>
|
|
71
|
+
{
|
|
72
|
+
timer = undefined;
|
|
73
|
+
last = Date.now();
|
|
74
|
+
value.set(pending);
|
|
75
|
+
}, ms - (now - last));
|
|
76
|
+
});
|
|
77
|
+
if (hasOwner())
|
|
78
|
+
{
|
|
79
|
+
onDispose(clear);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|