@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.
@@ -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.