@zerolith/state 0.0.0-stage → 1.1.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/dist/stored.js ADDED
@@ -0,0 +1,130 @@
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 { diagnostic, effect, hasOwner, onDispose, report, signal } from "zerolith";
6
+ /** The storage kinds reported unavailable on this page, each once. */
7
+ const unavailable = new Set();
8
+ /** How many stored signals are live for each key of each storage, so a second one for a key is reported. */
9
+ const live = new Map();
10
+ /** 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. */
11
+ function storageOf(kind, key) {
12
+ try {
13
+ const store = kind === "session" ? globalThis.sessionStorage : globalThis.localStorage;
14
+ store.getItem("");
15
+ return store;
16
+ }
17
+ catch {
18
+ if (!unavailable.has(kind)) {
19
+ unavailable.add(kind);
20
+ report(diagnostic("state_storage_unavailable", "warning", { storage: kind, key }));
21
+ }
22
+ return undefined;
23
+ }
24
+ }
25
+ /** The value kept as `raw` under `key`, or `initial` where nothing is kept or what is kept does not parse, which is reported. */
26
+ function parse(raw, key, initial) {
27
+ if (raw === null) {
28
+ return initial;
29
+ }
30
+ try {
31
+ return JSON.parse(raw);
32
+ }
33
+ catch {
34
+ report(diagnostic("state_storage_unreadable", "warning", { key }));
35
+ return initial;
36
+ }
37
+ }
38
+ /** 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. */
39
+ function toJson(next) {
40
+ try {
41
+ return JSON.stringify(next);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ }
47
+ /**
48
+ * A writable signal kept in storage under `key` as JSON: read back on
49
+ * creation, written on every set, and set again when another tab writes
50
+ * the key, so every tab shows the same value. A missing value takes
51
+ * `initial` quietly; one that does not parse takes `initial` and reports
52
+ * `state_storage_unreadable`. Setting `undefined` removes the key, so it
53
+ * reads back as `initial`; a value JSON cannot hold, such as a BigInt,
54
+ * stays in memory and reports `state_value_not_json`. Where the page has
55
+ * no storage, as on the server, the signal is a plain one and
56
+ * `state_storage_unavailable` is reported once, as it is for each write
57
+ * the storage refuses. Create each key once and share the signal: two
58
+ * stored signals for one key in the same tab do not hear each other,
59
+ * since the storage event tells other tabs alone, and the second reports
60
+ * `state_key_duplicate`. The write and the listener for other tabs are
61
+ * disposed with the enclosing owner when there is one, after which the
62
+ * signal keeps its value in memory alone; created in plain code, they
63
+ * live for the page.
64
+ */
65
+ export function stored(key, initial, options = {}) {
66
+ const kind = options.storage ?? "local";
67
+ const store = storageOf(kind, key);
68
+ const value = signal(store === undefined ? initial : parse(store.getItem(key), key, initial), options.equals === undefined ? undefined : { equals: options.equals });
69
+ if (store === undefined) {
70
+ return value;
71
+ }
72
+ const id = `${kind}:${key}`;
73
+ const count = (live.get(id) ?? 0) + 1;
74
+ live.set(id, count);
75
+ if (count > 1) {
76
+ report(diagnostic("state_key_duplicate", "warning", { storage: kind, key }));
77
+ }
78
+ let first = true;
79
+ let echo = false;
80
+ effect(() => {
81
+ const next = value.get();
82
+ if (first || echo) {
83
+ first = false;
84
+ return;
85
+ }
86
+ const json = toJson(next);
87
+ if (json === undefined && next !== undefined) {
88
+ report(diagnostic("state_value_not_json", "warning", { key }));
89
+ return;
90
+ }
91
+ try {
92
+ if (json === undefined) {
93
+ store.removeItem(key);
94
+ }
95
+ else {
96
+ store.setItem(key, json);
97
+ }
98
+ }
99
+ catch {
100
+ report(diagnostic("state_storage_unavailable", "warning", { storage: kind, key }));
101
+ }
102
+ });
103
+ const onStorage = (event) => {
104
+ if (event.storageArea !== store || (event.key !== null && event.key !== key)) {
105
+ return;
106
+ }
107
+ echo = true;
108
+ try {
109
+ value.set(parse(event.newValue, key, initial));
110
+ }
111
+ finally {
112
+ echo = false;
113
+ }
114
+ };
115
+ addEventListener("storage", onStorage);
116
+ if (hasOwner()) {
117
+ onDispose(() => {
118
+ removeEventListener("storage", onStorage);
119
+ const left = (live.get(id) ?? 1) - 1;
120
+ if (left > 0) {
121
+ live.set(id, left);
122
+ }
123
+ else {
124
+ live.delete(id);
125
+ }
126
+ });
127
+ }
128
+ return value;
129
+ }
130
+ //# sourceMappingURL=stored.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stored.js","sourceRoot":"","sources":["../src/stored.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
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 } from "zerolith";
6
+ /**
7
+ * A signal that follows `source` once it has been quiet for `ms`
8
+ * milliseconds, starting at its current value, so a search runs for the
9
+ * text a reader stopped typing rather than for every keystroke. The
10
+ * pending change is dropped with the enclosing owner when there is one.
11
+ */
12
+ export declare function debounced<T>(source: ReadableSignal<T>, ms: number): ReadableSignal<T>;
13
+ /**
14
+ * A signal that follows `source` at most once per `ms` milliseconds: the
15
+ * first change in a window shows at once, and the last one in it shows
16
+ * when the window ends, so a scroll position drives work at a steady rate.
17
+ * A change still pending is dropped when a newer one shows after the
18
+ * window, and with the enclosing owner when there is one.
19
+ */
20
+ export declare function throttled<T>(source: ReadableSignal<T>, ms: number): ReadableSignal<T>;
21
+ //# sourceMappingURL=timing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,KAAK,cAAc,EAAkD,MAAM,UAAU,CAAC;AAE/F;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,CAgBrF;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,CA4CrF"}
package/dist/timing.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Signals that follow another in their own time: after a quiet spell, or
3
+ * at most so often.
4
+ */
5
+ import { effect, hasOwner, onDispose, signal, untracked } from "zerolith";
6
+ /**
7
+ * A signal that follows `source` once it has been quiet for `ms`
8
+ * milliseconds, starting at its current value, so a search runs for the
9
+ * text a reader stopped typing rather than for every keystroke. The
10
+ * pending change is dropped with the enclosing owner when there is one.
11
+ */
12
+ export function debounced(source, ms) {
13
+ const value = signal(untracked(() => source.get()));
14
+ let first = true;
15
+ effect(() => {
16
+ const next = source.get();
17
+ if (first) {
18
+ first = false;
19
+ return undefined;
20
+ }
21
+ const timer = setTimeout(() => value.set(next), ms);
22
+ return () => clearTimeout(timer);
23
+ });
24
+ return value;
25
+ }
26
+ /**
27
+ * A signal that follows `source` at most once per `ms` milliseconds: the
28
+ * first change in a window shows at once, and the last one in it shows
29
+ * when the window ends, so a scroll position drives work at a steady rate.
30
+ * A change still pending is dropped when a newer one shows after the
31
+ * window, and with the enclosing owner when there is one.
32
+ */
33
+ export function throttled(source, ms) {
34
+ const value = signal(untracked(() => source.get()));
35
+ let last = Number.NEGATIVE_INFINITY;
36
+ let timer;
37
+ let pending;
38
+ let first = true;
39
+ const clear = () => {
40
+ if (timer !== undefined) {
41
+ clearTimeout(timer);
42
+ timer = undefined;
43
+ }
44
+ };
45
+ effect(() => {
46
+ const next = source.get();
47
+ if (first) {
48
+ first = false;
49
+ return;
50
+ }
51
+ const now = Date.now();
52
+ if (now - last >= ms) {
53
+ clear();
54
+ last = now;
55
+ value.set(next);
56
+ return;
57
+ }
58
+ pending = next;
59
+ timer ??= setTimeout(() => {
60
+ timer = undefined;
61
+ last = Date.now();
62
+ value.set(pending);
63
+ }, ms - (now - last));
64
+ });
65
+ if (hasOwner()) {
66
+ onDispose(clear);
67
+ }
68
+ return value;
69
+ }
70
+ //# sourceMappingURL=timing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timing.js","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAuB,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE/F;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAI,MAAyB,EAAE,EAAU;IAE9D,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IACpD,IAAI,KAAK,GAAG,IAAI,CAAC;IACjB,MAAM,CAAC,GAAG,EAAE;QAER,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE,CAAC;QAC1B,IAAI,KAAK,EACT,CAAC;YACG,KAAK,GAAG,KAAK,CAAC;YACd,OAAO,SAAS,CAAC;QACrB,CAAC;QACD,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;QACpD,OAAO,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;IACH,OAAO,KAAK,CAAC;AACjB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,SAAS,CAAI,MAAyB,EAAE,EAAU;IAE9D,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IACpD,IAAI,IAAI,GAAG,MAAM,CAAC,iBAAiB,CAAC;IACpC,IAAI,KAAgD,CAAC;IACrD,IAAI,OAAU,CAAC;IACf,IAAI,KAAK,GAAG,IAAI,CAAC;IACjB,MAAM,KAAK,GAAG,GAAS,EAAE;QAErB,IAAI,KAAK,KAAK,SAAS,EACvB,CAAC;YACG,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,KAAK,GAAG,SAAS,CAAC;QACtB,CAAC;IACL,CAAC,CAAC;IACF,MAAM,CAAC,GAAG,EAAE;QAER,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,EAAE,CAAC;QAC1B,IAAI,KAAK,EACT,CAAC;YACG,KAAK,GAAG,KAAK,CAAC;YACd,OAAO;QACX,CAAC;QACD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,IAAI,GAAG,GAAG,IAAI,IAAI,EAAE,EACpB,CAAC;YACG,KAAK,EAAE,CAAC;YACR,IAAI,GAAG,GAAG,CAAC;YACX,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAChB,OAAO;QACX,CAAC;QACD,OAAO,GAAG,IAAI,CAAC;QACf,KAAK,KAAK,UAAU,CAAC,GAAG,EAAE;YAEtB,KAAK,GAAG,SAAS,CAAC;YAClB,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAClB,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACvB,CAAC,EAAE,EAAE,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;IACH,IAAI,QAAQ,EAAE,EACd,CAAC;QACG,SAAS,CAAC,KAAK,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
- "name": "@zerolith/state",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@zerolith/state",
3
+ "version": "1.1.0",
4
+ "description": "Signals with a memory for zerolith: a signal kept in storage and across tabs, undo and redo, debounced and throttled signals, and a row selector",
5
+ "license": "Apache-2.0",
6
+ "author": "molexxxx",
7
+ "homepage": "https://zerolith.dev/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/molexxxx/zerolith.git",
11
+ "directory": "packages/state"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/molexxxx/zerolith/issues"
15
+ },
16
+ "keywords": [
17
+ "zerolith",
18
+ "signals",
19
+ "state",
20
+ "storage",
21
+ "undo",
22
+ "debounce"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "engines": {
27
+ "node": ">=24.0.0"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "development": "./dist/index.js",
33
+ "default": "./dist/production/index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src",
40
+ "LICENSE",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.build.json && node ../../scripts/production.ts"
45
+ },
46
+ "peerDependencies": {
47
+ "zerolith": "^1.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "zerolith": "^1.1.0"
51
+ }
52
+ }
package/src/history.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Undo and redo for a signal, from explicit checkpoints, as an editor
3
+ * keeps them.
4
+ */
5
+ import { type ReadableSignal, type Signal, batch, signal, untracked } from "zerolith";
6
+
7
+ /** Options for `history`. */
8
+ export interface HistoryOptions
9
+ {
10
+ /** How many checkpoints are kept; the oldest goes when one more is saved. Defaults to 50, which a value that is not a finite number keeps. */
11
+ limit?: number;
12
+ }
13
+
14
+ /** The undo and redo of one signal. */
15
+ export interface History<T>
16
+ {
17
+ /** Saves the signal's current value as the point `undo()` goes back to, and drops what `redo()` could have brought back. */
18
+ checkpoint(): void;
19
+ /** Sets the signal back to the last checkpoint, keeping the current value for `redo()`; false when there is no checkpoint. */
20
+ undo(): boolean;
21
+ /** Sets the signal forward to the value the last `undo()` left; false when there is none. */
22
+ redo(): boolean;
23
+ /** True while `undo()` has somewhere to go, for a button to follow. */
24
+ readonly canUndo: ReadableSignal<boolean>;
25
+ /** True while `redo()` has somewhere to go. */
26
+ readonly canRedo: ReadableSignal<boolean>;
27
+ /** Forgets every checkpoint and every value `redo()` could bring back; the signal keeps its value. */
28
+ dispose(): void;
29
+ /** The signal the history is of. */
30
+ readonly source: Signal<T>;
31
+ }
32
+
33
+ /**
34
+ * Undo and redo for `source` from explicit checkpoints: call
35
+ * `checkpoint()` before a change worth undoing, as an editor does before
36
+ * each edit, and `undo()` sets the signal back to it while `redo()` brings
37
+ * the undone value back. A new checkpoint after an undo starts a new
38
+ * branch, as in most editors, so what was undone is gone. `limit`
39
+ * checkpoints are kept, 50 by default. Each move writes the signal and
40
+ * both flags in one batch, so an effect over them runs once.
41
+ */
42
+ export function history<T>(source: Signal<T>, options: HistoryOptions = {}): History<T>
43
+ {
44
+ const limit = Number.isFinite(options.limit) ? Math.max(1, Math.floor(options.limit as number)) : 50;
45
+ const past: T[] = [];
46
+ const future: T[] = [];
47
+ const canUndo = signal(false);
48
+ const canRedo = signal(false);
49
+ const update = (): void =>
50
+ {
51
+ canUndo.set(past.length > 0);
52
+ canRedo.set(future.length > 0);
53
+ };
54
+ return {
55
+ source,
56
+ canUndo,
57
+ canRedo,
58
+ checkpoint(): void
59
+ {
60
+ past.push(untracked(() => source.get()));
61
+ if (past.length > limit)
62
+ {
63
+ past.shift();
64
+ }
65
+ future.length = 0;
66
+ batch(update);
67
+ },
68
+ undo(): boolean
69
+ {
70
+ if (past.length === 0)
71
+ {
72
+ return false;
73
+ }
74
+ const previous = past.pop() as T;
75
+ future.push(untracked(() => source.get()));
76
+ batch(() =>
77
+ {
78
+ source.set(previous);
79
+ update();
80
+ });
81
+ return true;
82
+ },
83
+ redo(): boolean
84
+ {
85
+ if (future.length === 0)
86
+ {
87
+ return false;
88
+ }
89
+ const next = future.pop() as T;
90
+ past.push(untracked(() => source.get()));
91
+ batch(() =>
92
+ {
93
+ source.set(next);
94
+ update();
95
+ });
96
+ return true;
97
+ },
98
+ dispose(): void
99
+ {
100
+ past.length = 0;
101
+ future.length = 0;
102
+ batch(update);
103
+ }
104
+ };
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @zerolith/state: signals with a memory, over the core alone. A signal
3
+ * kept in storage and in step across tabs, undo and redo for a signal,
4
+ * signals that follow another after a quiet spell or at most so often, and
5
+ * a selector that tells each row whether it is the selected one. The public
6
+ * surface is assembled here.
7
+ */
8
+ export type { StoredOptions } from "./stored.js";
9
+ export { stored } from "./stored.js";
10
+
11
+ export type { History, HistoryOptions } from "./history.js";
12
+ export { history } from "./history.js";
13
+
14
+ export { debounced, throttled } from "./timing.js";
15
+
16
+ export { selector } from "./selector.js";
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A selector over a signal: one boolean signal per key, true for the key
3
+ * the source holds, so a selection among many rows updates two rows
4
+ * rather than every row's own comparison.
5
+ */
6
+ import { type ReadableSignal, type Signal, effect, signal, untracked } from "zerolith";
7
+
8
+ /** True when `Map` would treat `a` and `b` as one key. */
9
+ function same(a: unknown, b: unknown): boolean
10
+ {
11
+ return a === b || (a !== a && b !== b);
12
+ }
13
+
14
+ /**
15
+ * A per-key view of `source`: `selected(key)` is a signal that is true
16
+ * while `source` holds `key`, and a change of `source` updates the two
17
+ * keys concerned alone, so a list of a thousand rows that each bind
18
+ * `selected(row.id)` runs two bindings on a selection, not a thousand
19
+ * comparisons. Keys are compared as `Map` keys are. A key's signal is
20
+ * shared by everyone who asks for it, and the selector keeps no hold of
21
+ * its own: the signal lives while a view reads it and goes with the row
22
+ * that did.
23
+ */
24
+ export function selector<T>(source: ReadableSignal<T>): (key: T) => ReadableSignal<boolean>
25
+ {
26
+ const flags = new Map<T, WeakRef<Signal<boolean>>>();
27
+ const gone = new FinalizationRegistry<T>((key) =>
28
+ {
29
+ if (flags.get(key)?.deref() === undefined)
30
+ {
31
+ flags.delete(key);
32
+ }
33
+ });
34
+ let current = untracked(() => source.get());
35
+ effect(() =>
36
+ {
37
+ const next = source.get();
38
+ if (same(next, current))
39
+ {
40
+ return;
41
+ }
42
+ flags.get(current)?.deref()?.set(false);
43
+ flags.get(next)?.deref()?.set(true);
44
+ current = next;
45
+ });
46
+ return (key) =>
47
+ {
48
+ let flag = flags.get(key)?.deref();
49
+ if (flag === undefined)
50
+ {
51
+ flag = signal(same(key, current));
52
+ flags.set(key, new WeakRef(flag));
53
+ gone.register(flag, key);
54
+ }
55
+ return flag;
56
+ };
57
+ }
package/src/stored.ts ADDED
@@ -0,0 +1,173 @@
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
+
16
+ /** The storage kinds reported unavailable on this page, each once. */
17
+ const unavailable = new Set<string>();
18
+
19
+ /** How many stored signals are live for each key of each storage, so a second one for a key is reported. */
20
+ const live = new Map<string, number>();
21
+
22
+ /** 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. */
23
+ function storageOf(kind: "local" | "session", key: string): Storage | undefined
24
+ {
25
+ try
26
+ {
27
+ const store = kind === "session" ? globalThis.sessionStorage : globalThis.localStorage;
28
+ store.getItem("");
29
+ return store;
30
+ }
31
+ catch
32
+ {
33
+ if (!unavailable.has(kind))
34
+ {
35
+ unavailable.add(kind);
36
+ report(diagnostic("state_storage_unavailable", "warning", { storage: kind, key }));
37
+ }
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /** The value kept as `raw` under `key`, or `initial` where nothing is kept or what is kept does not parse, which is reported. */
43
+ function parse<T>(raw: string | null, key: string, initial: T): T
44
+ {
45
+ if (raw === null)
46
+ {
47
+ return initial;
48
+ }
49
+ try
50
+ {
51
+ return JSON.parse(raw) as T;
52
+ }
53
+ catch
54
+ {
55
+ report(diagnostic("state_storage_unreadable", "warning", { key }));
56
+ return initial;
57
+ }
58
+ }
59
+
60
+ /** 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. */
61
+ function toJson(next: unknown): string | undefined
62
+ {
63
+ try
64
+ {
65
+ return JSON.stringify(next);
66
+ }
67
+ catch
68
+ {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * A writable signal kept in storage under `key` as JSON: read back on
75
+ * creation, written on every set, and set again when another tab writes
76
+ * the key, so every tab shows the same value. A missing value takes
77
+ * `initial` quietly; one that does not parse takes `initial` and reports
78
+ * `state_storage_unreadable`. Setting `undefined` removes the key, so it
79
+ * reads back as `initial`; a value JSON cannot hold, such as a BigInt,
80
+ * stays in memory and reports `state_value_not_json`. Where the page has
81
+ * no storage, as on the server, the signal is a plain one and
82
+ * `state_storage_unavailable` is reported once, as it is for each write
83
+ * the storage refuses. Create each key once and share the signal: two
84
+ * stored signals for one key in the same tab do not hear each other,
85
+ * since the storage event tells other tabs alone, and the second reports
86
+ * `state_key_duplicate`. The write and the listener for other tabs are
87
+ * disposed with the enclosing owner when there is one, after which the
88
+ * signal keeps its value in memory alone; created in plain code, they
89
+ * live for the page.
90
+ */
91
+ export function stored<T>(key: string, initial: T, options: StoredOptions<T> = {}): Signal<T>
92
+ {
93
+ const kind = options.storage ?? "local";
94
+ const store = storageOf(kind, key);
95
+ const value = signal<T>(store === undefined ? initial : parse(store.getItem(key), key, initial), options.equals === undefined ? undefined : { equals: options.equals });
96
+ if (store === undefined)
97
+ {
98
+ return value;
99
+ }
100
+ const id = `${kind}:${key}`;
101
+ const count = (live.get(id) ?? 0) + 1;
102
+ live.set(id, count);
103
+ if (count > 1)
104
+ {
105
+ report(diagnostic("state_key_duplicate", "warning", { storage: kind, key }));
106
+ }
107
+ let first = true;
108
+ let echo = false;
109
+ effect(() =>
110
+ {
111
+ const next = value.get();
112
+ if (first || echo)
113
+ {
114
+ first = false;
115
+ return;
116
+ }
117
+ const json = toJson(next);
118
+ if (json === undefined && next !== undefined)
119
+ {
120
+ report(diagnostic("state_value_not_json", "warning", { key }));
121
+ return;
122
+ }
123
+ try
124
+ {
125
+ if (json === undefined)
126
+ {
127
+ store.removeItem(key);
128
+ }
129
+ else
130
+ {
131
+ store.setItem(key, json);
132
+ }
133
+ }
134
+ catch
135
+ {
136
+ report(diagnostic("state_storage_unavailable", "warning", { storage: kind, key }));
137
+ }
138
+ });
139
+ const onStorage = (event: StorageEvent): void =>
140
+ {
141
+ if (event.storageArea !== store || (event.key !== null && event.key !== key))
142
+ {
143
+ return;
144
+ }
145
+ echo = true;
146
+ try
147
+ {
148
+ value.set(parse(event.newValue, key, initial));
149
+ }
150
+ finally
151
+ {
152
+ echo = false;
153
+ }
154
+ };
155
+ addEventListener("storage", onStorage);
156
+ if (hasOwner())
157
+ {
158
+ onDispose(() =>
159
+ {
160
+ removeEventListener("storage", onStorage);
161
+ const left = (live.get(id) ?? 1) - 1;
162
+ if (left > 0)
163
+ {
164
+ live.set(id, left);
165
+ }
166
+ else
167
+ {
168
+ live.delete(id);
169
+ }
170
+ });
171
+ }
172
+ return value;
173
+ }