@k8ordo/state 0.1.0 → 1.0.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 +168 -0
- 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 -12
- package/dist/page-state.mjs +17 -3
- package/dist/register.d.mts +31 -12
- 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 +37 -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/page-store.mjs +29 -32
- 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 +45 -12
- package/dist/use-app-state.d.mts +21 -9
- package/dist/use-app-state.mjs +16 -17
- package/docs/GUIDE.md +515 -91
- package/docs/llms.txt +74 -23
- package/package.json +29 -16
- package/dist/local-state.d.mts +0 -19
- package/dist/local-state.mjs +0 -19
- package/dist/store/local-store.d.mts +0 -9
- package/dist/store/local-store.mjs +0 -80
package/dist/schema/object.mjs
CHANGED
|
@@ -40,6 +40,11 @@ const analyzeSchema = (schema, slot) => {
|
|
|
40
40
|
defaults
|
|
41
41
|
};
|
|
42
42
|
};
|
|
43
|
+
const pickKeys = (info, data) => {
|
|
44
|
+
const values = {};
|
|
45
|
+
for (const key of info.keys) values[key] = data[key];
|
|
46
|
+
return values;
|
|
47
|
+
};
|
|
43
48
|
/**
|
|
44
49
|
* Whole-parse first; when that fails, the values are user-editable or stale
|
|
45
50
|
* input, so one broken field falls back to its own default instead of taking
|
|
@@ -47,17 +52,11 @@ const analyzeSchema = (schema, slot) => {
|
|
|
47
52
|
*/
|
|
48
53
|
const parseWithSalvage = (info, raw) => {
|
|
49
54
|
const whole = safeParse(info.schema, raw);
|
|
50
|
-
if (whole.success)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const salvaged = {};
|
|
56
|
-
for (const key of info.keys) if (key in raw) {
|
|
57
|
-
const field = safeParse(info.shape[key], raw[key]);
|
|
58
|
-
salvaged[key] = field.success ? field.data : info.defaults[key];
|
|
59
|
-
} else salvaged[key] = info.defaults[key];
|
|
60
|
-
return safeParse(info.schema, salvaged).success ? salvaged : { ...info.defaults };
|
|
55
|
+
if (whole.success) return pickKeys(info, whole.data);
|
|
56
|
+
const kept = {};
|
|
57
|
+
for (const key of info.keys) if (key in raw && safeParse(info.shape[key], raw[key]).success) kept[key] = raw[key];
|
|
58
|
+
const salvaged = safeParse(info.schema, kept);
|
|
59
|
+
return salvaged.success ? pickKeys(info, salvaged.data) : { ...info.defaults };
|
|
61
60
|
};
|
|
62
61
|
//#endregion
|
|
63
62
|
export { analyzeSchema, isRecord, parseWithSalvage, sameValue };
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
|
@@ -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
|
|
@@ -51,37 +51,33 @@ const createPageStore = (def) => {
|
|
|
51
51
|
pending = null;
|
|
52
52
|
pendingPush = false;
|
|
53
53
|
handle = null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
54
|
+
try {
|
|
55
|
+
const url = new URL(location.href);
|
|
56
|
+
const stored = storedEntryState();
|
|
57
|
+
const liveUrl = page.url === null ? {} : page.url.parse(url.searchParams);
|
|
58
|
+
const liveEntry = page.entry === null ? {} : page.entry.parse(isRecord(stored) ? stored[def.key] : void 0);
|
|
59
|
+
const target = canonical({
|
|
60
|
+
...liveUrl,
|
|
61
|
+
...liveEntry,
|
|
62
|
+
...patch
|
|
63
|
+
});
|
|
64
|
+
const urlChanged = page.url?.keys.some((key) => !sameValue(liveUrl[key], target[key])) ?? false;
|
|
65
|
+
const entryChanged = page.entry?.keys.some((key) => !sameValue(liveEntry[key], target[key])) ?? false;
|
|
66
|
+
if (!urlChanged && !entryChanged) {
|
|
67
|
+
core.applyNext(target);
|
|
68
|
+
current.settle();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const state = carryState(stored, target);
|
|
72
|
+
if (!urlChanged) {
|
|
73
73
|
navigation.updateCurrentEntry({ state });
|
|
74
74
|
current.settle();
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (page.url !== null) {
|
|
78
|
+
for (const key of page.url.keys) url.searchParams.delete(key);
|
|
79
|
+
for (const [key, value] of new URLSearchParams(page.url.search(target))) url.searchParams.append(key, value);
|
|
77
80
|
}
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (page.url !== null) {
|
|
81
|
-
for (const key of page.url.keys) url.searchParams.delete(key);
|
|
82
|
-
for (const [key, value] of new URLSearchParams(page.url.search(target))) url.searchParams.append(key, value);
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
81
|
current.adopt(navigation.navigate(url.href, {
|
|
86
82
|
history: push ? "push" : "replace",
|
|
87
83
|
state
|
|
@@ -92,6 +88,11 @@ const createPageStore = (def) => {
|
|
|
92
88
|
};
|
|
93
89
|
const update = (patch, options) => {
|
|
94
90
|
const resolved = resolvePatch(patch, core.snapshot(), page.keys, def.key);
|
|
91
|
+
const echoed = canonical({
|
|
92
|
+
...core.snapshot(),
|
|
93
|
+
...pending,
|
|
94
|
+
...resolved
|
|
95
|
+
});
|
|
95
96
|
if (pending === null) {
|
|
96
97
|
pending = {};
|
|
97
98
|
handle = createHandle();
|
|
@@ -99,10 +100,6 @@ const createPageStore = (def) => {
|
|
|
99
100
|
}
|
|
100
101
|
Object.assign(pending, resolved);
|
|
101
102
|
if (options?.history === "push") pendingPush = true;
|
|
102
|
-
const echoed = canonical({
|
|
103
|
-
...core.snapshot(),
|
|
104
|
-
...pending
|
|
105
|
-
});
|
|
106
103
|
for (const key of Object.keys(pending)) pending[key] = echoed[key];
|
|
107
104
|
core.applyNext(echoed);
|
|
108
105
|
return handle.external;
|
|
@@ -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
|