@k8ordo/state 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/GUIDE.md CHANGED
@@ -1,35 +1,47 @@
1
1
  # @k8ordo/state
2
2
 
3
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
4
+ history entry, localStorage, sessionStorage, a cookie, memory — and one zod
5
+ schema per boundary place (URL, entry, Web Storage, cookie) derives
5
6
  everything else: the server-side read, canonical link building, salvage of
6
7
  stale data, and a client subscription with exact per-key change detection.
8
+ Memory never crosses a boundary, so it is a typed box with no schema.
7
9
 
8
10
  Like every k8ordo package it assumes React 19 and Server Components, uses only
9
11
  what has reached Baseline newly available, and ships no polyfills or legacy
10
12
  fallbacks. The Navigation API reached Baseline in January 2026; this package
11
13
  treats it as simply present.
12
14
 
13
- ## The four places
15
+ ## The places
14
16
 
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 | — |
17
+ | definition | lives in | survives | shared with | server |
18
+ | --------------------------- | ------------------- | ------------------------ | --------------------- | ---------------------------------------------- |
19
+ | `definePageState` — `url` | searchParams | back/forward, sharing | anyone given the URL | reads, where the router passes the search |
20
+ | `definePageState` — `entry` | history entry state | back/forward, reload | that tab's entry | — |
21
+ | `defineLocalState` | localStorage | until deleted | every tab, one device | — |
22
+ | `defineSessionState` | sessionStorage | reload, until tab closes | that tab | — |
23
+ | `defineCookieState` | a cookie | 400 days from last write | every tab, one device | reads, where the page receives request cookies |
24
+ | `defineMemoryState` | the JS runtime | until reload | that tab | — |
21
25
 
22
26
  `url` and `entry` are the two faces of one history entry — one visible and
23
27
  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.
28
+ atomically. Web Storage, cookies and memory are app-scope, not page-scope,
29
+ which is why they are their own kinds: the grouping difference is real, so
30
+ the API makes it visible. sessionStorage is localStorage's per-tab twin,
31
+ built the same way: it survives a reload of the tab — unlike memory — and
32
+ goes when the tab does, seen by no other tab — unlike localStorage. A
33
+ cookie and localStorage hold the same kind of thing — a preference of
34
+ whoever uses the device — and differ in who can read it: every request
35
+ carries the cookie, so the server renders the real value instead of the
36
+ default.
27
37
 
28
38
  ```ts
29
39
  import {
40
+ defineCookieState,
30
41
  defineLocalState,
31
42
  defineMemoryState,
32
43
  definePageState,
44
+ defineSessionState,
33
45
  } from '@k8ordo/state';
34
46
  import * as z from 'zod/mini';
35
47
 
@@ -38,6 +50,7 @@ export const listState = definePageState('product-list', {
38
50
  url: z.object({
39
51
  q: z._default(z.string(), ''),
40
52
  page: z._default(z.coerce.number().check(z.int(), z.gte(1)), 1),
53
+ inStock: z._default(z.stringbool(), false),
41
54
  }),
42
55
  entry: z.object({
43
56
  expanded: z._default(z.array(z.string()), []),
@@ -50,6 +63,20 @@ export const prefs = defineLocalState(
50
63
  z.object({ view: z._default(z.enum(['grid', 'table']), 'grid') }),
51
64
  );
52
65
 
66
+ // This tab's visit: kept through a reload, gone with the tab.
67
+ export const notices = defineSessionState(
68
+ 'notices',
69
+ z.object({ dismissed: z._default(z.array(z.string()), []) }),
70
+ );
71
+
72
+ // A preference the server renders: every request carries it.
73
+ export const density = defineCookieState(
74
+ 'density',
75
+ z.object({
76
+ density: z._default(z.enum(['comfortable', 'compact']), 'comfortable'),
77
+ }),
78
+ );
79
+
53
80
  // A typed shared box. No schema: these values never cross a boundary,
54
81
  // so there is nothing to re-validate — the typed update is the only writer.
55
82
  // Treat the values as immutable: replace fields through update(); a nested
@@ -58,59 +85,136 @@ export const debugPanel = defineMemoryState('debug-panel', { open: false });
58
85
  ```
59
86
 
60
87
  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:
88
+ a user can edit, Web Storage or cookie JSON an older schema wrote, entry
89
+ state a session restore revived — and every such field must tolerate absence:
63
90
  `.default()` (`z._default()` in mini) or `.optional()`. Definitions throw at
