@k8ordo/state 0.1.0 → 0.2.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 +143 -0
- package/dist/local-state.d.mts +10 -0
- package/dist/local-state.mjs +12 -1
- package/dist/register.d.mts +21 -7
- package/dist/store/local-store.mjs +1 -2
- package/dist/store/page-store.mjs +29 -32
- package/dist/url/codec.mjs +18 -6
- package/dist/use-app-state.d.mts +6 -2
- package/docs/GUIDE.md +114 -25
- package/docs/llms.txt +15 -5
- package/package.json +12 -4
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @k8ordo/state
|
|
2
|
+
|
|
3
|
+
Declare state by where it lives — URL search params, hidden history-entry
|
|
4
|
+
state, localStorage, or memory. One zod schema per place derives the
|
|
5
|
+
server-side read, canonical links, salvage of stale data, and a client
|
|
6
|
+
subscription with exact per-key change detection. Links and GET forms work
|
|
7
|
+
before JavaScript loads; imperative URL updates ride the Navigation API.
|
|
8
|
+
|
|
9
|
+
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
10
|
+
Components, uses only what has reached Baseline newly available, and ships no
|
|
11
|
+
polyfills or legacy fallbacks.
|
|
12
|
+
|
|
13
|
+
- **Documentation**: https://ordo.k8o.me/state
|
|
14
|
+
- **Design guide**: [docs/GUIDE.md](docs/GUIDE.md) — shipped inside this package
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @k8ordo/state zod
|
|
20
|
+
# or
|
|
21
|
+
pnpm add @k8ordo/state zod
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Peer Dependencies
|
|
25
|
+
|
|
26
|
+
| Package | Version | Needed for |
|
|
27
|
+
| ---------------- | -------- | -------------------------------------------------------- |
|
|
28
|
+
| `react` | ≥19.2.6 | `useAppState` |
|
|
29
|
+
| `zod` | ^4.4.3 | the schemas (`zod/mini` works, and is the lighter pick) |
|
|
30
|
+
| `@k8ordo/router` | ^0.1.0 | optional — typed `href` paths from the app's route table |
|
|
31
|
+
| `typescript` | ≥7.0.2 | the shipped type declarations |
|
|
32
|
+
| `@types/react` | ≥19.2.18 | the shipped type declarations |
|
|
33
|
+
|
|
34
|
+
The schema ships to the browser here — the client parses and serializes with
|
|
35
|
+
it — so reach for `zod/mini` unless the app already pays for classic `zod`.
|
|
36
|
+
`@k8ordo/router` is a type-only peer: it is never loaded at runtime.
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
One definition per place, in a shared module with no `'use client'`:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// state.ts
|
|
44
|
+
import { defineLocalState, definePageState } from '@k8ordo/state';
|
|
45
|
+
import * as z from 'zod/mini';
|
|
46
|
+
|
|
47
|
+
export const listState = definePageState('list', {
|
|
48
|
+
url: z.object({
|
|
49
|
+
q: z._default(z.string(), ''),
|
|
50
|
+
page: z._default(z.coerce.number(), 1),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const prefs = defineLocalState(
|
|
55
|
+
'prefs',
|
|
56
|
+
z.object({ view: z._default(z.enum(['grid', 'table']), 'grid') }),
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// page.tsx — Server Component
|
|
62
|
+
import { listState } from './state';
|
|
63
|
+
|
|
64
|
+
export default async function Page({ searchParams }: PageProps<'/products'>) {
|
|
65
|
+
const url = listState.parseUrl(await searchParams); // typed, defaults applied
|
|
66
|
+
const products = await fetchProducts(url);
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
<Filters initialUrl={url} />
|
|
70
|
+
<ProductList products={products} />
|
|
71
|
+
<a href={listState.href('/products', { ...url, page: url.page + 1 })}>
|
|
72
|
+
next
|
|
73
|
+
</a>
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// filters.tsx
|
|
81
|
+
'use client';
|
|
82
|
+
import { useAppState } from '@k8ordo/state';
|
|
83
|
+
import { listState, prefs } from './state';
|
|
84
|
+
|
|
85
|
+
export function Filters({ initialUrl }: FiltersProps) {
|
|
86
|
+
const [{ q, page }, update] = useAppState(listState, { initialUrl });
|
|
87
|
+
const [{ view }, updatePrefs] = useAppState(prefs, ['view']);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<p>{q === '' ? 'all products' : `searching “${q}”`}</p>
|
|
92
|
+
<button type="button" onClick={() => update({ page: page + 1 })}>
|
|
93
|
+
next page
|
|
94
|
+
</button>
|
|
95
|
+
<button type="button" onClick={() => updatePrefs({ view: 'table' })}>
|
|
96
|
+
table view
|
|
97
|
+
</button>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`update()` applies synchronously, batches per handler into one write, and
|
|
104
|
+
returns `{ committed, finished }` handles. `href` omits every field at its
|
|
105
|
+
default, so every link is canonical. A value the schema rejects — a URL a
|
|
106
|
+
user edited, a row an older schema wrote — falls back to its own default,
|
|
107
|
+
field by field.
|
|
108
|
+
|
|
109
|
+
The [design guide](docs/GUIDE.md) covers the rest: the four places and when
|
|
110
|
+
each fits, history-entry state, batching and update handles, subscription
|
|
111
|
+
granularity, router requirements, typed routes via `Register`, reading local
|
|
112
|
+
state before hydration with `inlineRead()`, and GET forms with `@k8ordo/form`.
|
|
113
|
+
|
|
114
|
+
## AI Agent Documentation
|
|
115
|
+
|
|
116
|
+
The docs ship **inside the package**, so an agent always reads the exact
|
|
117
|
+
version you installed — there is no snapshot to copy or re-sync on upgrade.
|
|
118
|
+
|
|
119
|
+
Point your agent at them once by pasting this into your project's `CLAUDE.md` /
|
|
120
|
+
`AGENTS.md`:
|
|
121
|
+
|
|
122
|
+
```markdown
|
|
123
|
+
Use `@k8ordo/state` for URL, history-entry, localStorage and shared memory
|
|
124
|
+
state. Before adding or changing state, read
|
|
125
|
+
`node_modules/@k8ordo/state/docs/GUIDE.md`. Declare each state by where it
|
|
126
|
+
lives (`definePageState` / `defineLocalState` / `defineMemoryState`) in a
|
|
127
|
+
shared module, read the url slot on the server with `parseUrl`, build links
|
|
128
|
+
with `href`, and subscribe on the client with `useAppState`. Every boundary
|
|
129
|
+
field needs a default or `.optional()`; never mirror a definition's values
|
|
130
|
+
into React state.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
What each surface gives an agent:
|
|
134
|
+
|
|
135
|
+
| Surface | Where |
|
|
136
|
+
| -------------------------- | ---------------------------------------------- |
|
|
137
|
+
| Design guide (entry point) | `node_modules/@k8ordo/state/docs/GUIDE.md` |
|
|
138
|
+
| Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
|
|
139
|
+
| Markdown twin on the web | https://ordo.k8o.me/state/docs/GUIDE.md |
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
|
package/dist/local-state.d.mts
CHANGED
|
@@ -12,6 +12,16 @@ type LocalState<Schema extends StateSchema = StateSchema> = {
|
|
|
12
12
|
key: string;
|
|
13
13
|
/** The schema as passed. */
|
|
14
14
|
schema: Schema;
|
|
15
|
+
/** The localStorage key the values are written under: `k8ordo-state:<key>`. */
|
|
16
|
+
storageKey: string;
|
|
17
|
+
/**
|
|
18
|
+
* A JavaScript expression, for an inline `<script>`, that evaluates in the
|
|
19
|
+
* browser to the stored object — or `null` when nothing is stored, the JSON
|
|
20
|
+
* is corrupt, the value is not an object, or storage cannot be read. The
|
|
21
|
+
* schema does not run there: treat the result as untrusted and read only
|
|
22
|
+
* the fields you need, each with its own fallback.
|
|
23
|
+
*/
|
|
24
|
+
inlineRead: () => string;
|
|
15
25
|
};
|
|
16
26
|
declare const localCodecOf: (def: LocalState) => StoredCodec;
|
|
17
27
|
declare const defineLocalState: <Schema extends StateSchema>(key: string, schema: Schema) => LocalState<Schema>;
|
package/dist/local-state.mjs
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { createStoredCodec } from "./entry/codec.mjs";
|
|
2
2
|
//#region src/local-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 inlineReadOf = (storageKey) => `(()=>{try{const v=JSON.parse(localStorage.getItem(${literalOf(storageKey)}));return v!==null&&typeof v==="object"&&!Array.isArray(v)?v:null}catch{return null}})()`;
|
|
3
11
|
const codecs = /* @__PURE__ */ new WeakMap();
|
|
4
12
|
const localCodecOf = (def) => {
|
|
5
13
|
const codec = codecs.get(def);
|
|
@@ -7,10 +15,13 @@ const localCodecOf = (def) => {
|
|
|
7
15
|
return codec;
|
|
8
16
|
};
|
|
9
17
|
const defineLocalState = (key, schema) => {
|
|
18
|
+
const storageKey = `${STORAGE_KEY_PREFIX}${key}`;
|
|
10
19
|
const def = {
|
|
11
20
|
kind: "local",
|
|
12
21
|
key,
|
|
13
|
-
schema
|
|
22
|
+
schema,
|
|
23
|
+
storageKey,
|
|
24
|
+
inlineRead: () => inlineReadOf(storageKey)
|
|
14
25
|
};
|
|
15
26
|
codecs.set(def, createStoredCodec(schema, "local"));
|
|
16
27
|
return def;
|
package/dist/register.d.mts
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
|
+
import { RouteOf } from "@k8ordo/router";
|
|
1
2
|
//#region src/register.d.ts
|
|
2
3
|
/**
|
|
3
|
-
* The app-side hook for typed route paths. An application augments this once
|
|
4
|
+
* The app-side hook for typed route paths. An application augments this once,
|
|
5
|
+
* with the same line its `@k8ordo/router` augmentation uses:
|
|
4
6
|
*
|
|
5
7
|
* ```ts
|
|
6
|
-
* import type {
|
|
8
|
+
* import type { routes } from './routes';
|
|
7
9
|
* declare module '@k8ordo/state' {
|
|
8
|
-
* interface Register {
|
|
10
|
+
* interface Register { routes: typeof routes }
|
|
9
11
|
* }
|
|
10
12
|
* ```
|
|
11
13
|
*
|
|
12
|
-
* and every `href()` in the app is constrained to
|
|
14
|
+
* and every `href()` in the app is constrained to the table's linkable paths.
|
|
15
|
+
* A router that is not `@k8ordo/router` registers its path union directly —
|
|
16
|
+
* `interface Register { path: Route }` with `Route` from `next` — which is
|
|
17
|
+
* also what the framework's generator emitted before `routes` existed.
|
|
13
18
|
* Declared as an interface — the one exception to the repository's type-only
|
|
14
19
|
* rule — because declaration merging is the entire mechanism.
|
|
15
20
|
*/
|
|
16
21
|
interface Register {}
|
|
17
|
-
/**
|
|
18
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The path union a `Register` shape yields: the table's linkable paths when
|
|
24
|
+
* it carries `routes`, the union as given when it carries `path`, and any
|
|
25
|
+
* `/`-path when it carries neither. `RouteOf` is imported as a type only, so
|
|
26
|
+
* `@k8ordo/router` is never loaded at runtime and stays an optional peer.
|
|
27
|
+
*/
|
|
28
|
+
type PathFrom<R> = R extends {
|
|
29
|
+
routes: infer Routes;
|
|
30
|
+
} ? RouteOf<Routes> : R extends {
|
|
19
31
|
path: infer P extends string;
|
|
20
32
|
} ? P : `/${string}`;
|
|
33
|
+
/** `href()`'s path constraint: the registered route type, or any `/`-path. */
|
|
34
|
+
type RegisteredPath = PathFrom<Register>;
|
|
21
35
|
//#endregion
|
|
22
|
-
export { Register, RegisteredPath };
|
|
36
|
+
export { PathFrom, Register, RegisteredPath };
|
|
@@ -2,10 +2,9 @@ import { localCodecOf } from "../local-state.mjs";
|
|
|
2
2
|
import { createHandle, createStoreCore, resolvePatch } from "./core.mjs";
|
|
3
3
|
import { getOrCreateStore } from "./registry.mjs";
|
|
4
4
|
//#region src/store/local-store.ts
|
|
5
|
-
const storageKeyOf = (def) => `k8ordo-state:${def.key}`;
|
|
6
5
|
const createLocalStore = (def) => {
|
|
7
6
|
const codec = localCodecOf(def);
|
|
8
|
-
const storageKey =
|
|
7
|
+
const { storageKey } = def;
|
|
9
8
|
const read = () => {
|
|
10
9
|
const text = localStorage.getItem(storageKey);
|
|
11
10
|
if (text === null) return codec.parse(void 0);
|
|
@@ -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;
|
package/dist/url/codec.mjs
CHANGED
|
@@ -25,7 +25,14 @@ const serializeValue = (key, value) => {
|
|
|
25
25
|
const createUrlCodec = (schema) => {
|
|
26
26
|
const info = analyzeSchema(schema, "url");
|
|
27
27
|
const { keys, defaults } = info;
|
|
28
|
-
const multi = new Set(
|
|
28
|
+
const multi = /* @__PURE__ */ new Set();
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
const base = baseDef(info.shape[key]).type;
|
|
31
|
+
if (base === "array") {
|
|
32
|
+
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
|
+
multi.add(key);
|
|
34
|
+
} else if (base === "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(): ${key}`);
|
|
35
|
+
}
|
|
29
36
|
const parse = (input) => {
|
|
30
37
|
const raw = {};
|
|
31
38
|
for (const key of keys) {
|
|
@@ -45,11 +52,16 @@ const createUrlCodec = (schema) => {
|
|
|
45
52
|
}
|
|
46
53
|
return params.toString();
|
|
47
54
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The values as they will come back: written to a query string and read
|
|
57
|
+
* again. Going through the URL rather than handing the typed values
|
|
58
|
+
* straight to the schema is what makes a one-way spelling work — the
|
|
59
|
+
* `string → boolean` of `z.stringbool()` never sees a boolean, exactly as
|
|
60
|
+
* on arrival — and it is the only reading under which `update()` and a
|
|
61
|
+
* visitor's URL are the same path. A value with no URL spelling throws
|
|
62
|
+
* here, which is the earliest anyone can be told.
|
|
63
|
+
*/
|
|
64
|
+
const salvage = (values) => parse(new URLSearchParams(search(values)));
|
|
53
65
|
return {
|
|
54
66
|
keys,
|
|
55
67
|
defaults,
|
package/dist/use-app-state.d.mts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StateSchema } from "./schema/object.mjs";
|
|
1
2
|
import { OutputOf, PageState } from "./page-state.mjs";
|
|
2
3
|
import { LocalState } from "./local-state.mjs";
|
|
3
4
|
import { MemoryState } from "./memory-state.mjs";
|
|
@@ -9,7 +10,10 @@ type AnyState = PageState | LocalState | MemoryState;
|
|
|
9
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
11
|
type UrlStateOf<Def> = Def extends PageState<infer Url> ? OutputOf<Url> : never;
|
|
11
12
|
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
|
|
13
|
+
type OptionsFor<Def> = Def extends {
|
|
14
|
+
kind: 'page';
|
|
15
|
+
url: StateSchema;
|
|
16
|
+
} ? {
|
|
13
17
|
/**
|
|
14
18
|
* The RSC-parsed url state, passed down as a prop. It seeds SSR and
|
|
15
19
|
* the hydration render; without it those two renders see the defaults,
|
|
@@ -17,7 +21,7 @@ type OptionsFor<Def> = Def extends PageState ? {
|
|
|
17
21
|
* server-side source, so it always starts from defaults there.
|
|
18
22
|
*/
|
|
19
23
|
initialUrl?: Readonly<UrlStateOf<Def>>;
|
|
20
|
-
} :
|
|
24
|
+
} : never;
|
|
21
25
|
declare function useAppState<Def extends AnyState>(def: Def, options?: OptionsFor<Def>): [StateOf<Def>, UpdateFn<Def>];
|
|
22
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>];
|
|
23
27
|
//#endregion
|
package/docs/GUIDE.md
CHANGED
|
@@ -62,12 +62,30 @@ a user can edit, localStorage JSON an older schema wrote, entry state a
|
|
|
62
62
|
session restore revived — and every such field must tolerate absence:
|
|
63
63
|
`.default()` (`z._default()` in mini) or `.optional()`. Definitions throw at
|
|
64
64
|
module load naming the fields that do not. The first argument is the state's
|
|
65
|
-
identity: the localStorage key (`k8ordo-state:<key
|
|
66
|
-
namespace, and the store-registry
|
|
65
|
+
identity: the localStorage key (`k8ordo-state:<key>`, exposed as the
|
|
66
|
+
definition's `storageKey`), the entry-state namespace, and the store-registry
|
|
67
|
+
slot. Renaming it renames the data — and
|
|
67
68
|
the key must be app-unique per kind: two definitions of the same kind sharing
|
|
68
69
|
a key silently share one store (and, for local, one storage row). The module
|
|
69
70
|
system cannot enforce this, so treat the key like a global name.
|
|
70
71
|
|
|
72
|
+
**A boundary schema must also accept what it produces.** `update()` re-reads
|
|
73
|
+
its own values by the road they will come back on, so a field whose input and
|
|
74
|
+
output sides disagree lands on its default instead of the value you wrote. For
|
|
75
|
+
url that road is the query string, which is why two spellings are refused at
|
|
76
|
+
module load rather than at the first click:
|
|
77
|
+
|
|
78
|
+
| written | use instead |
|
|
79
|
+
| --------------------------------------------- | ---------------- |
|
|
80
|
+
| `z.boolean()` / `z.coerce.boolean()` in `url` | `z.stringbool()` |
|
|
81
|
+
| a `url` array defaulting to anything but `[]` | `.default([])` |
|
|
82
|
+
|
|
83
|
+
A URL carries strings, and `"false"` is not `false` to a boolean schema; an
|
|
84
|
+
absent param and an empty list are the same URL, so an array whose default is
|
|
85
|
+
non-empty could never write `[]`. The same rule holds for values a URL cannot
|
|
86
|
+
spell at all: a `z.date()` url field is refused the moment something writes
|
|
87
|
+
one, naming the field.
|
|
88
|
+
|
|
71
89
|
## zod, or zod/mini
|
|
72
90
|
|
|
73
91
|
Parsing runs on zod's shared core, so a schema written with either entry
|
|
@@ -96,6 +114,9 @@ localStorage — so there is nothing to scope.
|
|
|
96
114
|
|
|
97
115
|
## Server — read, and build links
|
|
98
116
|
|
|
117
|
+
`parseUrl` reads the url slot wherever a router hands a page its search — for
|
|
118
|
+
example Next.js:
|
|
119
|
+
|
|
99
120
|
```tsx
|
|
100
121
|
export default async function Page({ searchParams }: PageProps<'/products'>) {
|
|
101
122
|
const url = listState.parseUrl(await searchParams);
|
|
@@ -106,19 +127,28 @@ export default async function Page({ searchParams }: PageProps<'/products'>) {
|
|
|
106
127
|
<>
|
|
107
128
|
<Filters initialUrl={url} />
|
|
108
129
|
<ProductList products={products} />
|
|
109
|
-
<
|
|
130
|
+
<a href={listState.href('/products', { ...url, page: url.page + 1 })}>
|
|
110
131
|
next
|
|
111
|
-
</
|
|
132
|
+
</a>
|
|
112
133
|
</>
|
|
113
134
|
);
|
|
114
135
|
}
|
|
115
136
|
```
|
|
116
137
|
|
|
138
|
+
**Under `@k8ordo/static` and `@k8ordo/server` a page never sees the search.**
|
|
139
|
+
Those pages receive `params` and `pathname` — the pathname axis is the
|
|
140
|
+
router's, and the search is read in the browser by `useAppState`. The server
|
|
141
|
+
render therefore shows the url slot's defaults and the live URL takes over one
|
|
142
|
+
render after hydration. That is not a gap to route around: the router
|
|
143
|
+
intercepts a same-pathname navigation without loading anything, so a server
|
|
144
|
+
render keyed on the search would be right on the first load and stale after
|
|
145
|
+
the first `update()`. Building links is unaffected — `href` and `search` are
|
|
146
|
+
pure and run anywhere, including in a Server Component.
|
|
147
|
+
|
|
117
148
|
`href` and `search` are pure: unspecified fields mean their default, and
|
|
118
149
|
default values are omitted from the query, so every link is canonical and as
|
|
119
150
|
short as it can be. `search` returns the query string alone (no `?`) when the
|
|
120
|
-
path should stay in the caller's hands.
|
|
121
|
-
server; entry, local and memory state render as their defaults there.
|
|
151
|
+
path should stay in the caller's hands.
|
|
122
152
|
|
|
123
153
|
## Client — subscribe and update
|
|
124
154
|
|
|
@@ -162,7 +192,7 @@ returns the Navigation API's own shape, an object holding two promises:
|
|
|
162
192
|
```ts
|
|
163
193
|
const { committed, finished } = update({ page: 2 });
|
|
164
194
|
// committed: the write is in its home
|
|
165
|
-
// finished: the router
|
|
195
|
+
// finished: whatever the router did after the write is done
|
|
166
196
|
```
|
|
167
197
|
|
|
168
198
|
Ignoring the handle is the normal case and trips no floating-promise lint.
|
|
@@ -179,10 +209,12 @@ A superseded navigation (the user clicked again) rejects the handle with an
|
|
|
179
209
|
`AbortError`; unawaited calls never surface it. Updates with no navigation
|
|
180
210
|
behind them — entry-only, local, memory — return the same shape, settled.
|
|
181
211
|
|
|
182
|
-
- **Patches are validated on the spot.** The merged state
|
|
183
|
-
inside `update()` itself,
|
|
212
|
+
- **Patches are validated on the spot.** The merged state goes through the
|
|
213
|
+
schema inside `update()` itself, by the road a URL arrival takes:
|
|
184
214
|
`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.
|
|
215
|
+
`?page=0` would, and the echo never shows a value the schema rejects. A
|
|
216
|
+
value the URL cannot carry is refused there and then, before anything is
|
|
217
|
+
written — the call throws rather than rejecting a handle nobody awaits.
|
|
186
218
|
- **Routing is by field.** One patch may span both faces of a page state:
|
|
187
219
|
url + entry changes travel in a single `navigation.navigate()`, atomically.
|
|
188
220
|
A patch touching only entry fields uses `updateCurrentEntry()` — no
|
|
@@ -220,15 +252,19 @@ Everything except one operation works under any router:
|
|
|
220
252
|
|
|
221
253
|
| operation | needs |
|
|
222
254
|
| ---------------------------------- | ------------------------------------------- |
|
|
223
|
-
| `parseUrl` on the server | nothing |
|
|
224
255
|
| `href` / `search` links, GET forms | nothing — the router handles the click |
|
|
225
256
|
| entry / local / memory updates | nothing — no navigation is involved |
|
|
226
257
|
| url `update()` on the client | a router that intercepts the Navigation API |
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
that
|
|
258
|
+
| `parseUrl` on the server | a router that hands the page its search |
|
|
259
|
+
|
|
260
|
+
A url `update()` calls `navigation.navigate()`. Under `@k8ordo/router` —
|
|
261
|
+
including a page rendered by `@k8ordo/static` or `@k8ordo/server` — a
|
|
262
|
+
navigation that keeps the pathname is a state change and not a page change:
|
|
263
|
+
the router intercepts it without a load, nothing remounts, and scroll and
|
|
264
|
+
focus stay where they are. `finished` therefore resolves once that navigation
|
|
265
|
+
settles, with no fetch or render behind it, since `update()` already rendered
|
|
266
|
+
the new values. On a router that does not intercept the Navigation API
|
|
267
|
+
(Next.js today), the same call is a full document load: use links and GET
|
|
232
268
|
forms for url changes there, which is this package's preferred grain anyway.
|
|
233
269
|
There is deliberately no history-API fallback.
|
|
234
270
|
|
|
@@ -241,7 +277,34 @@ the same division `@k8ordo/form` draws.
|
|
|
241
277
|
`href` keeps the path literal alive in the type
|
|
242
278
|
(``'/products' | `/products?${string}` ``), which is what typed-route checks
|
|
243
279
|
strip a query from and verify. To constrain paths app-wide, augment `Register`
|
|
244
|
-
once:
|
|
280
|
+
once, with the same line the `@k8ordo/router` augmentation takes:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
// e.g. types/k8ordo.d.ts
|
|
284
|
+
import type { routes } from '../routes';
|
|
285
|
+
|
|
286
|
+
declare module '@k8ordo/router' {
|
|
287
|
+
interface Register {
|
|
288
|
+
routes: typeof routes;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
declare module '@k8ordo/state' {
|
|
293
|
+
interface Register {
|
|
294
|
+
routes: typeof routes;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`href` then accepts exactly the table's linkable paths — a `:param` becomes
|
|
300
|
+
`${string}`, a `*` wildcard is matched but never linked — derived through
|
|
301
|
+
`RouteOf` from `@k8ordo/router` as a type only, so the router stays an
|
|
302
|
+
optional peer and never loads at runtime. Under `@k8ordo/static` or
|
|
303
|
+
`@k8ordo/server` this is generated for you into `.k8ordo/` from `routes/`,
|
|
304
|
+
and hand-writing it is a mistake.
|
|
305
|
+
|
|
306
|
+
A router that is not `@k8ordo/router` has no table to hand over; register its
|
|
307
|
+
path union directly under `path` — `Route` from `next`, for instance:
|
|
245
308
|
|
|
246
309
|
```ts
|
|
247
310
|
// e.g. types/k8ordo-state.d.ts
|
|
@@ -254,10 +317,34 @@ declare module '@k8ordo/state' {
|
|
|
254
317
|
}
|
|
255
318
|
```
|
|
256
319
|
|
|
257
|
-
Every `href` in the app now rejects a
|
|
258
|
-
the augmentation the constraint is any
|
|
259
|
-
|
|
260
|
-
every consumer.
|
|
320
|
+
When both are present, `routes` wins. Every `href` in the app now rejects a
|
|
321
|
+
path its router does not know. Without the augmentation the constraint is any
|
|
322
|
+
`/`-prefixed string. Augment only in an application — a shared library
|
|
323
|
+
augmenting `Register` leaks the constraint to every consumer.
|
|
324
|
+
|
|
325
|
+
## Reading before hydration
|
|
326
|
+
|
|
327
|
+
Some state has to be applied before the first paint: a theme class on
|
|
328
|
+
`<html>`, say, or a colour scheme the browser must not flash the default of.
|
|
329
|
+
`useAppState` runs after hydration, which is too late for that, and the
|
|
330
|
+
alternative — an inline script with the storage key and the JSON envelope
|
|
331
|
+
hard-coded in a string — drifts the moment either changes.
|
|
332
|
+
|
|
333
|
+
A local definition carries both halves: `storageKey` is the key the store
|
|
334
|
+
writes under, and `inlineRead()` returns a JavaScript _expression_ for an
|
|
335
|
+
inline `<script>` that evaluates, in the browser, to the stored object — or
|
|
336
|
+
`null` when nothing is stored, the JSON is corrupt, the value is not an object,
|
|
337
|
+
or storage cannot be read. The key is escaped for a script context, so any
|
|
338
|
+
key is safe to emit.
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
<script>{`const s = ${themeState.inlineRead()}; if (s && s.mode === 'dark') document.documentElement.classList.add('dark');`}</script>
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The schema does not run there — no module has loaded yet — so what comes back
|
|
345
|
+
is the raw row, not the salvaged state `useAppState` will show. Treat it as
|
|
346
|
+
untrusted: read only the fields you need, each with its own fallback, and let
|
|
347
|
+
the hydrated store be the source of truth from then on.
|
|
261
348
|
|
|
262
349
|
## What it guarantees
|
|
263
350
|
|
|
@@ -280,10 +367,12 @@ with no library bookkeeping.
|
|
|
280
367
|
**Tabs agree.** localStorage state propagates across tabs through the
|
|
281
368
|
`storage` event; the same keyed subscription granularity applies.
|
|
282
369
|
|
|
283
|
-
**SSR sees real url values when you
|
|
284
|
-
as `initialUrl` and the server render and hydration render
|
|
285
|
-
URL state.
|
|
286
|
-
|
|
370
|
+
**SSR sees real url values when your router hands you the search.** Pass the
|
|
371
|
+
RSC-parsed url as `initialUrl` and the server render and the hydration render
|
|
372
|
+
show the actual URL state. Where a page receives no search — `@k8ordo/static`
|
|
373
|
+
and `@k8ordo/server` — the url slot renders its defaults and the live URL
|
|
374
|
+
takes over on hydration. Everything else — entry, local, memory — renders its
|
|
375
|
+
defaults on the server by construction: those places do not exist there.
|
|
287
376
|
|
|
288
377
|
## GET forms with @k8ordo/form
|
|
289
378
|
|
package/docs/llms.txt
CHANGED
|
@@ -20,17 +20,25 @@ When installed via npm, these docs live under `node_modules/@k8ordo/state/docs/`
|
|
|
20
20
|
- [Design Guide](GUIDE.md): the four places and when each fits, the shared
|
|
21
21
|
definition module, reading on the server, updating on the client, batching
|
|
22
22
|
and update handles, subscription granularity, router requirements, typed
|
|
23
|
-
routes via `Register`,
|
|
23
|
+
routes via `Register`, reading local state before hydration, and pairing
|
|
24
|
+
with `@k8ordo/form` for GET forms.
|
|
24
25
|
|
|
25
26
|
## Entry point
|
|
26
27
|
|
|
27
28
|
- `@k8ordo/state` — `definePageState(key, { url?, entry? })` returns a pure
|
|
28
29
|
definition usable from server and client: `parseUrl(searchParams)` reads
|
|
29
|
-
the url slot typed with defaults applied and per-field salvage
|
|
30
|
+
the url slot typed with defaults applied and per-field salvage (wherever the
|
|
31
|
+
router hands a page its search — under `@k8ordo/static` / `@k8ordo/server` a
|
|
32
|
+
page receives `params` and `pathname` only, and the url slot is read in the
|
|
33
|
+
browser);
|
|
30
34
|
`href(base, values?)` builds a canonical link keeping the path literal in
|
|
31
35
|
the type; `search(values?)` returns the query string alone.
|
|
32
36
|
`defineLocalState(key, schema)` and `defineMemoryState(key, initial)`
|
|
33
|
-
declare app-scope state
|
|
37
|
+
declare app-scope state; a local definition also exposes `storageKey` (the
|
|
38
|
+
localStorage key, `k8ordo-state:<key>`) and `inlineRead()`, a JavaScript
|
|
39
|
+
expression for an inline `<script>` that evaluates to the stored object or
|
|
40
|
+
`null` before hydration — unvalidated, so read only what you need with a
|
|
41
|
+
fallback. `useAppState(definition, keys?, { initialUrl? })`
|
|
34
42
|
(client) works on any kind and returns `[state, update]` — state is the
|
|
35
43
|
flat merge of a page definition's slots; `update(patch)` applies
|
|
36
44
|
synchronously, batches per handler into one write (url+entry atomically via
|
|
@@ -38,5 +46,7 @@ When installed via npm, these docs live under `node_modules/@k8ordo/state/docs/`
|
|
|
38
46
|
one `setItem`), and returns `{ committed, finished }` promise handles
|
|
39
47
|
(settled for non-navigating kinds; `{ history: 'push' }` exists only on
|
|
40
48
|
page state). `resetStateRegistry()` clears stores between tests. Augment
|
|
41
|
-
`interface Register {
|
|
42
|
-
|
|
49
|
+
`interface Register { routes: typeof routes }` — the same line as for
|
|
50
|
+
`@k8ordo/router` — to constrain `href` paths to the route table's linkable
|
|
51
|
+
paths; a router without a table registers its own path union as
|
|
52
|
+
`interface Register { path }` instead.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k8ordo/state",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Declare state by where it lives — URL search params, history-entry state, localStorage, or memory. One zod schema per place, over the Navigation API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"localstorage",
|
|
@@ -40,9 +40,11 @@
|
|
|
40
40
|
"registry": "https://registry.npmjs.org"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
+
"@arethetypeswrong/cli": "0.18.5",
|
|
43
44
|
"@types/react": "19.2.18",
|
|
44
45
|
"@vitejs/plugin-react": "6.0.5",
|
|
45
46
|
"@vitest/browser-playwright": "4.1.10",
|
|
47
|
+
"publint": "0.3.23",
|
|
46
48
|
"react": "19.2.8",
|
|
47
49
|
"react-dom": "19.2.8",
|
|
48
50
|
"typescript": "7.0.2",
|
|
@@ -50,15 +52,20 @@
|
|
|
50
52
|
"vite-plus": "0.2.9",
|
|
51
53
|
"vitest": "4.1.10",
|
|
52
54
|
"vitest-browser-react": "2.2.0",
|
|
53
|
-
"zod": "4.4.3"
|
|
55
|
+
"zod": "4.4.3",
|
|
56
|
+
"@k8ordo/router": "^0.1.0"
|
|
54
57
|
},
|
|
55
58
|
"peerDependencies": {
|
|
56
59
|
"@types/react": ">=19.2.18",
|
|
57
60
|
"react": ">=19.2.6",
|
|
58
61
|
"typescript": ">=7.0.2",
|
|
59
|
-
"zod": "^4.4.3"
|
|
62
|
+
"zod": "^4.4.3",
|
|
63
|
+
"@k8ordo/router": "^0.1.0"
|
|
60
64
|
},
|
|
61
65
|
"peerDependenciesMeta": {
|
|
66
|
+
"@k8ordo/router": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
62
69
|
"@types/react": {
|
|
63
70
|
"optional": true
|
|
64
71
|
},
|
|
@@ -71,6 +78,7 @@
|
|
|
71
78
|
"test": "vp test",
|
|
72
79
|
"typecheck": "tsc --noEmit",
|
|
73
80
|
"check": "vp check",
|
|
74
|
-
"check:write": "vp check --fix"
|
|
81
|
+
"check:write": "vp check --fix",
|
|
82
|
+
"check:package": "publint && attw --pack . --profile esm-only"
|
|
75
83
|
}
|
|
76
84
|
}
|