@k8ordo/state 0.2.0 → 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/README.md +53 -28
- package/dist/base.d.mts +9 -0
- package/dist/base.mjs +15 -0
- package/dist/cookie-state.d.mts +38 -0
- package/dist/cookie-state.mjs +35 -0
- package/dist/entry/codec.d.mts +3 -4
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +4 -3
- package/dist/memory-state.d.mts +3 -4
- package/dist/page-state.d.mts +21 -11
- package/dist/page-state.mjs +17 -3
- package/dist/register.d.mts +25 -19
- package/dist/row/codec.d.mts +48 -0
- package/dist/row/codec.mjs +60 -0
- package/dist/schema/object.d.mts +8 -9
- package/dist/schema/object.mjs +10 -11
- package/dist/storage-state.d.mts +38 -0
- package/dist/storage-state.mjs +37 -0
- package/dist/store/cookie-store.d.mts +12 -0
- package/dist/store/cookie-store.mjs +129 -0
- package/dist/store/core.d.mts +12 -12
- package/dist/store/memory-store.d.mts +3 -4
- package/dist/store/page-store.d.mts +7 -6
- package/dist/store/registry.d.mts +3 -4
- package/dist/store/storage-store.d.mts +9 -0
- package/dist/store/storage-store.mjs +97 -0
- package/dist/url/codec.d.mts +4 -5
- package/dist/url/codec.mjs +32 -11
- package/dist/use-app-state.d.mts +15 -6
- package/dist/use-app-state.mjs +16 -17
- package/docs/GUIDE.md +450 -115
- package/docs/llms.txt +72 -31
- package/package.json +24 -19
- package/dist/local-state.d.mts +0 -29
- package/dist/local-state.mjs +0 -30
- package/dist/store/local-store.d.mts +0 -9
- package/dist/store/local-store.mjs +0 -79
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { StateSchema } from "./schema/object.mjs";
|
|
2
|
+
import { RowCodec, Versioning } from "./row/codec.mjs";
|
|
3
|
+
//#region src/storage-state.d.ts
|
|
4
|
+
type StorageState<Kind extends 'local' | 'session', Schema extends StateSchema> = {
|
|
5
|
+
kind: Kind;
|
|
6
|
+
/** Identity of this state: the storage key and the store registry slot. */
|
|
7
|
+
key: string;
|
|
8
|
+
/** The schema as passed. */
|
|
9
|
+
schema: Schema;
|
|
10
|
+
/** The Web Storage key the values are written under: `k8ordo-state:<key>`. */
|
|
11
|
+
storageKey: string;
|
|
12
|
+
/**
|
|
13
|
+
* A JavaScript expression, for an inline `<script>`, that evaluates in the
|
|
14
|
+
* browser to the stored object — or `null` when nothing is stored, the JSON
|
|
15
|
+
* is corrupt, the value is not an object, storage cannot be read, or (for a
|
|
16
|
+
* versioned local state) the row was written by another version. Neither
|
|
17
|
+
* the schema nor `migrate` runs there: treat the result as untrusted and
|
|
18
|
+
* read only the fields you need, each with its own fallback.
|
|
19
|
+
*/
|
|
20
|
+
inlineRead: () => string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* App-scope state that lives in localStorage: device-persistent, shared
|
|
24
|
+
* across tabs, never page-bound — which is why it is its own definition kind
|
|
25
|
+
* instead of a slot on a page state.
|
|
26
|
+
*/
|
|
27
|
+
export type LocalState<Schema extends StateSchema = StateSchema> = StorageState<'local', Schema>;
|
|
28
|
+
/**
|
|
29
|
+
* App-scope state that lives in sessionStorage: kept through reloads of the
|
|
30
|
+
* tab and gone when the tab closes, shared with no other tab — built the way
|
|
31
|
+
* local state is, over the other Web Storage area.
|
|
32
|
+
*/
|
|
33
|
+
export type SessionState<Schema extends StateSchema = StateSchema> = StorageState<'session', Schema>;
|
|
34
|
+
export declare const storageCodecOf: (def: LocalState | SessionState) => RowCodec;
|
|
35
|
+
export declare const defineLocalState: <Schema extends StateSchema>(key: string, schema: Schema, versioning?: Versioning<Schema>) => LocalState<Schema>;
|
|
36
|
+
export declare const defineSessionState: <Schema extends StateSchema>(key: string, schema: Schema) => SessionState<Schema>;
|
|
37
|
+
//#endregion
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createRowCodec } from "./row/codec.mjs";
|
|
2
|
+
//#region src/storage-state.ts
|
|
3
|
+
const STORAGE_KEY_PREFIX = "k8ordo-state:";
|
|
4
|
+
/**
|
|
5
|
+
* The key as a JavaScript string literal. `JSON.stringify` escapes it for
|
|
6
|
+
* JavaScript; `<` is escaped on top so the literal can never close the inline
|
|
7
|
+
* `<script>` it is written into, whatever the key contains.
|
|
8
|
+
*/
|
|
9
|
+
const literalOf = (text) => JSON.stringify(text).replaceAll("<", String.raw`\u003c`);
|
|
10
|
+
const isObjectExpression = (value) => `${value}!==null&&typeof ${value}==="object"&&!Array.isArray(${value})`;
|
|
11
|
+
const inlineReadOf = (area, storageKey, version) => {
|
|
12
|
+
const row = `JSON.parse(${area}.getItem(${literalOf(storageKey)}))`;
|
|
13
|
+
return version === void 0 ? `(()=>{try{const v=${row};return ${isObjectExpression("v")}?v:null}catch{return null}})()` : `(()=>{try{const r=${row};const v=Array.isArray(r)&&r.length===2&&r[0]===${String(version)}?r[1]:null;return ${isObjectExpression("v")}?v:null}catch{return null}})()`;
|
|
14
|
+
};
|
|
15
|
+
const codecs = /* @__PURE__ */ new WeakMap();
|
|
16
|
+
const storageCodecOf = (def) => {
|
|
17
|
+
const codec = codecs.get(def);
|
|
18
|
+
if (codec === void 0) throw new TypeError(`"${def.key}" was not created by ${def.kind === "local" ? "defineLocalState" : "defineSessionState"} of this module instance`);
|
|
19
|
+
return codec;
|
|
20
|
+
};
|
|
21
|
+
const defineStorageState = (kind, area, key, schema, versioning) => {
|
|
22
|
+
const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
|
|
23
|
+
const codec = createRowCodec(schema, kind, key, versioning);
|
|
24
|
+
const def = {
|
|
25
|
+
kind,
|
|
26
|
+
key,
|
|
27
|
+
schema,
|
|
28
|
+
storageKey,
|
|
29
|
+
inlineRead: () => inlineReadOf(area, storageKey, codec.version)
|
|
30
|
+
};
|
|
31
|
+
codecs.set(def, codec);
|
|
32
|
+
return def;
|
|
33
|
+
};
|
|
34
|
+
const defineLocalState = (key, schema, versioning) => defineStorageState("local", "localStorage", key, schema, versioning);
|
|
35
|
+
const defineSessionState = (key, schema) => defineStorageState("session", "sessionStorage", key, schema, void 0);
|
|
36
|
+
//#endregion
|
|
37
|
+
export { defineLocalState, defineSessionState, storageCodecOf };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StateValues } from "../schema/object.mjs";
|
|
2
|
+
import { CookieState } from "../cookie-state.mjs";
|
|
3
|
+
import { Store } from "./core.mjs";
|
|
4
|
+
//#region src/store/cookie-store.d.ts
|
|
5
|
+
export declare const cookieStoreOf: (def: CookieState) => Store;
|
|
6
|
+
/**
|
|
7
|
+
* What SSR and the hydration render see: the values the server read from the
|
|
8
|
+
* request's cookies when the caller passed them down; the defaults otherwise.
|
|
9
|
+
* Pure on purpose: the server must never touch `document` or create a store.
|
|
10
|
+
*/
|
|
11
|
+
export declare const cookieInitialSnapshot: (def: CookieState, initialCookie: Readonly<StateValues> | undefined) => StateValues;
|
|
12
|
+
//#endregion
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { cookieCodecOf, parseCookieText } from "../cookie-state.mjs";
|
|
2
|
+
import { createHandle, createStoreCore, resolvePatch } from "./core.mjs";
|
|
3
|
+
import { getOrCreateStore } from "./registry.mjs";
|
|
4
|
+
//#region src/store/cookie-store.ts
|
|
5
|
+
const MAX_AGE = 3456e4;
|
|
6
|
+
const rawCookie = (name) => {
|
|
7
|
+
for (const part of document.cookie.split(";")) {
|
|
8
|
+
const at = part.indexOf("=");
|
|
9
|
+
if (at !== -1 && part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
const decoded = (raw) => {
|
|
13
|
+
if (raw === void 0) return void 0;
|
|
14
|
+
try {
|
|
15
|
+
return decodeURIComponent(raw);
|
|
16
|
+
} catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const createCookieStore = (def) => {
|
|
21
|
+
const codec = cookieCodecOf(def);
|
|
22
|
+
const { cookieName } = def;
|
|
23
|
+
const readRow = () => codec.read(parseCookieText(decoded(rawCookie(cookieName))));
|
|
24
|
+
const read = () => readRow().values;
|
|
25
|
+
let pending = null;
|
|
26
|
+
let handle = null;
|
|
27
|
+
let writing = 0;
|
|
28
|
+
let missed = false;
|
|
29
|
+
const sync = () => {
|
|
30
|
+
if (writing > 0) {
|
|
31
|
+
missed = true;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
missed = false;
|
|
35
|
+
const { values: fresh, stale } = readRow();
|
|
36
|
+
if (stale) writeBack();
|
|
37
|
+
core.applyNext(pending === null ? fresh : {
|
|
38
|
+
...fresh,
|
|
39
|
+
...pending
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const onChange = (event) => {
|
|
43
|
+
if ([...event.changed, ...event.deleted].some((cookie) => cookie.name === cookieName)) sync();
|
|
44
|
+
};
|
|
45
|
+
cookieStore.addEventListener("change", onChange);
|
|
46
|
+
const write = async (patch, current) => {
|
|
47
|
+
try {
|
|
48
|
+
const target = codec.salvage({
|
|
49
|
+
...read(),
|
|
50
|
+
...patch
|
|
51
|
+
});
|
|
52
|
+
const init = {
|
|
53
|
+
name: cookieName,
|
|
54
|
+
value: encodeURIComponent(JSON.stringify(codec.row(target))),
|
|
55
|
+
path: "/",
|
|
56
|
+
sameSite: "lax",
|
|
57
|
+
maxAge: MAX_AGE
|
|
58
|
+
};
|
|
59
|
+
await cookieStore.set(init);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
writing -= 1;
|
|
62
|
+
if (missed) sync();
|
|
63
|
+
current.fail(error);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
writing -= 1;
|
|
67
|
+
sync();
|
|
68
|
+
current.settle();
|
|
69
|
+
};
|
|
70
|
+
let writes = Promise.resolve();
|
|
71
|
+
const enqueue = (patch, current) => {
|
|
72
|
+
writing += 1;
|
|
73
|
+
writes = writes.then(() => write(patch, current));
|
|
74
|
+
};
|
|
75
|
+
const flush = () => {
|
|
76
|
+
const patch = pending;
|
|
77
|
+
const current = handle;
|
|
78
|
+
pending = null;
|
|
79
|
+
handle = null;
|
|
80
|
+
enqueue(patch, current);
|
|
81
|
+
};
|
|
82
|
+
function writeBack() {
|
|
83
|
+
enqueue({}, createHandle());
|
|
84
|
+
}
|
|
85
|
+
const first = readRow();
|
|
86
|
+
const core = createStoreCore(codec.keys, first.values);
|
|
87
|
+
if (first.stale) writeBack();
|
|
88
|
+
const update = (patch) => {
|
|
89
|
+
const resolved = resolvePatch(patch, core.snapshot(), codec.keys, def.key);
|
|
90
|
+
if (pending === null) {
|
|
91
|
+
pending = {};
|
|
92
|
+
handle = createHandle();
|
|
93
|
+
queueMicrotask(flush);
|
|
94
|
+
}
|
|
95
|
+
Object.assign(pending, resolved);
|
|
96
|
+
const echoed = codec.salvage({
|
|
97
|
+
...core.snapshot(),
|
|
98
|
+
...pending
|
|
99
|
+
});
|
|
100
|
+
for (const key of Object.keys(pending)) pending[key] = echoed[key];
|
|
101
|
+
core.applyNext(echoed);
|
|
102
|
+
return handle.external;
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
subscribe: core.subscribe,
|
|
106
|
+
getSnapshot: core.getSnapshot,
|
|
107
|
+
update,
|
|
108
|
+
dispose: () => {
|
|
109
|
+
cookieStore.removeEventListener("change", onChange);
|
|
110
|
+
core.clear();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
const cookieStoreOf = (def) => getOrCreateStore("cookie", def.key, () => createCookieStore(def));
|
|
115
|
+
/**
|
|
116
|
+
* What SSR and the hydration render see: the values the server read from the
|
|
117
|
+
* request's cookies when the caller passed them down; the defaults otherwise.
|
|
118
|
+
* Pure on purpose: the server must never touch `document` or create a store.
|
|
119
|
+
*/
|
|
120
|
+
const cookieInitialSnapshot = (def, initialCookie) => {
|
|
121
|
+
const codec = cookieCodecOf(def);
|
|
122
|
+
const base = { ...codec.defaults };
|
|
123
|
+
if (initialCookie !== void 0) {
|
|
124
|
+
for (const key of codec.keys) if (key in initialCookie) base[key] = initialCookie[key];
|
|
125
|
+
}
|
|
126
|
+
return base;
|
|
127
|
+
};
|
|
128
|
+
//#endregion
|
|
129
|
+
export { cookieInitialSnapshot, cookieStoreOf };
|
package/dist/store/core.d.mts
CHANGED
|
@@ -4,17 +4,18 @@ import { StateValues } from "../schema/object.mjs";
|
|
|
4
4
|
* Mirrors `navigation.navigate()`'s own return shape: an object holding the
|
|
5
5
|
* promises rather than a promise, so fire-and-forget callers ignore it
|
|
6
6
|
* without tripping no-floating-promises, and the 5% that await pick a stage.
|
|
7
|
-
* Slots with no navigation behind them return
|
|
8
|
-
* the type stays uniform across every kind of
|
|
7
|
+
* Slots with no navigation behind them return the same pair, settled or
|
|
8
|
+
* rejected by the store itself — the type stays uniform across every kind of
|
|
9
|
+
* state.
|
|
9
10
|
*/
|
|
10
|
-
type UpdateHandle = {
|
|
11
|
+
export type UpdateHandle = {
|
|
11
12
|
/** The write is in its home (history entry, storage, memory). */
|
|
12
13
|
committed: Promise<void>;
|
|
13
14
|
/** All downstream work (a router's intercept fetch/render) is done. */
|
|
14
15
|
finished: Promise<void>;
|
|
15
16
|
};
|
|
16
|
-
type Patch = StateValues | ((current: StateValues) => StateValues);
|
|
17
|
-
type Store = {
|
|
17
|
+
export type Patch = StateValues | ((current: StateValues) => StateValues);
|
|
18
|
+
export type Store = {
|
|
18
19
|
subscribe: (keys: readonly string[] | null, notify: () => void) => () => void;
|
|
19
20
|
getSnapshot: (sig: string, keys: readonly string[] | null) => StateValues;
|
|
20
21
|
update: (patch: Patch, options?: {
|
|
@@ -22,7 +23,7 @@ type Store = {
|
|
|
22
23
|
}) => UpdateHandle;
|
|
23
24
|
dispose: () => void;
|
|
24
25
|
};
|
|
25
|
-
type Handle = {
|
|
26
|
+
export type Handle = {
|
|
26
27
|
external: UpdateHandle;
|
|
27
28
|
settle: () => void;
|
|
28
29
|
fail: (error: unknown) => void;
|
|
@@ -31,8 +32,8 @@ type Handle = {
|
|
|
31
32
|
finished?: Promise<unknown> | undefined;
|
|
32
33
|
}) => void;
|
|
33
34
|
};
|
|
34
|
-
declare const createHandle: () => Handle;
|
|
35
|
-
type StoreCore = {
|
|
35
|
+
export declare const createHandle: () => Handle;
|
|
36
|
+
export type StoreCore = {
|
|
36
37
|
snapshot: () => StateValues;
|
|
37
38
|
applyNext: (next: StateValues) => void;
|
|
38
39
|
subscribe: Store['subscribe'];
|
|
@@ -44,11 +45,10 @@ type StoreCore = {
|
|
|
44
45
|
* exact per-key change detection, and notification only to the listeners
|
|
45
46
|
* whose subscribed keys actually changed.
|
|
46
47
|
*/
|
|
47
|
-
declare const createStoreCore: (keys: readonly string[], initial: StateValues) => StoreCore;
|
|
48
|
+
export declare const createStoreCore: (keys: readonly string[], initial: StateValues) => StoreCore;
|
|
48
49
|
/**
|
|
49
50
|
* The unknown-field guard every store shares: the typed API already prevents
|
|
50
51
|
* this, so reaching it means an untyped caller — fail loudly, not silently.
|
|
51
52
|
*/
|
|
52
|
-
declare const resolvePatch: (patch: Patch, current: StateValues, keys: readonly string[], label: string) => StateValues;
|
|
53
|
-
//#endregion
|
|
54
|
-
export { Handle, Patch, Store, StoreCore, UpdateHandle, createHandle, createStoreCore, resolvePatch };
|
|
53
|
+
export declare const resolvePatch: (patch: Patch, current: StateValues, keys: readonly string[], label: string) => StateValues;
|
|
54
|
+
//#endregion
|
|
@@ -2,8 +2,7 @@ import { StateValues } from "../schema/object.mjs";
|
|
|
2
2
|
import { MemoryState } from "../memory-state.mjs";
|
|
3
3
|
import { Store } from "./core.mjs";
|
|
4
4
|
//#region src/store/memory-store.d.ts
|
|
5
|
-
declare const memoryStoreOf: (def: MemoryState) => Store;
|
|
5
|
+
export declare const memoryStoreOf: (def: MemoryState) => Store;
|
|
6
6
|
/** SSR renders the initial values — static, so no cross-request concern. */
|
|
7
|
-
declare const memoryInitialSnapshot: (def: MemoryState) => StateValues;
|
|
8
|
-
//#endregion
|
|
9
|
-
export { memoryInitialSnapshot, memoryStoreOf };
|
|
7
|
+
export declare const memoryInitialSnapshot: (def: MemoryState) => StateValues;
|
|
8
|
+
//#endregion
|
|
@@ -2,20 +2,21 @@ import { StateValues } from "../schema/object.mjs";
|
|
|
2
2
|
import { PageState } from "../page-state.mjs";
|
|
3
3
|
import { Store } from "./core.mjs";
|
|
4
4
|
//#region src/store/page-store.d.ts
|
|
5
|
-
type UpdateOptions = {
|
|
5
|
+
export type UpdateOptions = {
|
|
6
6
|
/**
|
|
7
7
|
* `replace` by default: an update is a refinement of the current entry.
|
|
8
|
-
* Opt into `push` for the updates the back button should undo.
|
|
8
|
+
* Opt into `push` for the updates the back button should undo. It applies
|
|
9
|
+
* only when a url value changes; an entry-only write rewrites the current
|
|
10
|
+
* entry in place.
|
|
9
11
|
*/
|
|
10
12
|
history?: 'push' | 'replace';
|
|
11
13
|
};
|
|
12
|
-
declare const pageStoreOf: (def: PageState) => Store;
|
|
14
|
+
export declare const pageStoreOf: (def: PageState) => Store;
|
|
13
15
|
/**
|
|
14
16
|
* What SSR and the hydration render see: the RSC-parsed url when the caller
|
|
15
17
|
* passed one down; defaults for everything else — the entry slot never
|
|
16
18
|
* reaches the server. Pure on purpose: the server must never touch
|
|
17
19
|
* `navigation` or create a store.
|
|
18
20
|
*/
|
|
19
|
-
declare const pageInitialSnapshot: (def: PageState, initialUrl: Readonly<StateValues> | undefined) => StateValues;
|
|
20
|
-
//#endregion
|
|
21
|
-
export { UpdateOptions, pageInitialSnapshot, pageStoreOf };
|
|
21
|
+
export declare const pageInitialSnapshot: (def: PageState, initialUrl: Readonly<StateValues> | undefined) => StateValues;
|
|
22
|
+
//#endregion
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/store/registry.d.ts
|
|
2
|
-
declare const getOrCreateStore: <Store extends {
|
|
2
|
+
export declare const getOrCreateStore: <Store extends {
|
|
3
3
|
dispose?: () => void;
|
|
4
4
|
}>(scope: string, key: string, create: () => Store) => Store;
|
|
5
5
|
/**
|
|
@@ -7,6 +7,5 @@ declare const getOrCreateStore: <Store extends {
|
|
|
7
7
|
* would carry state from one test into the next. Unmount components first —
|
|
8
8
|
* mounted hooks keep their old store through closures.
|
|
9
9
|
*/
|
|
10
|
-
declare const resetStateRegistry: () => void;
|
|
11
|
-
//#endregion
|
|
12
|
-
export { getOrCreateStore, resetStateRegistry };
|
|
10
|
+
export declare const resetStateRegistry: () => void;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { StateValues } from "../schema/object.mjs";
|
|
2
|
+
import { LocalState, SessionState } from "../storage-state.mjs";
|
|
3
|
+
import { Store } from "./core.mjs";
|
|
4
|
+
//#region src/store/storage-store.d.ts
|
|
5
|
+
export declare const localStoreOf: (def: LocalState) => Store;
|
|
6
|
+
export declare const sessionStoreOf: (def: SessionState) => Store;
|
|
7
|
+
/** SSR and hydration see the defaults — the server has no Web Storage. */
|
|
8
|
+
export declare const storageInitialSnapshot: (def: LocalState | SessionState) => StateValues;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { storageCodecOf } from "../storage-state.mjs";
|
|
2
|
+
import { createHandle, createStoreCore, resolvePatch } from "./core.mjs";
|
|
3
|
+
import { getOrCreateStore } from "./registry.mjs";
|
|
4
|
+
//#region src/store/storage-store.ts
|
|
5
|
+
const createStorageStore = (def, storage) => {
|
|
6
|
+
const codec = storageCodecOf(def);
|
|
7
|
+
const { storageKey } = def;
|
|
8
|
+
const write = (values) => {
|
|
9
|
+
storage.setItem(storageKey, JSON.stringify(codec.row(values)));
|
|
10
|
+
};
|
|
11
|
+
const readRow = () => {
|
|
12
|
+
const text = storage.getItem(storageKey);
|
|
13
|
+
let row;
|
|
14
|
+
try {
|
|
15
|
+
row = text === null ? void 0 : JSON.parse(text);
|
|
16
|
+
} catch {
|
|
17
|
+
row = void 0;
|
|
18
|
+
}
|
|
19
|
+
return codec.read(row);
|
|
20
|
+
};
|
|
21
|
+
const read = () => readRow().values;
|
|
22
|
+
const upgrade = () => {
|
|
23
|
+
queueMicrotask(() => {
|
|
24
|
+
const { values, stale } = readRow();
|
|
25
|
+
if (!stale) return;
|
|
26
|
+
try {
|
|
27
|
+
write(values);
|
|
28
|
+
} catch {}
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const first = readRow();
|
|
32
|
+
const core = createStoreCore(codec.keys, first.values);
|
|
33
|
+
if (first.stale) upgrade();
|
|
34
|
+
let pending = null;
|
|
35
|
+
let handle = null;
|
|
36
|
+
const onStorage = (event) => {
|
|
37
|
+
if (event.storageArea !== storage) return;
|
|
38
|
+
if (event.key !== storageKey && event.key !== null) return;
|
|
39
|
+
const { values: fresh, stale } = readRow();
|
|
40
|
+
if (stale) upgrade();
|
|
41
|
+
core.applyNext(pending === null ? fresh : {
|
|
42
|
+
...fresh,
|
|
43
|
+
...pending
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
window.addEventListener("storage", onStorage);
|
|
47
|
+
const flush = () => {
|
|
48
|
+
const patch = pending;
|
|
49
|
+
const current = handle;
|
|
50
|
+
pending = null;
|
|
51
|
+
handle = null;
|
|
52
|
+
try {
|
|
53
|
+
const target = codec.salvage({
|
|
54
|
+
...read(),
|
|
55
|
+
...patch
|
|
56
|
+
});
|
|
57
|
+
const values = {};
|
|
58
|
+
for (const key of codec.keys) values[key] = target[key];
|
|
59
|
+
write(values);
|
|
60
|
+
core.applyNext(target);
|
|
61
|
+
current.settle();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
current.fail(error);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const update = (patch) => {
|
|
67
|
+
const resolved = resolvePatch(patch, core.snapshot(), codec.keys, def.key);
|
|
68
|
+
if (pending === null) {
|
|
69
|
+
pending = {};
|
|
70
|
+
handle = createHandle();
|
|
71
|
+
queueMicrotask(flush);
|
|
72
|
+
}
|
|
73
|
+
Object.assign(pending, resolved);
|
|
74
|
+
const echoed = codec.salvage({
|
|
75
|
+
...core.snapshot(),
|
|
76
|
+
...pending
|
|
77
|
+
});
|
|
78
|
+
for (const key of Object.keys(pending)) pending[key] = echoed[key];
|
|
79
|
+
core.applyNext(echoed);
|
|
80
|
+
return handle.external;
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
subscribe: core.subscribe,
|
|
84
|
+
getSnapshot: core.getSnapshot,
|
|
85
|
+
update,
|
|
86
|
+
dispose: () => {
|
|
87
|
+
window.removeEventListener("storage", onStorage);
|
|
88
|
+
core.clear();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const localStoreOf = (def) => getOrCreateStore("local", def.key, () => createStorageStore(def, localStorage));
|
|
93
|
+
const sessionStoreOf = (def) => getOrCreateStore("session", def.key, () => createStorageStore(def, sessionStorage));
|
|
94
|
+
/** SSR and hydration see the defaults — the server has no Web Storage. */
|
|
95
|
+
const storageInitialSnapshot = (def) => ({ ...storageCodecOf(def).defaults });
|
|
96
|
+
//#endregion
|
|
97
|
+
export { localStoreOf, sessionStoreOf, storageInitialSnapshot };
|
package/dist/url/codec.d.mts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { StateSchema, StateValues } from "../schema/object.mjs";
|
|
2
2
|
//#region src/url/codec.d.ts
|
|
3
3
|
/** `URLSearchParams`, or the object shape frameworks hand to a page. */
|
|
4
|
-
type UrlInput = URLSearchParams | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
5
|
-
type UrlCodec = {
|
|
4
|
+
export type UrlInput = URLSearchParams | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
5
|
+
export type UrlCodec = {
|
|
6
6
|
keys: readonly string[];
|
|
7
7
|
defaults: Readonly<StateValues>;
|
|
8
8
|
parse: (input: UrlInput) => StateValues;
|
|
@@ -10,6 +10,5 @@ type UrlCodec = {
|
|
|
10
10
|
/** Runs already-typed values through the schema, salvage included. */
|
|
11
11
|
salvage: (values: Readonly<StateValues>) => StateValues;
|
|
12
12
|
};
|
|
13
|
-
declare const createUrlCodec: (schema: StateSchema) => UrlCodec;
|
|
14
|
-
//#endregion
|
|
15
|
-
export { UrlCodec, UrlInput, createUrlCodec };
|
|
13
|
+
export declare const createUrlCodec: (schema: StateSchema) => UrlCodec;
|
|
14
|
+
//#endregion
|
package/dist/url/codec.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { analyzeSchema, parseWithSalvage, sameValue } from "../schema/object.mjs";
|
|
2
|
+
import { safeEncode } from "zod/v4/core";
|
|
2
3
|
//#region src/url/codec.ts
|
|
3
4
|
const defOf = (schema) => schema._zod.def;
|
|
4
5
|
/**
|
|
@@ -17,28 +18,45 @@ const readAll = (input, key) => {
|
|
|
17
18
|
if (value === void 0) return [];
|
|
18
19
|
return typeof value === "string" ? [value] : value;
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* 真偽値は、フィールド(配列なら要素)のスキーマ自身が encode した綴りで書く。`String(true)` の
|
|
23
|
+
* `"true"` は `z.stringbool({ truthy: ['yes'] })` が読み返せず、既定値に落ちる。
|
|
24
|
+
*/
|
|
25
|
+
const spellBoolean = (schema, value) => {
|
|
26
|
+
try {
|
|
27
|
+
const encoded = safeEncode(schema, value);
|
|
28
|
+
if (encoded.success && typeof encoded.data === "string") return encoded.data;
|
|
29
|
+
} catch {}
|
|
30
|
+
return String(value);
|
|
31
|
+
};
|
|
32
|
+
const serializeValue = (schema, key, value) => {
|
|
21
33
|
if (typeof value === "string") return value;
|
|
22
|
-
if (typeof value === "
|
|
23
|
-
|
|
34
|
+
if (typeof value === "boolean") return spellBoolean(schema, value);
|
|
35
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
36
|
+
throw new TypeError(`url field "${key}" holds a value of type ${typeof value}, which has no URL serialization`);
|
|
37
|
+
};
|
|
38
|
+
const refuseBoolean = (schema, name) => {
|
|
39
|
+
if (baseDef(schema).type === "boolean") throw new TypeError(`url boolean fields must use z.stringbool() — a URL carries strings, and "false" is not false to z.boolean() or z.coerce.boolean(): ${name}`);
|
|
24
40
|
};
|
|
25
41
|
const createUrlCodec = (schema) => {
|
|
26
42
|
const info = analyzeSchema(schema, "url");
|
|
27
43
|
const { keys, defaults } = info;
|
|
28
|
-
const
|
|
44
|
+
const items = /* @__PURE__ */ new Map();
|
|
29
45
|
for (const key of keys) {
|
|
30
|
-
const base = baseDef(info.shape[key])
|
|
31
|
-
if (base === "array") {
|
|
46
|
+
const base = baseDef(info.shape[key]);
|
|
47
|
+
if (base.type === "array") {
|
|
32
48
|
if (!sameValue(defaults[key], [])) throw new TypeError(`url array fields must default to [] — an absent param and an empty list are the same URL: ${key}`);
|
|
33
|
-
|
|
34
|
-
|
|
49
|
+
const item = base.element;
|
|
50
|
+
refuseBoolean(item, `${key}[]`);
|
|
51
|
+
items.set(key, item);
|
|
52
|
+
} else refuseBoolean(info.shape[key], key);
|
|
35
53
|
}
|
|
36
54
|
const parse = (input) => {
|
|
37
55
|
const raw = {};
|
|
38
56
|
for (const key of keys) {
|
|
39
57
|
const got = readAll(input, key);
|
|
40
58
|
if (got.length === 0) continue;
|
|
41
|
-
raw[key] =
|
|
59
|
+
raw[key] = items.has(key) ? got : got[0];
|
|
42
60
|
}
|
|
43
61
|
return parseWithSalvage(info, raw);
|
|
44
62
|
};
|
|
@@ -47,8 +65,11 @@ const createUrlCodec = (schema) => {
|
|
|
47
65
|
for (const key of keys) {
|
|
48
66
|
const value = key in values ? values[key] : defaults[key];
|
|
49
67
|
if (value === void 0 || sameValue(defaults[key], value)) continue;
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
const field = info.shape[key];
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
const item = items.get(key) ?? field;
|
|
71
|
+
for (const each of value) params.append(key, serializeValue(item, key, each));
|
|
72
|
+
} else params.append(key, serializeValue(field, key, value));
|
|
52
73
|
}
|
|
53
74
|
return params.toString();
|
|
54
75
|
};
|
package/dist/use-app-state.d.mts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { StateSchema } from "./schema/object.mjs";
|
|
2
|
+
import { CookieState } from "./cookie-state.mjs";
|
|
2
3
|
import { OutputOf, PageState } from "./page-state.mjs";
|
|
3
|
-
import { LocalState } from "./
|
|
4
|
+
import { LocalState, SessionState } from "./storage-state.mjs";
|
|
4
5
|
import { MemoryState } from "./memory-state.mjs";
|
|
5
6
|
import { UpdateHandle } from "./store/core.mjs";
|
|
6
7
|
import { UpdateOptions } from "./store/page-store.mjs";
|
|
7
8
|
import { output } from "zod/v4/core";
|
|
8
9
|
//#region src/use-app-state.d.ts
|
|
9
|
-
type AnyState = PageState | LocalState | MemoryState;
|
|
10
|
-
type StateOf<Def> = Def extends PageState<infer Url, infer Entry> ? OutputOf<Url> & OutputOf<Entry> : Def extends LocalState<infer Schema> ? output<Schema> : Def extends MemoryState<infer Values> ? Values : never;
|
|
10
|
+
export type AnyState = PageState | LocalState | SessionState | CookieState | MemoryState;
|
|
11
|
+
type StateOf<Def> = Def extends PageState<infer Url, infer Entry> ? OutputOf<Url> & OutputOf<Entry> : Def extends LocalState<infer Schema> ? output<Schema> : Def extends SessionState<infer Schema> ? output<Schema> : Def extends CookieState<infer Schema> ? output<Schema> : Def extends MemoryState<infer Values> ? Values : never;
|
|
11
12
|
type UrlStateOf<Def> = Def extends PageState<infer Url> ? OutputOf<Url> : never;
|
|
12
13
|
type UpdateFn<Def> = (patch: Readonly<Partial<StateOf<Def>>> | ((current: StateOf<Def>) => Partial<StateOf<Def>>), ...rest: Def extends PageState ? [options?: UpdateOptions] : []) => UpdateHandle;
|
|
13
14
|
type OptionsFor<Def> = Def extends {
|
|
@@ -21,8 +22,16 @@ type OptionsFor<Def> = Def extends {
|
|
|
21
22
|
* server-side source, so it always starts from defaults there.
|
|
22
23
|
*/
|
|
23
24
|
initialUrl?: Readonly<UrlStateOf<Def>>;
|
|
25
|
+
} : Def extends CookieState<infer Schema> ? {
|
|
26
|
+
/**
|
|
27
|
+
* What `parseCookies(request.cookies)` returned on the server, passed
|
|
28
|
+
* down as a prop. It seeds SSR and the hydration render; without it
|
|
29
|
+
* those two renders see the defaults, which flashes once the cookie
|
|
30
|
+
* is read in the browser.
|
|
31
|
+
*/
|
|
32
|
+
initialCookie?: Readonly<output<Schema>>;
|
|
24
33
|
} : never;
|
|
25
|
-
declare function useAppState<Def extends AnyState>(def: Def, options?: OptionsFor<Def>): [StateOf<Def>, UpdateFn<Def>];
|
|
26
|
-
declare function useAppState<Def extends AnyState, const Key extends Extract<keyof StateOf<Def>, string>>(def: Def, keys: readonly Key[], options?: OptionsFor<Def>): [Pick<StateOf<Def>, Key>, UpdateFn<Def>];
|
|
34
|
+
export declare function useAppState<Def extends AnyState>(def: Def, options?: OptionsFor<Def>): [StateOf<Def>, UpdateFn<Def>];
|
|
35
|
+
export declare function useAppState<Def extends AnyState, const Key extends Extract<keyof StateOf<Def>, string>>(def: Def, keys: readonly Key[], options?: OptionsFor<Def>): [Pick<StateOf<Def>, Key>, UpdateFn<Def>];
|
|
27
36
|
//#endregion
|
|
28
|
-
export {
|
|
37
|
+
export {};
|
package/dist/use-app-state.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import { cookieInitialSnapshot, cookieStoreOf } from "./store/cookie-store.mjs";
|
|
3
3
|
import { memoryInitialSnapshot, memoryStoreOf } from "./store/memory-store.mjs";
|
|
4
4
|
import { pageInitialSnapshot, pageStoreOf } from "./store/page-store.mjs";
|
|
5
|
+
import { localStoreOf, sessionStoreOf, storageInitialSnapshot } from "./store/storage-store.mjs";
|
|
5
6
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
6
7
|
//#region src/use-app-state.ts
|
|
7
|
-
const storeOf = (def) => def.kind === "page" ? pageStoreOf(def) : def.kind === "local" ? localStoreOf(def) : memoryStoreOf(def);
|
|
8
|
-
const initialOf = (def,
|
|
8
|
+
const storeOf = (def) => def.kind === "page" ? pageStoreOf(def) : def.kind === "local" ? localStoreOf(def) : def.kind === "session" ? sessionStoreOf(def) : def.kind === "cookie" ? cookieStoreOf(def) : memoryStoreOf(def);
|
|
9
|
+
const initialOf = (def, options) => def.kind === "page" ? pageInitialSnapshot(def, options?.initialUrl) : def.kind === "local" || def.kind === "session" ? storageInitialSnapshot(def) : def.kind === "cookie" ? cookieInitialSnapshot(def, options?.initialCookie) : memoryInitialSnapshot(def);
|
|
9
10
|
const FULL = "*";
|
|
10
11
|
function useAppState(def, keysOrOptions, maybeOptions) {
|
|
11
12
|
const gotKeys = Array.isArray(keysOrOptions);
|
|
@@ -19,25 +20,23 @@ function useAppState(def, keysOrOptions, maybeOptions) {
|
|
|
19
20
|
keys
|
|
20
21
|
]);
|
|
21
22
|
const initialUrl = options?.initialUrl;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return cached;
|
|
35
|
-
};
|
|
23
|
+
const initialCookie = options?.initialCookie;
|
|
24
|
+
const serverSnapshot = useMemo(() => {
|
|
25
|
+
const base = initialOf(def, {
|
|
26
|
+
initialUrl,
|
|
27
|
+
initialCookie
|
|
28
|
+
});
|
|
29
|
+
if (keys === null) return base;
|
|
30
|
+
const pick = {};
|
|
31
|
+
for (const key of keys) pick[key] = base[key];
|
|
32
|
+
return pick;
|
|
36
33
|
}, [
|
|
37
34
|
def,
|
|
38
35
|
initialUrl,
|
|
36
|
+
initialCookie,
|
|
39
37
|
keys
|
|
40
38
|
]);
|
|
39
|
+
const getServerSnapshot = useCallback(() => serverSnapshot, [serverSnapshot]);
|
|
41
40
|
return [useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), useCallback((patch, updateOptions) => storeOf(def).update(patch, updateOptions), [def])];
|
|
42
41
|
}
|
|
43
42
|
//#endregion
|