64
91
  module load naming the fields that do not. The first argument is the state's
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
68
- the key must be app-unique per kind: two definitions of the same kind sharing
69
- a key silently share one store (and, for local, one storage row). The module
70
- system cannot enforce this, so treat the key like a global name.
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([])` |
92
+ identity: the Web Storage key (`k8ordo-state:<key>` in localStorage or
93
+ sessionStorage, exposed as the definition's `storageKey`), the cookie name (`k8ordo-state.<key>`, exposed as
94
+ `cookieName` — a cookie name is an HTTP token, so a cookie key is refused at
95
+ module load when it holds anything but letters, digits and ``!#$%&'*+-.^_`|~``),
96
+ the entry-state namespace, and the store-registry slot. Renaming it renames
97
+ the data — and the key must be app-unique per kind: two definitions of the
98
+ same kind sharing a key silently share one store (and, for local, session
99
+ and cookie state, one stored row). Different kinds never collide: a local and
100
+ a session definition under the same key are two places. The module system
101
+ cannot enforce this, so treat the key like a global name.
102
+
103
+ **A boundary schema must read back what it writes.** `update()` runs the
104
+ merged values through the schema before anything is written, and a field that
105
+ fails there lands on its default instead of the value you wrote. A url value
106
+ is written into a query string and read again — the road a visitor's URL
107
+ takes — so a url field must read back its own query-string spelling. A boolean
108
+ is written in its schema's own spelling — `"true"`, or the first of a
109
+ `z.stringbool()`'s `truthy` — which is what lets `z.stringbool()` turn it back
110
+ into `true`; an array's booleans are each written in the item schema's. Two
111
+ spellings that cannot are refused at module load rather than at the first
112
+ click:
113
+
114
+ | written | use instead |
115
+ | ------------------------------------------------------------------------- | ---------------- |
116
+ | `z.boolean()` / `z.coerce.boolean()` in `url`, or as a `url` array's item | `z.stringbool()` |
117
+ | a `url` array that is `.optional()` or defaults to anything but `[]` | `.default([])` |
82
118
 
83
119
  A URL carries strings, and `"false"` is not `false` to a boolean schema; an
84
120
  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.
121
+ not `[]` — non-empty, or `undefined` from `.optional()` — could never write
122
+ `[]`. The same rule holds for values a URL cannot spell at all: a `z.date()`
123
+ url field is refused the moment something writes one, naming the field.
124
+
125
+ Entry, local, session and cookie values are handed back to the schema as the
126
+ typed values they are, so there the schema must accept its own output: a
127
+ `z.stringbool()` or a type-changing transform never survives a write — use
128
+ `z.boolean()` and plain types. `update()` does not round-trip the JSON that
129
+ Web Storage and the cookie hold, so keep those fields to what JSON
130
+ represents: a `z.date()` shows its `Date` after the write and falls back to
131
+ its default on the next load.
132
+
133
+ ## When a stored shape changes
134
+
135
+ A localStorage row or a cookie outlives the code that wrote it. On its own, a
136
+ row an older schema wrote salvages field by field: every field the schema
137
+ still accepts keeps its value and the rest land on their defaults — right for
138
+ an added field or a tightened constraint, wrong for a renamed field or a
139
+ changed meaning, which reset without a word. For those, give
140
+ `defineLocalState` or `defineCookieState` a version and a migration:
141
+
142
+ ```ts
143
+ export const prefs = defineLocalState(
144
+ 'prefs',
145
+ z.object({
146
+ view: z._default(z.enum(['grid', 'table']), 'grid'),
147
+ pageSize: z._default(z.number(), 20),
148
+ }),
149
+ {
150
+ version: 1,
151
+ // Rows from before the version stored { layout: 'list' | 'cards' }.
152
+ migrate: (old) => ({
153
+ view: old['layout'] === 'list' ? 'table' : 'grid',
154
+ pageSize: old['pageSize'],
155
+ }),
156
+ },
157
+ );
158
+ ```
159
+
160
+ - **A versioned row is `[version, values]`.** A row with no version — written
161
+ before the definition declared one — reads as version `0`, so a definition
162
+ adopts a version when its shape first changes and migrates its existing
163
+ rows from `0`. Raise the version on the next change and branch on
164
+ `migrate`'s second argument, `fromVersion`.
165
+ - **Older rows are migrated, then read, then written back.** A row older than
166
+ `version` goes through `migrate(old, fromVersion)` and then through the
167
+ schema, field by field like any read: `migrate` returns the schema's keys
168
+ (another key is a type error) with whatever values it has — hand old
169
+ values over as they are, and the schema rejects what does not fit. The
170
+ browser store writes the result back in the current version. The server's
171
+ `parseCookies` migrates too but never writes (a page cannot answer with
172
+ `Set-Cookie`); the browser writes back after hydration, and both read the
173
+ same values, so nothing flashes.
174
+ - **A newer row is left alone.** A row a newer version wrote — a tab that
175
+ loaded the next deploy first — is salvaged without `migrate` and never
176
+ written back, so it stays the newer tab's.
177
+ - **A throwing `migrate` loses nothing.** It reads as nothing stored: the
178
+ defaults show, and the row stays as it was for a fixed `migrate` to try
179
+ again.
180
+ - **Adopting a version changes the row's shape.** A tab still running code
181
+ from before reads `[version, values]` as nothing stored until it reloads.
182
+ - **`inlineRead()` hands out only the current version.** Neither the schema
183
+ nor `migrate` can run before a module loads, so a row of any other version
184
+ evaluates to `null` until a store has migrated it.
185
+ - **Without the option, nothing changes:** a row is the bare values object,
186
+ salvaged field by field. `defineSessionState` takes no version — its rows
187
+ go with the tab, and salvage covers the rare one kept open across a deploy.
88
188
 
