@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 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.
@@ -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>;
@@ -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;
@@ -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 { Route } from 'next';
8
+ * import type { routes } from './routes';
7
9
  * declare module '@k8ordo/state' {
8
- * interface Register { path: Route }
10
+ * interface Register { routes: typeof routes }
9
11
  * }
10
12
  * ```
11
13
  *
12
- * and every `href()` in the app is constrained to its router's route type.
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
- /** `href()`'s path constraint: the registered route type, or any `/`-path. */
18
- type RegisteredPath = Register extends {
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 = storageKeyOf(def);
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
- 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 {
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
- } catch (error) {
76
- current.fail(error);
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;
@@ -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(keys.filter((key) => baseDef(info.shape[key]).type === "array"));
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
- 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
- };
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,
@@ -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 PageState ? {
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
- } : Record<never, never>;
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>`), the entry-state
66
- namespace, and the store-registry slot. Renaming it renames the data — and
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
- <Link href={listState.href('/products', { ...url, page: url.page + 1 })}>
130
+ <a href={listState.href('/products', { ...url, page: url.page + 1 })}>
110
131
  next
111
- </Link>
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. Only the url slot exists on the
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's intercept work (fetch, render) is done
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 passes the schema
183
- inside `update()` itself, with the same salvage as a URL arrival:
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
- 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
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 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.
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 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.
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`, and pairing with `@k8ordo/form` for GET forms.
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. `useAppState(definition, keys?, { initialUrl? })`
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 { path }` to constrain `href` paths to the app's typed
42
- routes.
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.1.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
  }