@k8ordo/state 0.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/LICENSE +21 -0
- package/dist/entry/codec.d.mts +18 -0
- package/dist/entry/codec.mjs +31 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +6 -0
- package/dist/local-state.d.mts +19 -0
- package/dist/local-state.mjs +19 -0
- package/dist/memory-state.d.mts +17 -0
- package/dist/memory-state.mjs +8 -0
- package/dist/page-state.d.mts +53 -0
- package/dist/page-state.mjs +37 -0
- package/dist/register.d.mts +22 -0
- package/dist/register.mjs +1 -0
- package/dist/schema/object.d.mts +35 -0
- package/dist/schema/object.mjs +63 -0
- package/dist/store/core.d.mts +54 -0
- package/dist/store/core.mjs +106 -0
- package/dist/store/local-store.d.mts +9 -0
- package/dist/store/local-store.mjs +80 -0
- package/dist/store/memory-store.d.mts +9 -0
- package/dist/store/memory-store.mjs +28 -0
- package/dist/store/page-store.d.mts +21 -0
- package/dist/store/page-store.mjs +137 -0
- package/dist/store/registry.d.mts +12 -0
- package/dist/store/registry.mjs +0 -0
- package/dist/url/codec.d.mts +15 -0
- package/dist/url/codec.mjs +62 -0
- package/dist/use-app-state.d.mts +24 -0
- package/dist/use-app-state.mjs +44 -0
- package/docs/GUIDE.md +308 -0
- package/docs/llms.txt +42 -0
- package/package.json +76 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StateValues } from "../schema/object.mjs";
|
|
2
|
+
import { PageState } from "../page-state.mjs";
|
|
3
|
+
import { Store } from "./core.mjs";
|
|
4
|
+
//#region src/store/page-store.d.ts
|
|
5
|
+
type UpdateOptions = {
|
|
6
|
+
/**
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
history?: 'push' | 'replace';
|
|
11
|
+
};
|
|
12
|
+
declare const pageStoreOf: (def: PageState) => Store;
|
|
13
|
+
/**
|
|
14
|
+
* What SSR and the hydration render see: the RSC-parsed url when the caller
|
|
15
|
+
* passed one down; defaults for everything else — the entry slot never
|
|
16
|
+
* reaches the server. Pure on purpose: the server must never touch
|
|
17
|
+
* `navigation` or create a store.
|
|
18
|
+
*/
|
|
19
|
+
declare const pageInitialSnapshot: (def: PageState, initialUrl: Readonly<StateValues> | undefined) => StateValues;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { UpdateOptions, pageInitialSnapshot, pageStoreOf };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { isRecord, sameValue } from "../schema/object.mjs";
|
|
2
|
+
import { internalsOf } from "../page-state.mjs";
|
|
3
|
+
import { createHandle, createStoreCore, resolvePatch } from "./core.mjs";
|
|
4
|
+
import { getOrCreateStore } from "./registry.mjs";
|
|
5
|
+
//#region src/store/page-store.ts
|
|
6
|
+
const storedEntryState = () => navigation.currentEntry?.getState();
|
|
7
|
+
const readLive = (def, page) => {
|
|
8
|
+
const live = {};
|
|
9
|
+
if (page.url !== null) Object.assign(live, page.url.parse(new URL(location.href).searchParams));
|
|
10
|
+
if (page.entry !== null) {
|
|
11
|
+
const stored = storedEntryState();
|
|
12
|
+
Object.assign(live, page.entry.parse(isRecord(stored) ? stored[def.key] : void 0));
|
|
13
|
+
}
|
|
14
|
+
return live;
|
|
15
|
+
};
|
|
16
|
+
const createPageStore = (def) => {
|
|
17
|
+
const page = internalsOf(def);
|
|
18
|
+
const core = createStoreCore(page.keys, readLive(def, page));
|
|
19
|
+
let pending = null;
|
|
20
|
+
let pendingPush = false;
|
|
21
|
+
let handle = null;
|
|
22
|
+
const sync = () => {
|
|
23
|
+
const live = readLive(def, page);
|
|
24
|
+
core.applyNext(pending === null ? live : {
|
|
25
|
+
...live,
|
|
26
|
+
...pending
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
navigation.addEventListener("currententrychange", sync);
|
|
30
|
+
/**
|
|
31
|
+
* The whole navigation state travels with every write we make: the entry
|
|
32
|
+
* state object is shared ground (the router's own state, other page
|
|
33
|
+
* states), and a navigate that dropped it would wipe the neighbours.
|
|
34
|
+
*/
|
|
35
|
+
const canonical = (values) => ({
|
|
36
|
+
...page.url === null ? {} : page.url.salvage(values),
|
|
37
|
+
...page.entry === null ? {} : page.entry.salvage(values)
|
|
38
|
+
});
|
|
39
|
+
const carryState = (stored, target) => {
|
|
40
|
+
if (page.entry === null) return stored;
|
|
41
|
+
const base = isRecord(stored) ? { ...stored } : {};
|
|
42
|
+
const own = {};
|
|
43
|
+
for (const key of page.entry.keys) own[key] = target[key];
|
|
44
|
+
base[def.key] = own;
|
|
45
|
+
return base;
|
|
46
|
+
};
|
|
47
|
+
const flush = () => {
|
|
48
|
+
const patch = pending;
|
|
49
|
+
const push = pendingPush;
|
|
50
|
+
const current = handle;
|
|
51
|
+
pending = null;
|
|
52
|
+
pendingPush = false;
|
|
53
|
+
handle = null;
|
|
54
|
+
const url = new URL(location.href);
|
|
55
|
+
const stored = storedEntryState();
|
|
56
|
+
const liveUrl = page.url === null ? {} : page.url.parse(url.searchParams);
|
|
57
|
+
const liveEntry = page.entry === null ? {} : page.entry.parse(isRecord(stored) ? stored[def.key] : void 0);
|
|
58
|
+
const target = canonical({
|
|
59
|
+
...liveUrl,
|
|
60
|
+
...liveEntry,
|
|
61
|
+
...patch
|
|
62
|
+
});
|
|
63
|
+
const urlChanged = page.url?.keys.some((key) => !sameValue(liveUrl[key], target[key])) ?? false;
|
|
64
|
+
const entryChanged = page.entry?.keys.some((key) => !sameValue(liveEntry[key], target[key])) ?? false;
|
|
65
|
+
if (!urlChanged && !entryChanged) {
|
|
66
|
+
core.applyNext(target);
|
|
67
|
+
current.settle();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const state = carryState(stored, target);
|
|
71
|
+
if (!urlChanged) {
|
|
72
|
+
try {
|
|
73
|
+
navigation.updateCurrentEntry({ state });
|
|
74
|
+
current.settle();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
current.fail(error);
|
|
77
|
+
}
|
|
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
|
+
current.adopt(navigation.navigate(url.href, {
|
|
86
|
+
history: push ? "push" : "replace",
|
|
87
|
+
state
|
|
88
|
+
}));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
current.fail(error);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const update = (patch, options) => {
|
|
94
|
+
const resolved = resolvePatch(patch, core.snapshot(), page.keys, def.key);
|
|
95
|
+
if (pending === null) {
|
|
96
|
+
pending = {};
|
|
97
|
+
handle = createHandle();
|
|
98
|
+
queueMicrotask(flush);
|
|
99
|
+
}
|
|
100
|
+
Object.assign(pending, resolved);
|
|
101
|
+
if (options?.history === "push") pendingPush = true;
|
|
102
|
+
const echoed = canonical({
|
|
103
|
+
...core.snapshot(),
|
|
104
|
+
...pending
|
|
105
|
+
});
|
|
106
|
+
for (const key of Object.keys(pending)) pending[key] = echoed[key];
|
|
107
|
+
core.applyNext(echoed);
|
|
108
|
+
return handle.external;
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
subscribe: core.subscribe,
|
|
112
|
+
getSnapshot: core.getSnapshot,
|
|
113
|
+
update,
|
|
114
|
+
dispose: () => {
|
|
115
|
+
navigation.removeEventListener("currententrychange", sync);
|
|
116
|
+
core.clear();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
const pageStoreOf = (def) => getOrCreateStore("page", def.key, () => createPageStore(def));
|
|
121
|
+
/**
|
|
122
|
+
* What SSR and the hydration render see: the RSC-parsed url when the caller
|
|
123
|
+
* passed one down; defaults for everything else — the entry slot never
|
|
124
|
+
* reaches the server. Pure on purpose: the server must never touch
|
|
125
|
+
* `navigation` or create a store.
|
|
126
|
+
*/
|
|
127
|
+
const pageInitialSnapshot = (def, initialUrl) => {
|
|
128
|
+
const page = internalsOf(def);
|
|
129
|
+
const base = {};
|
|
130
|
+
for (const codec of [page.url, page.entry]) if (codec !== null) for (const key of codec.keys) base[key] = codec.defaults[key];
|
|
131
|
+
if (page.url !== null && initialUrl !== void 0) {
|
|
132
|
+
for (const key of page.url.keys) if (key in initialUrl) base[key] = initialUrl[key];
|
|
133
|
+
}
|
|
134
|
+
return base;
|
|
135
|
+
};
|
|
136
|
+
//#endregion
|
|
137
|
+
export { pageInitialSnapshot, pageStoreOf };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/store/registry.d.ts
|
|
2
|
+
declare const getOrCreateStore: <Store extends {
|
|
3
|
+
dispose?: () => void;
|
|
4
|
+
}>(scope: string, key: string, create: () => Store) => Store;
|
|
5
|
+
/**
|
|
6
|
+
* Test-only escape hatch for the provider-less design: without it, stores
|
|
7
|
+
* would carry state from one test into the next. Unmount components first —
|
|
8
|
+
* mounted hooks keep their old store through closures.
|
|
9
|
+
*/
|
|
10
|
+
declare const resetStateRegistry: () => void;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { getOrCreateStore, resetStateRegistry };
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { StateSchema, StateValues } from "../schema/object.mjs";
|
|
2
|
+
//#region src/url/codec.d.ts
|
|
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 = {
|
|
6
|
+
keys: readonly string[];
|
|
7
|
+
defaults: Readonly<StateValues>;
|
|
8
|
+
parse: (input: UrlInput) => StateValues;
|
|
9
|
+
search: (values: Readonly<Partial<StateValues>>) => string;
|
|
10
|
+
/** Runs already-typed values through the schema, salvage included. */
|
|
11
|
+
salvage: (values: Readonly<StateValues>) => StateValues;
|
|
12
|
+
};
|
|
13
|
+
declare const createUrlCodec: (schema: StateSchema) => UrlCodec;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { UrlCodec, UrlInput, createUrlCodec };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { analyzeSchema, parseWithSalvage, sameValue } from "../schema/object.mjs";
|
|
2
|
+
//#region src/url/codec.ts
|
|
3
|
+
const defOf = (schema) => schema._zod.def;
|
|
4
|
+
/**
|
|
5
|
+
* Peels wrappers (`default`, `optional`, `catch`, …) and pipes down to the
|
|
6
|
+
* type that decides how many values a param carries. Only the input side of a
|
|
7
|
+
* pipe matters here — that is the side the URL string enters through.
|
|
8
|
+
*/
|
|
9
|
+
const baseDef = (schema) => {
|
|
10
|
+
let def = defOf(schema);
|
|
11
|
+
while (def.innerType !== void 0 || def.type === "pipe" && def.in !== void 0) def = defOf(def.innerType ?? def.in);
|
|
12
|
+
return def;
|
|
13
|
+
};
|
|
14
|
+
const readAll = (input, key) => {
|
|
15
|
+
if (input instanceof URLSearchParams) return input.getAll(key);
|
|
16
|
+
const value = input[key];
|
|
17
|
+
if (value === void 0) return [];
|
|
18
|
+
return typeof value === "string" ? [value] : value;
|
|
19
|
+
};
|
|
20
|
+
const serializeValue = (key, value) => {
|
|
21
|
+
if (typeof value === "string") return value;
|
|
22
|
+
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value);
|
|
23
|
+
throw new TypeError(`url field "${key}" holds a ${typeof value}, which has no URL serialization`);
|
|
24
|
+
};
|
|
25
|
+
const createUrlCodec = (schema) => {
|
|
26
|
+
const info = analyzeSchema(schema, "url");
|
|
27
|
+
const { keys, defaults } = info;
|
|
28
|
+
const multi = new Set(keys.filter((key) => baseDef(info.shape[key]).type === "array"));
|
|
29
|
+
const parse = (input) => {
|
|
30
|
+
const raw = {};
|
|
31
|
+
for (const key of keys) {
|
|
32
|
+
const got = readAll(input, key);
|
|
33
|
+
if (got.length === 0) continue;
|
|
34
|
+
raw[key] = multi.has(key) ? got : got[0];
|
|
35
|
+
}
|
|
36
|
+
return parseWithSalvage(info, raw);
|
|
37
|
+
};
|
|
38
|
+
const search = (values) => {
|
|
39
|
+
const params = new URLSearchParams();
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
const value = key in values ? values[key] : defaults[key];
|
|
42
|
+
if (value === void 0 || sameValue(defaults[key], value)) continue;
|
|
43
|
+
if (Array.isArray(value)) for (const item of value) params.append(key, serializeValue(key, item));
|
|
44
|
+
else params.append(key, serializeValue(key, value));
|
|
45
|
+
}
|
|
46
|
+
return params.toString();
|
|
47
|
+
};
|
|
48
|
+
const salvage = (values) => {
|
|
49
|
+
const own = {};
|
|
50
|
+
for (const key of keys) if (key in values) own[key] = values[key];
|
|
51
|
+
return parseWithSalvage(info, own);
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
keys,
|
|
55
|
+
defaults,
|
|
56
|
+
parse,
|
|
57
|
+
search,
|
|
58
|
+
salvage
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { createUrlCodec };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { OutputOf, PageState } from "./page-state.mjs";
|
|
2
|
+
import { LocalState } from "./local-state.mjs";
|
|
3
|
+
import { MemoryState } from "./memory-state.mjs";
|
|
4
|
+
import { UpdateHandle } from "./store/core.mjs";
|
|
5
|
+
import { UpdateOptions } from "./store/page-store.mjs";
|
|
6
|
+
import { output } from "zod/v4/core";
|
|
7
|
+
//#region src/use-app-state.d.ts
|
|
8
|
+
type AnyState = PageState | LocalState | MemoryState;
|
|
9
|
+
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
|
+
type UrlStateOf<Def> = Def extends PageState<infer Url> ? OutputOf<Url> : never;
|
|
11
|
+
type UpdateFn<Def> = (patch: Readonly<Partial<StateOf<Def>>> | ((current: StateOf<Def>) => Partial<StateOf<Def>>), ...rest: Def extends PageState ? [options?: UpdateOptions] : []) => UpdateHandle;
|
|
12
|
+
type OptionsFor<Def> = Def extends PageState ? {
|
|
13
|
+
/**
|
|
14
|
+
* The RSC-parsed url state, passed down as a prop. It seeds SSR and
|
|
15
|
+
* the hydration render; without it those two renders see the defaults,
|
|
16
|
+
* which flashes once the live URL takes over. The entry slot has no
|
|
17
|
+
* server-side source, so it always starts from defaults there.
|
|
18
|
+
*/
|
|
19
|
+
initialUrl?: Readonly<UrlStateOf<Def>>;
|
|
20
|
+
} : Record<never, never>;
|
|
21
|
+
declare function useAppState<Def extends AnyState>(def: Def, options?: OptionsFor<Def>): [StateOf<Def>, UpdateFn<Def>];
|
|
22
|
+
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>];
|
|
23
|
+
//#endregion
|
|
24
|
+
export { AnyState, useAppState };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { localInitialSnapshot, localStoreOf } from "./store/local-store.mjs";
|
|
3
|
+
import { memoryInitialSnapshot, memoryStoreOf } from "./store/memory-store.mjs";
|
|
4
|
+
import { pageInitialSnapshot, pageStoreOf } from "./store/page-store.mjs";
|
|
5
|
+
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
6
|
+
//#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, initialUrl) => def.kind === "page" ? pageInitialSnapshot(def, initialUrl) : def.kind === "local" ? localInitialSnapshot(def) : memoryInitialSnapshot(def);
|
|
9
|
+
const FULL = "*";
|
|
10
|
+
function useAppState(def, keysOrOptions, maybeOptions) {
|
|
11
|
+
const gotKeys = Array.isArray(keysOrOptions);
|
|
12
|
+
const options = gotKeys ? maybeOptions : keysOrOptions;
|
|
13
|
+
const sig = gotKeys ? JSON.stringify(keysOrOptions.toSorted()) : FULL;
|
|
14
|
+
const keys = useMemo(() => sig === FULL ? null : JSON.parse(sig), [sig]);
|
|
15
|
+
const subscribe = useCallback((notify) => storeOf(def).subscribe(keys, notify), [def, keys]);
|
|
16
|
+
const getSnapshot = useCallback(() => storeOf(def).getSnapshot(sig, keys), [
|
|
17
|
+
def,
|
|
18
|
+
sig,
|
|
19
|
+
keys
|
|
20
|
+
]);
|
|
21
|
+
const initialUrl = options?.initialUrl;
|
|
22
|
+
const getServerSnapshot = useMemo(() => {
|
|
23
|
+
let cached;
|
|
24
|
+
return () => {
|
|
25
|
+
if (cached === void 0) {
|
|
26
|
+
const base = initialOf(def, initialUrl);
|
|
27
|
+
if (keys === null) cached = base;
|
|
28
|
+
else {
|
|
29
|
+
const pick = {};
|
|
30
|
+
for (const key of keys) pick[key] = base[key];
|
|
31
|
+
cached = pick;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return cached;
|
|
35
|
+
};
|
|
36
|
+
}, [
|
|
37
|
+
def,
|
|
38
|
+
initialUrl,
|
|
39
|
+
keys
|
|
40
|
+
]);
|
|
41
|
+
return [useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), useCallback((patch, updateOptions) => storeOf(def).update(patch, updateOptions), [def])];
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { useAppState };
|
package/docs/GUIDE.md
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# @k8ordo/state
|
|
2
|
+
|
|
3
|
+
Declare state by where it lives. A definition names a place — the URL, the
|
|
4
|
+
history entry, localStorage, memory — and one zod schema per place derives
|
|
5
|
+
everything else: the server-side read, canonical link building, salvage of
|
|
6
|
+
stale data, and a client subscription with exact per-key change detection.
|
|
7
|
+
|
|
8
|
+
Like every k8ordo package it assumes React 19 and Server Components, uses only
|
|
9
|
+
what has reached Baseline newly available, and ships no polyfills or legacy
|
|
10
|
+
fallbacks. The Navigation API reached Baseline in January 2026; this package
|
|
11
|
+
treats it as simply present.
|
|
12
|
+
|
|
13
|
+
## The four places
|
|
14
|
+
|
|
15
|
+
| definition | lives in | survives | shared with | server |
|
|
16
|
+
| --------------------------- | ------------------- | --------------------- | --------------------- | ------ |
|
|
17
|
+
| `definePageState` — `url` | searchParams | back/forward, sharing | anyone given the URL | reads |
|
|
18
|
+
| `definePageState` — `entry` | history entry state | back/forward, reload | that tab's entry | — |
|
|
19
|
+
| `defineLocalState` | localStorage | until deleted | every tab, one device | — |
|
|
20
|
+
| `defineMemoryState` | the JS runtime | until reload | that tab | — |
|
|
21
|
+
|
|
22
|
+
`url` and `entry` are the two faces of one history entry — one visible and
|
|
23
|
+
shareable, one hidden — which is why they share a definition and update
|
|
24
|
+
atomically. localStorage and memory are app-scope, not page-scope, which is
|
|
25
|
+
why they are their own kinds: the grouping difference is real, so the API
|
|
26
|
+
makes it visible.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import {
|
|
30
|
+
defineLocalState,
|
|
31
|
+
defineMemoryState,
|
|
32
|
+
definePageState,
|
|
33
|
+
} from '@k8ordo/state';
|
|
34
|
+
import * as z from 'zod/mini';
|
|
35
|
+
|
|
36
|
+
// Page state: colocate with the feature that owns it.
|
|
37
|
+
export const listState = definePageState('product-list', {
|
|
38
|
+
url: z.object({
|
|
39
|
+
q: z._default(z.string(), ''),
|
|
40
|
+
page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
|
|
41
|
+
}),
|
|
42
|
+
entry: z.object({
|
|
43
|
+
expanded: z._default(z.array(z.string()), []),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// App state: lives in a shared module.
|
|
48
|
+
export const prefs = defineLocalState(
|
|
49
|
+
'prefs',
|
|
50
|
+
z.object({ view: z._default(z.enum(['grid', 'table']), 'grid') }),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// A typed shared box. No schema: these values never cross a boundary,
|
|
54
|
+
// so there is nothing to re-validate — the typed update is the only writer.
|
|
55
|
+
// Treat the values as immutable: replace fields through update(); a nested
|
|
56
|
+
// reference mutated in place bypasses change detection.
|
|
57
|
+
export const debugPanel = defineMemoryState('debug-panel', { open: false });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Schemas appear exactly where data comes back across a boundary — URL strings
|
|
61
|
+
a user can edit, localStorage JSON an older schema wrote, entry state a
|
|
62
|
+
session restore revived — and every such field must tolerate absence:
|
|
63
|
+
`.default()` (`z._default()` in mini) or `.optional()`. Definitions throw at
|
|
64
|
+
module load naming the fields that do not. The first argument is the state's
|
|
65
|
+
identity: the localStorage key (`k8ordo-state:<key>`), the entry-state
|
|
66
|
+
namespace, and the store-registry slot. Renaming it renames the data — and
|
|
67
|
+
the key must be app-unique per kind: two definitions of the same kind sharing
|
|
68
|
+
a key silently share one store (and, for local, one storage row). The module
|
|
69
|
+
system cannot enforce this, so treat the key like a global name.
|
|
70
|
+
|
|
71
|
+
## zod, or zod/mini
|
|
72
|
+
|
|
73
|
+
Parsing runs on zod's shared core, so a schema written with either entry
|
|
74
|
+
works. Unlike `@k8ordo/form` — where the schema stays on the server — the
|
|
75
|
+
client here parses and serializes with the schema itself, so the schema module
|
|
76
|
+
ships to the browser. Reach for `zod/mini` (10 kB gzipped vs 63 kB) unless the
|
|
77
|
+
app already pays for classic `zod` elsewhere.
|
|
78
|
+
|
|
79
|
+
## The shape of it
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
shared definePageState / defineLocalState / defineMemoryState no 'use client'
|
|
83
|
+
↓ import ↓ import
|
|
84
|
+
server listState.parseUrl(searchParams) client useAppState(def, ['q', 'page'])
|
|
85
|
+
listState.href('/products', {...}) → [state, update]
|
|
86
|
+
RSC, Server Action
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A definition is pure data — schemas and pure functions. No store lives inside
|
|
90
|
+
it, so a Server Component imports it without creating server-side mutable
|
|
91
|
+
state. The live store exists only in the browser, created lazily by the first
|
|
92
|
+
`useAppState`, keyed by the definition's string key (which is what lets state
|
|
93
|
+
survive HMR re-evaluation of the definition module). There is no Provider:
|
|
94
|
+
the stores mirror browser-wide singletons — the URL, the history entry,
|
|
95
|
+
localStorage — so there is nothing to scope.
|
|
96
|
+
|
|
97
|
+
## Server — read, and build links
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
export default async function Page({ searchParams }: PageProps<'/products'>) {
|
|
101
|
+
const url = listState.parseUrl(await searchParams);
|
|
102
|
+
// ^ { q: string; page: number } — typed, defaults applied
|
|
103
|
+
|
|
104
|
+
const products = await fetchProducts(url);
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<Filters initialUrl={url} />
|
|
108
|
+
<ProductList products={products} />
|
|
109
|
+
<Link href={listState.href('/products', { ...url, page: url.page + 1 })}>
|
|
110
|
+
next
|
|
111
|
+
</Link>
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`href` and `search` are pure: unspecified fields mean their default, and
|
|
118
|
+
default values are omitted from the query, so every link is canonical and as
|
|
119
|
+
short as it can be. `search` returns the query string alone (no `?`) when the
|
|
120
|
+
path should stay in the caller's hands. Only the url slot exists on the
|
|
121
|
+
server; entry, local and memory state render as their defaults there.
|
|
122
|
+
|
|
123
|
+
## Client — subscribe and update
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
'use client';
|
|
127
|
+
import { useAppState } from '@k8ordo/state';
|
|
128
|
+
import { listState, prefs } from './state';
|
|
129
|
+
|
|
130
|
+
export function Filters({ initialUrl }: FiltersProps) {
|
|
131
|
+
const [{ q, page, expanded }, update] = useAppState(listState, {
|
|
132
|
+
initialUrl,
|
|
133
|
+
});
|
|
134
|
+
const [{ view }, updatePrefs] = useAppState(prefs, ['view']);
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
<p>{q === '' ? 'all products' : `searching “${q}”`}</p>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={() => update({ page: page + 1 }, { history: 'push' })}
|
|
142
|
+
>
|
|
143
|
+
next page
|
|
144
|
+
</button>
|
|
145
|
+
<button type="button" onClick={() => updatePrefs({ view: 'table' })}>
|
|
146
|
+
table view
|
|
147
|
+
</button>
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The hook is the same for every kind. State is the flat merge of a page
|
|
154
|
+
definition's two slots — moving a field between `url` and `entry` is a
|
|
155
|
+
one-line change in the definition and no change at the call sites.
|
|
156
|
+
|
|
157
|
+
## Updates
|
|
158
|
+
|
|
159
|
+
`update()` applies synchronously — the next render sees the new value — and
|
|
160
|
+
returns the Navigation API's own shape, an object holding two promises:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const { committed, finished } = update({ page: 2 });
|
|
164
|
+
// committed: the write is in its home
|
|
165
|
+
// finished: the router's intercept work (fetch, render) is done
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Ignoring the handle is the normal case and trips no floating-promise lint.
|
|
169
|
+
Awaiting `finished` composes with React 19 async transitions:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
const [isPending, startTransition] = useTransition();
|
|
173
|
+
startTransition(async () => {
|
|
174
|
+
await update({ page: 2 }).finished;
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A superseded navigation (the user clicked again) rejects the handle with an
|
|
179
|
+
`AbortError`; unawaited calls never surface it. Updates with no navigation
|
|
180
|
+
behind them — entry-only, local, memory — return the same shape, settled.
|
|
181
|
+
|
|
182
|
+
- **Patches are validated on the spot.** The merged state passes the schema
|
|
183
|
+
inside `update()` itself, with the same salvage as a URL arrival:
|
|
184
|
+
`update({ page: 0 })` on a `min(1)` field lands on the default exactly as
|
|
185
|
+
`?page=0` would, and the echo never shows a value the schema rejects.
|
|
186
|
+
- **Routing is by field.** One patch may span both faces of a page state:
|
|
187
|
+
url + entry changes travel in a single `navigation.navigate()`, atomically.
|
|
188
|
+
A patch touching only entry fields uses `updateCurrentEntry()` — no
|
|
189
|
+
navigation, works under any router.
|
|
190
|
+
- **`replace` by default.** An update refines the current entry. Pass
|
|
191
|
+
`{ history: 'push' }` only for updates the back button should undo — the
|
|
192
|
+
option exists only on page state, the one kind with a navigation behind it.
|
|
193
|
+
- **One handler, one write.** Several `update()` calls in the same event
|
|
194
|
+
handler collapse into a single navigation (or storage write); they share
|
|
195
|
+
one handle. A batch that ends where it started does not write at all.
|
|
196
|
+
- **Functional form.** `update((current) => ({ page: current.page + 1 }))`
|
|
197
|
+
reads the batched state, not the committed one.
|
|
198
|
+
- **Shared ground stays shared.** A page state rewrites only its own params
|
|
199
|
+
in the URL and only its own namespace in the entry state; params and state
|
|
200
|
+
owned by other definitions — or by nobody — survive every write.
|
|
201
|
+
|
|
202
|
+
## Subscription granularity
|
|
203
|
+
|
|
204
|
+
The schema fixes the key set, so change detection is exact, per key:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
useAppState(listState); // any declared field re-renders this
|
|
208
|
+
useAppState(listState, ['q']); // only q changes re-render this
|
|
209
|
+
useAppState(listState, []); // write-only: subscribe to nothing
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A `page` change never re-renders a `['q']` subscriber. Inline key arrays are
|
|
213
|
+
normalized internally — no `useMemo` at the call site. When two pieces of
|
|
214
|
+
state update at very different rates, give them separate definitions; the
|
|
215
|
+
definition is the subscription boundary.
|
|
216
|
+
|
|
217
|
+
## Routers
|
|
218
|
+
|
|
219
|
+
Everything except one operation works under any router:
|
|
220
|
+
|
|
221
|
+
| operation | needs |
|
|
222
|
+
| ---------------------------------- | ------------------------------------------- |
|
|
223
|
+
| `parseUrl` on the server | nothing |
|
|
224
|
+
| `href` / `search` links, GET forms | nothing — the router handles the click |
|
|
225
|
+
| entry / local / memory updates | nothing — no navigation is involved |
|
|
226
|
+
| url `update()` on the client | a router that intercepts the Navigation API |
|
|
227
|
+
|
|
228
|
+
A url `update()` calls `navigation.navigate()`. On a router that intercepts
|
|
229
|
+
the `navigate` event, that is a same-document update flowing through the
|
|
230
|
+
router's own pipeline — transitions, scroll handling and all. On a router
|
|
231
|
+
that does not (Next.js today), it is a full document load: use links and GET
|
|
232
|
+
forms for url changes there, which is this package's preferred grain anyway.
|
|
233
|
+
There is deliberately no history-API fallback.
|
|
234
|
+
|
|
235
|
+
Keystrokes do not belong in the URL. Let the DOM or local React state hold
|
|
236
|
+
the draft and call `update()` at commit points — submit, blur, pagination —
|
|
237
|
+
the same division `@k8ordo/form` draws.
|
|
238
|
+
|
|
239
|
+
## Typed routes
|
|
240
|
+
|
|
241
|
+
`href` keeps the path literal alive in the type
|
|
242
|
+
(``'/products' | `/products?${string}` ``), which is what typed-route checks
|
|
243
|
+
strip a query from and verify. To constrain paths app-wide, augment `Register`
|
|
244
|
+
once:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// e.g. types/k8ordo-state.d.ts
|
|
248
|
+
import type { Route } from 'next';
|
|
249
|
+
|
|
250
|
+
declare module '@k8ordo/state' {
|
|
251
|
+
interface Register {
|
|
252
|
+
path: Route;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Every `href` in the app now rejects a path its router does not know. Without
|
|
258
|
+
the augmentation the constraint is any `/`-prefixed string. Augment only in an
|
|
259
|
+
application — a shared library augmenting `Register` leaks the constraint to
|
|
260
|
+
every consumer.
|
|
261
|
+
|
|
262
|
+
## What it guarantees
|
|
263
|
+
|
|
264
|
+
**Boundary data is input, not trusted state.** A URL param a user edited, a
|
|
265
|
+
localStorage row an older schema wrote, a restored entry state — anything the
|
|
266
|
+
schema rejects falls back to that field's own default, field by field, and a
|
|
267
|
+
combination an object-level `refine` forbids falls back to the defaults as a
|
|
268
|
+
whole. One broken value never takes the rest down, and nothing throws at read
|
|
269
|
+
time. An array field collects repeated params; a repeated param on a scalar
|
|
270
|
+
field takes the first value.
|
|
271
|
+
|
|
272
|
+
**Canonical URLs.** Serialization omits every field sitting at its default, so
|
|
273
|
+
the same state always produces the same, shortest URL — links, bookmarks and
|
|
274
|
+
caches agree.
|
|
275
|
+
|
|
276
|
+
**Back means back.** Both faces of a page state live on the history entry via
|
|
277
|
+
the platform, not in a parallel store, so back/forward restore them together
|
|
278
|
+
with no library bookkeeping.
|
|
279
|
+
|
|
280
|
+
**Tabs agree.** localStorage state propagates across tabs through the
|
|
281
|
+
`storage` event; the same keyed subscription granularity applies.
|
|
282
|
+
|
|
283
|
+
**SSR sees real url values when you hand them over.** Pass the RSC-parsed url
|
|
284
|
+
as `initialUrl` and the server render and hydration render show the actual
|
|
285
|
+
URL state. Everything else — entry, local, memory — renders its defaults on
|
|
286
|
+
the server by construction: those places do not exist there.
|
|
287
|
+
|
|
288
|
+
## GET forms with @k8ordo/form
|
|
289
|
+
|
|
290
|
+
A search or filter form is a GET form, and its constraints and its URL state
|
|
291
|
+
are the same schema:
|
|
292
|
+
|
|
293
|
+
```tsx
|
|
294
|
+
import { formFields } from '@k8ordo/form/server';
|
|
295
|
+
|
|
296
|
+
const filterFields = formFields(listState.url); // one schema, both jobs
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The form submits as GET, the RSC reads it back with `parseUrl`, and the whole
|
|
300
|
+
loop works before JavaScript loads.
|
|
301
|
+
|
|
302
|
+
## Testing
|
|
303
|
+
|
|
304
|
+
`resetStateRegistry()` clears the provider-less store registry between tests.
|
|
305
|
+
Unmount components first — mounted hooks keep their store through closures.
|
|
306
|
+
When testing url updates, intercept the `navigate` event in the test itself
|
|
307
|
+
(as a router would); an unintercepted `navigation.navigate()` is a
|
|
308
|
+
cross-document load.
|
package/docs/llms.txt
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @k8ordo/state
|
|
2
|
+
|
|
3
|
+
> Declare state by where it lives: `definePageState` (typed URL search params
|
|
4
|
+
> plus hidden history-entry state — the two faces of one entry, updated
|
|
5
|
+
> atomically over the Navigation API), `defineLocalState` (localStorage,
|
|
6
|
+
> cross-tab), `defineMemoryState` (a typed shared box, resets on reload).
|
|
7
|
+
> One zod schema per place derives the server read, canonical links, salvage
|
|
8
|
+
> of stale data, and a client subscription with exact per-key change
|
|
9
|
+
> detection. Works through links and GET forms before JavaScript loads;
|
|
10
|
+
> imperative url updates ride a Navigation-API-intercepting router.
|
|
11
|
+
>
|
|
12
|
+
> Like every k8ordo package it assumes React 19 and Server Components, uses
|
|
13
|
+
> only what has reached Baseline newly available, and ships no polyfills or
|
|
14
|
+
> legacy fallbacks.
|
|
15
|
+
|
|
16
|
+
When installed via npm, these docs live under `node_modules/@k8ordo/state/docs/`.
|
|
17
|
+
|
|
18
|
+
## Guide
|
|
19
|
+
|
|
20
|
+
- [Design Guide](GUIDE.md): the four places and when each fits, the shared
|
|
21
|
+
definition module, reading on the server, updating on the client, batching
|
|
22
|
+
and update handles, subscription granularity, router requirements, typed
|
|
23
|
+
routes via `Register`, and pairing with `@k8ordo/form` for GET forms.
|
|
24
|
+
|
|
25
|
+
## Entry point
|
|
26
|
+
|
|
27
|
+
- `@k8ordo/state` — `definePageState(key, { url?, entry? })` returns a pure
|
|
28
|
+
definition usable from server and client: `parseUrl(searchParams)` reads
|
|
29
|
+
the url slot typed with defaults applied and per-field salvage;
|
|
30
|
+
`href(base, values?)` builds a canonical link keeping the path literal in
|
|
31
|
+
the type; `search(values?)` returns the query string alone.
|
|
32
|
+
`defineLocalState(key, schema)` and `defineMemoryState(key, initial)`
|
|
33
|
+
declare app-scope state. `useAppState(definition, keys?, { initialUrl? })`
|
|
34
|
+
(client) works on any kind and returns `[state, update]` — state is the
|
|
35
|
+
flat merge of a page definition's slots; `update(patch)` applies
|
|
36
|
+
synchronously, batches per handler into one write (url+entry atomically via
|
|
37
|
+
`navigation.navigate()`, entry-only via `updateCurrentEntry()`, local via
|
|
38
|
+
one `setItem`), and returns `{ committed, finished }` promise handles
|
|
39
|
+
(settled for non-navigating kinds; `{ history: 'push' }` exists only on
|
|
40
|
+
page state). `resetStateRegistry()` clears stores between tests. Augment
|
|
41
|
+
`interface Register { path }` to constrain `href` paths to the app's typed
|
|
42
|
+
routes.
|