89
189
  ## zod, or zod/mini
90
190
 
91
191
  Parsing runs on zod's shared core, so a schema written with either entry
92
192
  works. Unlike `@k8ordo/form` — where the schema stays on the server — the
93
193
  client here parses and serializes with the schema itself, so the schema module
94
- ships to the browser. Reach for `zod/mini` (10 kB gzipped vs 63 kB) unless the
95
- app already pays for classic `zod` elsewhere.
194
+ ships to the browser. Reach for `zod/mini` (a typical definition module
195
+ bundles to about a third of classic `zod`'s gzipped size) unless the app
196
+ already pays for classic `zod` elsewhere.
96
197
 
97
198
  ## The shape of it
98
199
 
99
200
  ```
100
- shared definePageState / defineLocalState / defineMemoryState no 'use client'
201
+ shared definePageState / defineLocalState / defineSessionState
202
+ defineCookieState / defineMemoryState — no 'use client'
101
203
  ↓ import ↓ import
102
204
  server listState.parseUrl(searchParams) client useAppState(def, ['q', 'page'])
103
- listState.href('/products', {...}) → [state, update]
205
+ density.parseCookies(request.cookies) → [state, update]
206
+ listState.href('/products', {...})
104
207
  RSC, Server Action
105
208
  ```
106
209
 
107
- A definition is pure data — schemas and pure functions. No store lives inside
108
- it, so a Server Component imports it without creating server-side mutable
109
- state. The live store exists only in the browser, created lazily by the first
110
- `useAppState`, keyed by the definition's string key (which is what lets state
111
- survive HMR re-evaluation of the definition module). There is no Provider:
112
- the stores mirror browser-wide singletons — the URL, the history entry,
113
- localStorage — so there is nothing to scope.
210
+ A definition is pure data — schemas (or a memory state's initial values) and
211
+ pure functions. No store lives inside it, so a Server Component imports it
212
+ without creating server-side mutable state. The live store exists only in
213
+ the browser, created lazily by the first `useAppState`, keyed by the
214
+ definition's string key (which is what lets state survive HMR re-evaluation
215
+ of the definition module). There is no Provider: the stores mirror
216
+ browser-wide singletons — the URL, the history entry, Web Storage, the
217
+ cookie jar — so there is nothing to scope.
114
218
 
115
219
  ## Server — read, and build links
116
220
 
@@ -120,7 +224,7 @@ example Next.js:
120
224
  ```tsx
121
225
  export default async function Page({ searchParams }: PageProps<'/products'>) {
122
226
  const url = listState.parseUrl(await searchParams);
123
- // ^ { q: string; page: number } — typed, defaults applied
227
+ // ^ { q: string; page: number; inStock: boolean } — typed, defaults applied
124
228
 
125
229
  const products = await fetchProducts(url);
126
230
  return (
@@ -135,27 +239,181 @@ export default async function Page({ searchParams }: PageProps<'/products'>) {
135
239
  }
136
240
  ```
137
241
 
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.
242
+ **In `@k8ordo/framework`'s server mode a page says what of the search it
243
+ reads.** A page
244
+ exports the url schema it reads, and receives that slot, parsed, as `search`:
245
+
246
+ ```tsx
247
+ // src/routes/products/page.tsx
248
+ import type { PageProps } from '@k8ordo/framework';
249
+
250
+ import { listState } from '../../state';
251
+
252
+ export const search = listState.url;
253
+
254
+ export default async function ProductsPage({ search }: PageProps<'/products'>) {
255
+ // ^ { q: string; page: number }
256
+ const products = await fetchProducts(search);
257
+ return <ProductList products={products} />;
258
+ }
259
+ ```
260
+
261
+ The framework reads it the way `parseUrl` does — defaults applied, a
262
+ rejected field salvaged — and, because the page is rendered for the search it
263
+ was given, a navigation that moves the search loads that page again: a GET
264
+ form, a link, or a url `update()`. That load is a state change otherwise — no
265
+ scroll, no focus reset, nothing remounts — and `update().finished` settles
266
+ once the page is on screen with the new search. The router's usual shortcut,
267
+ a same-pathname navigation that loads nothing, still holds for every page
268
+ that did not declare `search`, and for a search that did not move.
269
+
270
+ A page that does not export `search` never sees the search, and neither does
271
+ a layout: the pathname axis is the router's, and the search is read in the
272
+ browser by `useAppState`, so such a server render shows the url slot's
273
+ defaults and the live URL takes over one render after hydration.
274
+ Static mode refuses the export: a file is the same whatever the search
275
+ holds. Building links is unaffected — `href` and `search` are pure and run
276
+ anywhere, including in a Server Component.
277
+
278
+ `urlReader(schema)` is that reading on its own — a url schema in,
279
+ `(input) => values` out, the codec built once — for code handed the schema
280
+ without its definition; the framework's generated table calls it.
147
281
 
