@k8ordo/state 0.1.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/README.md +168 -0
- package/dist/base.d.mts +9 -0
- package/dist/base.mjs +15 -0
- package/dist/cookie-state.d.mts +38 -0
- package/dist/cookie-state.mjs +35 -0
- package/dist/entry/codec.d.mts +3 -4
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +4 -3
- package/dist/memory-state.d.mts +3 -4
- package/dist/page-state.d.mts +21 -12
- package/dist/page-state.mjs +17 -3
- package/dist/register.d.mts +31 -12
- package/dist/row/codec.d.mts +48 -0
- package/dist/row/codec.mjs +60 -0
- package/dist/schema/object.d.mts +8 -9
- package/dist/schema/object.mjs +10 -11
- package/dist/storage-state.d.mts +37 -0
- package/dist/storage-state.mjs +37 -0
- package/dist/store/cookie-store.d.mts +12 -0
- package/dist/store/cookie-store.mjs +129 -0
- package/dist/store/core.d.mts +12 -12
- package/dist/store/memory-store.d.mts +3 -4
- package/dist/store/page-store.d.mts +7 -6
- package/dist/store/page-store.mjs +29 -32
- package/dist/store/registry.d.mts +3 -4
- package/dist/store/storage-store.d.mts +9 -0
- package/dist/store/storage-store.mjs +97 -0
- package/dist/url/codec.d.mts +4 -5
- package/dist/url/codec.mjs +45 -12
- package/dist/use-app-state.d.mts +21 -9
- package/dist/use-app-state.mjs +16 -17
- package/docs/GUIDE.md +515 -91
- package/docs/llms.txt +74 -23
- package/package.json +29 -16
- package/dist/local-state.d.mts +0 -19
- package/dist/local-state.mjs +0 -19
- package/dist/store/local-store.d.mts +0 -9
- package/dist/store/local-store.mjs +0 -80
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
|
|
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
|
|
15
|
+
## The places
|
|
14
16
|
|
|
15
|
-
| definition | lives in | survives
|
|
16
|
-
| --------------------------- | ------------------- |
|
|
17
|
-
| `definePageState` — `url` | searchParams | back/forward, sharing
|
|
18
|
-
| `definePageState` — `entry` | history entry state | back/forward, reload
|
|
19
|
-
| `defineLocalState` | localStorage | until deleted
|
|
20
|
-
| `
|
|
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.
|
|
25
|
-
why they are their own kinds: the grouping difference is real, so
|
|
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,74 +85,335 @@ 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,
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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([])` |
|
|
118
|
+
|
|
119
|
+
A URL carries strings, and `"false"` is not `false` to a boolean schema; an
|
|
120
|
+
absent param and an empty list are the same URL, so an array whose default is
|
|
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.
|
|
70
188
|
|
|
71
189
|
## zod, or zod/mini
|
|
72
190
|
|
|
73
191
|
Parsing runs on zod's shared core, so a schema written with either entry
|
|
74
192
|
works. Unlike `@k8ordo/form` — where the schema stays on the server — the
|
|
75
193
|
client here parses and serializes with the schema itself, so the schema module
|
|
76
|
-
ships to the browser. Reach for `zod/mini` (
|
|
77
|
-
|
|
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.
|
|
78
197
|
|
|
79
198
|
## The shape of it
|
|
80
199
|
|
|
81
200
|
```
|
|
82
|
-
shared definePageState / defineLocalState /
|
|
201
|
+
shared definePageState / defineLocalState / defineSessionState
|
|
202
|
+
defineCookieState / defineMemoryState — no 'use client'
|
|
83
203
|
↓ import ↓ import
|
|
84
204
|
server listState.parseUrl(searchParams) client useAppState(def, ['q', 'page'])
|
|
85
|
-
|
|
205
|
+
density.parseCookies(request.cookies) → [state, update]
|
|
206
|
+
listState.href('/products', {...})
|
|
86
207
|
RSC, Server Action
|
|
87
208
|
```
|
|
88
209
|
|
|
89
|
-
A definition is pure data — schemas
|
|
90
|
-
it, so a Server Component imports it
|
|
91
|
-
state. The live store exists only in
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
the
|
|
95
|
-
|
|
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.
|
|
96
218
|
|
|
97
219
|
## Server — read, and build links
|
|
98
220
|
|
|
221
|
+
`parseUrl` reads the url slot wherever a router hands a page its search — for
|
|
222
|
+
example Next.js:
|
|
223
|
+
|
|
99
224
|
```tsx
|
|
100
225
|
export default async function Page({ searchParams }: PageProps<'/products'>) {
|
|
101
226
|
const url = listState.parseUrl(await searchParams);
|
|
102
|
-
// ^ { q: string; page: number } — typed, defaults applied
|
|
227
|
+
// ^ { q: string; page: number; inStock: boolean } — typed, defaults applied
|
|
103
228
|
|
|
104
229
|
const products = await fetchProducts(url);
|
|
105
230
|
return (
|
|
106
231
|
<>
|
|
107
232
|
<Filters initialUrl={url} />
|
|
108
233
|
<ProductList products={products} />
|
|
109
|
-
<
|
|
234
|
+
<a href={listState.href('/products', { ...url, page: url.page + 1 })}>
|
|
110
235
|
next
|
|
111
|
-
</
|
|
236
|
+
</a>
|
|
112
237
|
</>
|
|
113
238
|
);
|
|
114
239
|
}
|
|
115
240
|
```
|
|
116
241
|
|
|
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.
|
|
281
|
+
|
|
117
282
|
`href` and `search` are pure: unspecified fields mean their default, and
|
|
118
283
|
default values are omitted from the query, so every link is canonical and as
|
|
119
284
|
short as it can be. `search` returns the query string alone (no `?`) when the
|
|
120
|
-
path should stay in the caller's hands.
|
|
121
|
-
|
|
285
|
+
path should stay in the caller's hands.
|
|
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.
|
|
122
408
|
|
|
123
409
|
## Client — subscribe and update
|
|
124
410
|
|
|
125
411
|
```tsx
|
|
412
|
+
// src/components/filters.tsx
|
|
126
413
|
'use client';
|
|
127
414
|
import { useAppState } from '@k8ordo/state';
|
|
128
|
-
|
|
415
|
+
|
|
416
|
+
import { listState, prefs } from '../state';
|
|
129
417
|
|
|
130
418
|
export function Filters({ initialUrl }: FiltersProps) {
|
|
131
419
|
const [{ q, page, expanded }, update] = useAppState(listState, {
|
|
@@ -151,8 +439,9 @@ export function Filters({ initialUrl }: FiltersProps) {
|
|
|
151
439
|
```
|
|
152
440
|
|
|
153
441
|
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`
|
|
155
|
-
|
|
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`.
|
|
156
445
|
|
|
157
446
|
## Updates
|
|
158
447
|
|
|
@@ -162,37 +451,63 @@ returns the Navigation API's own shape, an object holding two promises:
|
|
|
162
451
|
```ts
|
|
163
452
|
const { committed, finished } = update({ page: 2 });
|
|
164
453
|
// committed: the write is in its home
|
|
165
|
-
// finished: the router
|
|
454
|
+
// finished: whatever the router did after the write is done
|
|
166
455
|
```
|
|
167
456
|
|
|
168
457
|
Ignoring the handle is the normal case and trips no floating-promise lint.
|
|
169
|
-
|
|
458
|
+
Code that has to wait for the write awaits `finished`:
|
|
170
459
|
|
|
171
460
|
```tsx
|
|
172
|
-
const
|
|
173
|
-
startTransition(async () => {
|
|
461
|
+
const onNext = async () => {
|
|
174
462
|
await update({ page: 2 }).finished;
|
|
175
|
-
|
|
463
|
+
heading.current?.focus();
|
|
464
|
+
};
|
|
176
465
|
```
|
|
177
466
|
|
|
178
|
-
|
|
179
|
-
`
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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.
|
|
482
|
+
|
|
483
|
+
- **Patches are validated on the spot.** The merged state goes through the
|
|
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()`.
|
|
190
497
|
- **`replace` by default.** An update refines the current entry. Pass
|
|
191
498
|
`{ 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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
196
511
|
- **Functional form.** `update((current) => ({ page: current.page + 1 }))`
|
|
197
512
|
reads the batched state, not the committed one.
|
|
198
513
|
- **Shared ground stays shared.** A page state rewrites only its own params
|
|
@@ -201,7 +516,8 @@ behind them — entry-only, local, memory — return the same shape, settled.
|
|
|
201
516
|
|
|
202
517
|
## Subscription granularity
|
|
203
518
|
|
|
204
|
-
The
|
|
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:
|
|
205
521
|
|
|
206
522
|
```tsx
|
|
207
523
|
useAppState(listState); // any declared field re-renders this
|
|
@@ -216,19 +532,25 @@ definition is the subscription boundary.
|
|
|
216
532
|
|
|
217
533
|
## Routers
|
|
218
534
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
| operation
|
|
222
|
-
|
|
|
223
|
-
| `
|
|
224
|
-
|
|
|
225
|
-
|
|
|
226
|
-
|
|
|
227
|
-
|
|
228
|
-
A url `update()` calls `navigation.navigate()`.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
535
|
+
Two operations depend on the router; everything else works under any router:
|
|
536
|
+
|
|
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`) |
|
|
543
|
+
|
|
544
|
+
A url `update()` calls `navigation.navigate()`. Under `@k8ordo/router` —
|
|
545
|
+
including a page rendered by `@k8ordo/framework` — a
|
|
546
|
+
navigation that keeps the pathname is a state change and not a page change:
|
|
547
|
+
the router intercepts it without a load, nothing remounts, and scroll and
|
|
548
|
+
focus stay where they are. `finished` therefore resolves once that navigation
|
|
549
|
+
settles, with no fetch or render behind it, since `update()` already rendered
|
|
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
|
|
553
|
+
(Next.js today), the same call is a full document load: use links and GET
|
|
232
554
|
forms for url changes there, which is this package's preferred grain anyway.
|
|
233
555
|
There is deliberately no history-API fallback.
|
|
234
556
|
|
|
@@ -241,7 +563,52 @@ the same division `@k8ordo/form` draws.
|
|
|
241
563
|
`href` keeps the path literal alive in the type
|
|
242
564
|
(``'/products' | `/products?${string}` ``), which is what typed-route checks
|
|
243
565
|
strip a query from and verify. To constrain paths app-wide, augment `Register`
|
|
244
|
-
once:
|
|
566
|
+
once, with the same line the `@k8ordo/router` augmentation takes:
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
// src/k8ordo.d.ts
|
|
570
|
+
import type { routes } from './routes';
|
|
571
|
+
|
|
572
|
+
declare module '@k8ordo/router' {
|
|
573
|
+
interface Register {
|
|
574
|
+
routes: typeof routes;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
declare module '@k8ordo/state' {
|
|
579
|
+
interface Register {
|
|
580
|
+
routes: typeof routes;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
```
|
|
584
|
+
|
|
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.
|
|
609
|
+
|
|
610
|
+
A router that is not `@k8ordo/router` has no table to hand over; register its
|
|
611
|
+
path union directly under `path` — `Route` from `next`, for instance:
|
|
245
612
|
|
|
246
613
|
```ts
|
|
247
614
|
// e.g. types/k8ordo-state.d.ts
|
|
@@ -254,20 +621,50 @@ declare module '@k8ordo/state' {
|
|
|
254
621
|
}
|
|
255
622
|
```
|
|
256
623
|
|
|
257
|
-
Every `href` in the app now rejects a
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
624
|
+
When both are present, `routes` wins. Every `href` in the app now rejects a
|
|
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
|
|
628
|
+
augmenting `Register` leaks the constraint to every consumer.
|
|
629
|
+
|
|
630
|
+
## Reading before hydration
|
|
631
|
+
|
|
632
|
+
Some state has to be applied before the first paint: a theme class on
|
|
633
|
+
`<html>`, say, or a colour scheme the browser must not flash the default of.
|
|
634
|
+
`useAppState` runs after hydration, which is too late for that, and the
|
|
635
|
+
alternative — an inline script with the storage key and the JSON envelope
|
|
636
|
+
hard-coded in a string — drifts the moment either changes.
|
|
637
|
+
|
|
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
|
|
642
|
+
`null` when nothing is stored, the JSON is corrupt, the value is not an object,
|
|
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
|
|
645
|
+
key is safe to emit.
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
<script>{`const s = ${themeState.inlineRead()}; if (s && s.mode === 'dark') document.documentElement.classList.add('dark');`}</script>
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
The schema does not run there — no module has loaded yet — so what comes back
|
|
652
|
+
is the raw row, not the salvaged state `useAppState` will show. Treat it as
|
|
653
|
+
untrusted: read only the fields you need, each with its own fallback, and let
|
|
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`.
|
|
261
657
|
|
|
262
658
|
## What it guarantees
|
|
263
659
|
|
|
264
660
|
**Boundary data is input, not trusted state.** A URL param a user edited, a
|
|
265
|
-
|
|
266
|
-
schema rejects falls back to that field's own default, field by
|
|
267
|
-
combination an object-level `refine` forbids falls back to the
|
|
268
|
-
whole. One broken value
|
|
269
|
-
time. An array field collects repeated params
|
|
270
|
-
field
|
|
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.
|
|
271
668
|
|
|
272
669
|
**Canonical URLs.** Serialization omits every field sitting at its default, so
|
|
273
670
|
the same state always produces the same, shortest URL — links, bookmarks and
|
|
@@ -278,12 +675,27 @@ the platform, not in a parallel store, so back/forward restore them together
|
|
|
278
675
|
with no library bookkeeping.
|
|
279
676
|
|
|
280
677
|
**Tabs agree.** localStorage state propagates across tabs through the
|
|
281
|
-
`storage` event
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
681
|
+
|
|
682
|
+
**SSR sees real url values when your router hands you the search.** Pass the
|
|
683
|
+
RSC-parsed url as `initialUrl` and the server render and the hydration render
|
|
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.
|
|
287
699
|
|
|
288
700
|
## GET forms with @k8ordo/form
|
|
289
701
|
|
|
@@ -296,13 +708,25 @@ import { formFields } from '@k8ordo/form/server';
|
|
|
296
708
|
const filterFields = formFields(listState.url); // one schema, both jobs
|
|
297
709
|
```
|
|
298
710
|
|
|
299
|
-
|
|
300
|
-
|
|
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.
|
|
301
721
|
|
|
302
722
|
## Testing
|
|
303
723
|
|
|
304
724
|
`resetStateRegistry()` clears the provider-less store registry between tests.
|
|
305
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.
|
|
306
730
|
When testing url updates, intercept the `navigate` event in the test itself
|
|
307
731
|
(as a router would); an unintercepted `navigation.navigate()` is a
|
|
308
732
|
cross-document load.
|