148
282
  `href` and `search` are pure: unspecified fields mean their default, and
149
283
  default values are omitted from the query, so every link is canonical and as
150
284
  short as it can be. `search` returns the query string alone (no `?`) when the
151
285
  path should stay in the caller's hands.
152
286
 
287
+ The path `href` takes is written from the application's root, the way the
288
+ route table is, and the link it returns carries Vite's `base` in front of it:
289
+ under `base: '/docs/'`, `listState.href('/products', { page: 2 })` is
290
+ `/docs/products?page=2`. Hand it a path, then — not what `@k8ordo/router`'s
291
+ `href` returned, which carries the base already. Outside Vite (Next.js, say)
292
+ there is no `import.meta.env` to read, nothing is added, and a `basePath` is
293
+ the framework's own `<Link>`'s to add.
294
+
295
+ ## Cookie state — preferences the server renders
296
+
297
+ localStorage never reaches the server, so a preference kept there renders its
298
+ default on the server and flips to the real value after hydration. A cookie
299
+ goes with every request: where the page receives the request's cookies —
300
+ `request.cookies` in `@k8ordo/framework`'s server mode — `parseCookies` reads
301
+ the values
302
+ and `initialCookie` seeds the server render and the hydration render with
303
+ them, so the default never flashes.
304
+
305
+ ```tsx
306
+ // src/routes/layout.tsx — server mode
307
+ import type { LayoutProps } from '@k8ordo/framework';
308
+
309
+ import { Shell } from '../components/shell';
310
+ import { density } from '../state';
311
+
312
+ export default function Layout({ request, children }: LayoutProps<'/'>) {
313
+ return (
314
+ <Shell initialCookie={density.parseCookies(request.cookies)}>
315
+ {children}
316
+ </Shell>
317
+ );
318
+ }
319
+ ```
320
+
321
+ ```tsx
322
+ // src/components/shell.tsx
323
+ 'use client';
324
+ import { useAppState } from '@k8ordo/state';
325
+ import type { OutputOf } from '@k8ordo/state';
326
+ import type { ReactNode } from 'react';
327
+
328
+ import { density } from '../state';
329
+
330
+ type ShellProps = {
331
+ initialCookie: OutputOf<typeof density.schema>;
332
+ children: ReactNode;
333
+ };
334
+
335
+ export function Shell({ initialCookie, children }: ShellProps) {
336
+ const [values, update] = useAppState(density, { initialCookie });
337
+ // …
338
+ }
339
+ ```
340
+
341
+ - **One cookie per definition.** The values are one cookie named
342
+ `k8ordo-state.<key>` (`cookieName`), holding the percent-encoded JSON of the
343
+ declared fields. `parseCookies(cookies)` takes the request's cookies as a
344
+ `ReadonlyMap<string, string>` of percent-decoded values — what
345
+ `request.cookies` is — and salvages field by field like every other read:
346
+ absent, corrupt or rejected values land on their defaults.
347
+ - **Seed every renderer.** Like `initialUrl`, `initialCookie` seeds only the
348
+ `useAppState` call it is passed to: a component that renders on the server
349
+ without it shows the defaults there. Read the cookie once, high up — a
350
+ layout receives `request` too — and pass the result down.
351
+ - **Without the request, it is local state.** In static mode there is no
352
+ request: the server render shows the defaults and hydration takes the
353
+ cookie over, exactly as localStorage does.
354
+ - **The browser writes it with the Cookie Store API** (Baseline since Firefox
355
+ 140 shipped it in June 2025): `Path=/`, `SameSite=Lax`, `Max-Age` of 400
356
+ days — the longest a browser keeps a cookie — renewed by every write, and
357
+ `Secure`, which the API always sets, so the page must be on HTTPS —
358
+ Chromium and Firefox also keep it on `http://localhost`, but Safari drops a
359
+ `Secure` cookie there, so develop over HTTPS to see it persist in Safari.
360
+ `Lax` rather than the API's default `Strict` is what makes the
361
+ first request from a link on another site carry the cookie; under `Strict`
362
+ the server would render the defaults exactly there. A read is synchronous
363
+ (`document.cookie`), because a render cannot wait for the API's promise.
364
+ - **Tabs agree.** The API's `change` event reaches every open tab, and it
365
+ reports a cookie a server response set as well as one a script wrote.
366
+ - **Keep it small.** Every byte rides every request, and a cookie over 4 KB
367
+ (name and value together) is refused: the handle rejects with the API's
368
+ `TypeError` while the rendered value stays, as with a full localStorage.
369
+
370
+ **Not a secret.** A cookie the browser writes can never be `HttpOnly`: any
371
+ script on the page reads and rewrites it, and a visitor can edit it as freely
372
+ as a URL. Keep sessions, tokens and anything else that must not leak or be
373
+ forged out of it — and on the server, treat what `parseCookies` returns as
374
+ input, which is why it passes the schema before you see it.
375
+
376
+ **Cookies the server writes are a different tool.** A page is a render and
377
+ never writes the response; in server mode the framework's
378
+ `cookies()` writes cookies from the places that answer a request — `guard.ts`,
379
+ `route.ts` and Server Actions. That is where a session cookie belongs, as
380
+ `HttpOnly`, and this package never sees it. The same `cookies()` can also
381
+ write a cookie state, for a form that sets a preference without JavaScript,
382
+ say: write `cookieName` with the value `cookieValue(values)` returns — the
383
+ values pass the schema, unspecified fields mean their default — and the
384
+ attributes above (`Path=/`, `SameSite=Lax`, `Max-Age=34560000`), never
385
+ `HttpOnly`, or the browser store can no longer see it. Open tabs take the new
386
+ values in through the `change` event.
387
+
388
+ ```ts
389
+ // src/lib/actions.ts
390
+ 'use server';
391
+ import { cookies } from '@k8ordo/framework/server';
392
+
393
+ import { density } from '../state';
394
+
395
+ export async function compact() {
396
+ cookies().set(
397
+ density.cookieName,
398
+ density.cookieValue({ density: 'compact' }),
399
+ { httpOnly: false, maxAge: 34_560_000 },
400
+ );
401
+ }
402
+ ```
403
+
404
+ `cookieValue` returns the JSON unencoded: `cookies().set` percent-encodes the
405
+ value on its way out (so does Next.js's), which is the one encoding
406
+ `parseCookies` expects undone. Writing a `Set-Cookie` header by hand, pass
407
+ the value through `encodeURIComponent` yourself — once.
408
+
153
409
  ## Client — subscribe and update
154
410
 
155
411
  ```tsx
412
+ // src/components/filters.tsx
156
413
  'use client';
157
414
  import { useAppState } from '@k8ordo/state';
158
- import { listState, prefs } from './state';
415
+
416
+ import { listState, prefs } from '../state';
159
417
 
160
418
  export function Filters({ initialUrl }: FiltersProps) {
161
419
  const [{ q, page, expanded }, update] = useAppState(listState, {
@@ -181,8 +439,9 @@ export function Filters({ initialUrl }: FiltersProps) {
181
439
  ```
182
440
 
183
441
  The hook is the same for every kind. State is the flat merge of a page
184
- definition's two slots — moving a field between `url` and `entry` is a
185
- one-line change in the definition and no change at the call sites.
442
+ definition's two slots — moving a field between `url` and `entry` changes the
443
+ definition and nothing at the call sites. The field's spelling follows its
444
+ slot, though: `z.stringbool()` in `url` is `z.boolean()` in `entry`.
186
445
 
187
446
  ## Updates
188
447
 
@@ -196,35 +455,59 @@ const { committed, finished } = update({ page: 2 });
196
455
  ```
197
456
 
198
457
  Ignoring the handle is the normal case and trips no floating-promise lint.
199
- Awaiting `finished` composes with React 19 async transitions:
458
+ Code that has to wait for the write awaits `finished`:
200
459
 
201
460
  ```tsx
202
- const [isPending, startTransition] = useTransition();
203
- startTransition(async () => {
461
+ const onNext = async () => {
204
462
  await update({ page: 2 }).finished;
205
- });
463
+ heading.current?.focus();
464
+ };
206
465
  ```
207
466
 
208
- A superseded navigation (the user clicked again) rejects the handle with an
209
- `AbortError`; unawaited calls never surface it. Updates with no navigation
210
- behind them — entry-only, local, memory — return the same shape, settled.
467
+ An async action — `startTransition(async …)`, `useTransition`'s included, or
468
+ `@k8ordo/ui`'s `Button` `onAction` — can await it the same way. Under
469
+ `@k8ordo/router` a url update issued while another page is still loading is a
470
+ page change, and a page change never joins the action, so `finished` settles
471
+ once that page is on screen rather than waiting for the action to end.
472
+
473
+ A navigation overtaken by a later one — another page state's write from the
474
+ same handler, say — rejects the handle with an `AbortError`; unawaited calls
475
+ never surface it. Updates with no navigation behind them return the same
476
+ shape: entry-only, local, session and no-change page handles settle once the
477
+ batch is flushed, cookie handles once the Cookie Store API has written it,
478
+ memory handles on the spot. A local, session or cookie write that fails —
479
+ the browser refuses it (a full quota, a cookie over 4 KB), or the schema
480
+ throws on the values it was about to write — rejects the handle with that
481
+ error while the rendered value stays.
211
482
 
212
483
  - **Patches are validated on the spot.** The merged state goes through the
213
- schema inside `update()` itself, by the road a URL arrival takes:
214
- `update({ page: 0 })` on a `min(1)` field lands on the default exactly as
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.
218
- - **Routing is by field.** One patch may span both faces of a page state:
219
- url + entry changes travel in a single `navigation.navigate()`, atomically.
220
- A patch touching only entry fields uses `updateCurrentEntry()` — no
221
- navigation, works under any router.
484
+ schema inside `update()` itself, url fields by the road a URL arrival
485
+ takes: `update({ page: 0 })` on a `min(1)` field lands on the default
486
+ exactly as `?page=0` would, and the echo never shows a value the schema
487
+ rejects. A value the URL cannot carry is refused there and then, before
488
+ anything is written — the call throws rather than rejecting a handle nobody
489
+ awaits.
490
+ - **Routing is by what changed.** One patch may span both faces of a page
491
+ state: a batch that changes a url field travels, with any entry changes, in
492
+ a single `navigation.navigate()`, atomically. A batch that changes entry
493
+ values but no url value uses `updateCurrentEntry()` — no navigation, works
494
+ under any router — even when the patch names url fields. Local and session
495
+ state are one `setItem` on their storage, cookie state one
496
+ `cookieStore.set()`.
222
497
  - **`replace` by default.** An update refines the current entry. Pass
223
498
  `{ history: 'push' }` only for updates the back button should undo — the
224
- option exists only on page state, the one kind with a navigation behind it.
225
- - **One handler, one write.** Several `update()` calls in the same event
226
- handler collapse into a single navigation (or storage write); they share
227
- one handle. A batch that ends where it started does not write at all.
499
+ option exists only on page state, the one kind with a navigation behind it,
500
+ and takes effect only when a url value changes: an entry-only write
501
+ rewrites the current entry in place.
502
+ - **One handler, one write.** Several `update()` calls on a page, local,
503
+ session or cookie state made synchronously in one event handler collapse
504
+ into a single write — one navigation, entry update, storage write or cookie
505
+ write — and share one handle; an `await` between two calls starts a new
506
+ batch with its own write and handle. For page state, a batch that ends where
507
+ it started neither navigates nor touches the entry; local, session and
508
+ cookie state still write their row (creating one if none was stored).
509
+ Memory has nothing to batch: each call applies on the spot and returns its
510
+ own settled handle.
228
511
  - **Functional form.** `update((current) => ({ page: current.page + 1 }))`
229
512
  reads the batched state, not the committed one.
230
513
  - **Shared ground stays shared.** A page state rewrites only its own params
@@ -233,7 +516,8 @@ behind them — entry-only, local, memory — return the same shape, settled.
233
516
 
234
517
  ## Subscription granularity
235
518
 
236
- The schema fixes the key set, so change detection is exact, per key:
519
+ The definition fixes the key set — its schemas' keys, or a memory state's
520
+ initial values — so change detection is exact, per key:
237
521
 
238
522
  ```tsx
239
523
  useAppState(listState); // any declared field re-renders this
@@ -248,22 +532,24 @@ definition is the subscription boundary.
248
532
 
249
533
  ## Routers
250
534
 
251
- Everything except one operation works under any router:
535
+ Two operations depend on the router; everything else works under any router:
252
536
 
253
- | operation | needs |
254
- | ---------------------------------- | ------------------------------------------- |
255
- | `href` / `search` links, GET forms | nothing — the router handles the click |
256
- | entry / local / memory updates | nothing — no navigation is involved |
257
- | url `update()` on the client | a router that intercepts the Navigation API |
258
- | `parseUrl` on the server | a router that hands the page its search |
537
+ | operation | needs |
538
+ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
539
+ | `href` / `search` links, GET forms | nothing — the router or the browser handles the click or the submission |
540
+ | updates that change only entry, local, session, cookie or memory values | nothing — no navigation is involved |
541
+ | url `update()` on the client | a router that intercepts the Navigation API |
542
+ | `parseUrl` on the server | a router that hands the page its search (`@k8ordo/framework` in server mode: `export const search`) |
259
543
 
260
544
  A url `update()` calls `navigation.navigate()`. Under `@k8ordo/router` —
261
- including a page rendered by `@k8ordo/static` or `@k8ordo/server` — a
545
+ including a page rendered by `@k8ordo/framework` — a
262
546
  navigation that keeps the pathname is a state change and not a page change:
263
547
  the router intercepts it without a load, nothing remounts, and scroll and
264
548
  focus stay where they are. `finished` therefore resolves once that navigation
265
549
  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
550
+ the new values — unless the page showing exports `search` in
551
+ server mode and the search moved: then the page loads again, in place,
552
+ and `finished` waits for it to be on screen. On a router that does not intercept the Navigation API
267
553
  (Next.js today), the same call is a full document load: use links and GET
268
554
  forms for url changes there, which is this package's preferred grain anyway.
269
555
  There is deliberately no history-API fallback.
@@ -280,8 +566,8 @@ strip a query from and verify. To constrain paths app-wide, augment `Register`
280
566
  once, with the same line the `@k8ordo/router` augmentation takes:
281
567
 
282
568
  ```ts
283
- // e.g. types/k8ordo.d.ts
284
- import type { routes } from '../routes';
569
+ // src/k8ordo.d.ts
570
+ import type { routes } from './routes';
285
571
 
286
572
  declare module '@k8ordo/router' {
287
573
  interface Register {
@@ -296,12 +582,30 @@ declare module '@k8ordo/state' {
296
582
  }
297
583
  ```
298
584
 
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.
585
+ `href` then checks the path it is handed against the table's linkable
586
+ patterns, segment by segment: a literal segment must be spelled as the pattern
587
+ spells it, a `:param` takes any one non-empty segment — a `${string}` from a
588
+ template literal such as `` `/${locale}/products` `` included — and a `*`
589
+ wildcard is matched but never linked. A path no pattern matches is a type
590
+ error, a `/:locale` table included: `'/ja/nowhere'` is refused even though
591
+ `/:locale` takes any first segment. The error lists what would be accepted —
592
+ the table's linkable patterns. With a table of `/`, `/members` and
593
+ `/posts/:id`, `listState.href('/membrs')` reports:
594
+
595
+ ```
596
+ Argument of type '"/membrs"' is not assignable to parameter of type '"/" | "/members" | "/posts/:id"'.
597
+ ```
598
+
599
+ The check is exported as `RegisteredPath<Path>`: `Path` when accepted, and
600
+ that union of patterns when refused. Each pattern's own spelling is a path
601
+ the table accepts, so the union lets nothing through that the check refuses.
602
+ It goes through `NavigablePath`
603
+ from `@k8ordo/router` as a type only, so the router stays an optional peer and
604
+ never loads at runtime. Under `@k8ordo/framework` this is generated for you into `.k8ordo/register.gen.ts` from
605
+ `routes/` when the application's own `package.json` lists `@k8ordo/state` in
606
+ `dependencies` or `devDependencies` — a transitive dependency does not count.
607
+ Do not hand-write it in such an application: it would duplicate the generated
608
+ declaration.
305
609
 
306
610
  A router that is not `@k8ordo/router` has no table to hand over; register its
307
611
  path union directly under `path` — `Route` from `next`, for instance:
@@ -318,8 +622,9 @@ declare module '@k8ordo/state' {
318
622
  ```
319
623
 
320
624
  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
625
+ path its router does not know, and the error lists the registered union.
626
+ Without the augmentation the constraint is any `/`-prefixed string, and a path
627
+ without the leading `/` is reported against `` `/${string}` ``. Augment only in an application — a shared library
323
628
  augmenting `Register` leaks the constraint to every consumer.
324
629
 
325
630
  ## Reading before hydration
@@ -330,11 +635,13 @@ Some state has to be applied before the first paint: a theme class on
330
635
  alternative — an inline script with the storage key and the JSON envelope
331
636
  hard-coded in a string — drifts the moment either changes.
332
637
 
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
638
+ A local or session definition carries both halves: `storageKey` is the key
639
+ the store writes under, and `inlineRead()` returns a JavaScript _expression_
640
+ for an inline `<script>` that evaluates, in the browser, to the object stored
641
+ in its own storage area — localStorage or sessionStorage — or
336
642
  `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
643
+ or storage cannot be read — and, for a versioned local state, when the row was
644
+ written by another version. The key is escaped for a script context, so any
338
645
  key is safe to emit.
339
646
 
340
647
  ```tsx
@@ -344,17 +651,20 @@ key is safe to emit.
344
651
  The schema does not run there — no module has loaded yet — so what comes back
345
652
  is the raw row, not the salvaged state `useAppState` will show. Treat it as
346
653
  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.
654
+ the hydrated store be the source of truth from then on. The script changes
655
+ `<html>` before React hydrates it, so render that element with
656
+ `suppressHydrationWarning`.
348
657
 
349
658
  ## What it guarantees
350
659
 
351
660
  **Boundary data is input, not trusted state.** A URL param a user edited, a
352
- localStorage row an older schema wrote, a restored entry state — anything the
353
- schema rejects falls back to that field's own default, field by field, and a
354
- combination an object-level `refine` forbids falls back to the defaults as a
355
- whole. One broken value never takes the rest down, and nothing throws at read
356
- time. An array field collects repeated params; a repeated param on a scalar
357
- field takes the first value.
661
+ Web Storage row or a cookie an older schema wrote, a restored entry state —
662
+ anything the schema rejects falls back to that field's own default, field by
663
+ field, and a combination an object-level `refine` forbids falls back to the
664
+ defaults as a whole. One broken value does not take the rest down, and
665
+ nothing throws at read time. An array field collects repeated params and is salvaged as one
666
+ field: one rejected element resets the whole array to `[]`. A repeated param
667
+ on a scalar field takes the first value.
358
668
 
359
669
  **Canonical URLs.** Serialization omits every field sitting at its default, so
360
670
  the same state always produces the same, shortest URL — links, bookmarks and
@@ -365,14 +675,27 @@ the platform, not in a parallel store, so back/forward restore them together
365
675
  with no library bookkeeping.
366
676
 
367
677
  **Tabs agree.** localStorage state propagates across tabs through the
368
- `storage` event; the same keyed subscription granularity applies.
678
+ `storage` event, cookie state through the Cookie Store API's `change` event;
679
+ the same keyed subscription granularity applies. sessionStorage belongs to one
680
+ tab, so its `storage` event reaches only other frames of that tab.
369
681
 
370
682
  **SSR sees real url values when your router hands you the search.** Pass the
371
683
  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.
684
+ show the actual URL state. In `@k8ordo/framework`'s server mode a page that
685
+ exports
686
+ `search = listState.url` receives it already parsed — pass that as
687
+ `initialUrl`. Where a page receives no search — any other page, and every page
688
+ in static mode — the url slot renders its defaults and the live URL
689
+ takes over on hydration.
690
+
691
+ **SSR sees real cookie values when the page receives the request.** Pass what
692
+ `parseCookies(request.cookies)` returned as `initialCookie` — in
693
+ `@k8ordo/framework`'s server mode, say — and neither render shows the
694
+ defaults. Without a request (static mode), the cookie takes over on
695
+ hydration.
696
+
697
+ Everything else — entry, local, session, memory — renders its defaults on the
698
+ server by construction: those places do not exist there.
376
699
 
377
700
  ## GET forms with @k8ordo/form
378
701
 
@@ -385,13 +708,25 @@ import { formFields } from '@k8ordo/form/server';
385
708
  const filterFields = formFields(listState.url); // one schema, both jobs
386
709
  ```
387
710
 
388
- The form submits as GET, the RSC reads it back with `parseUrl`, and the whole
389
- loop works before JavaScript loads.
711
+ A `z.stringbool()` field derives a checkbox whose `value` is the same spelling
712
+ of `true` that `update()` writes, so a checked box submits `inStock=true` —
713
+ the URL state itself would write. The form submits as GET, which writes the
714
+ URL with or without JavaScript.
715
+ Where the router hands the page its search, the RSC reads it back with
716
+ `parseUrl` and the whole loop works before JavaScript loads — in
717
+ `@k8ordo/framework`'s server mode, a page that exports `search = listState.url` is that page,
718
+ and with JavaScript the submission loads it again in place. Anywhere else
719
+ (static mode, a page that does not declare `search`) the server render
720
+ shows the defaults, and the submitted values appear once the page hydrates.
390
721
 
391
722
  ## Testing
392
723
 
393
724
  `resetStateRegistry()` clears the provider-less store registry between tests.
394
725
  Unmount components first — mounted hooks keep their store through closures.
726
+ Clear stored rows by the definition's `storageKey` (in its own storage area)
727
+ or `cookieName` (`await cookieStore.delete(def.cookieName)`); the Cookie
728
+ Store API's `change` event fires in the same tab too, so a test that writes
729
+ the cookie itself stands in for another tab.
395
730
  When testing url updates, intercept the `navigate` event in the test itself
396
731
  (as a router would); an unintercepted `navigation.navigate()` is a
397
732
  cross-document load.