@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/README.md +53 -28
- 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 +25 -20
- 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/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 +32 -11
- package/dist/use-app-state.d.mts +15 -7
- package/dist/use-app-state.mjs +16 -17
- package/docs/GUIDE.md +450 -115
- package/docs/llms.txt +72 -31
- package/package.json +24 -19
- package/dist/local-state.d.mts +0 -29
- package/dist/local-state.mjs +0 -30
- package/dist/store/local-store.d.mts +0 -9
- package/dist/store/local-store.mjs +0 -79
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,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,
|
|
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
|
-
definition's `storageKey`), the
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
spell at all: a `z.date()`
|
|
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` (
|
|
95
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
|
108
|
-
it, so a Server Component imports it
|
|
109
|
-
state. The live store exists only in
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
the
|
|
113
|
-
|
|
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
|
-
**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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`
|
|
185
|
-
|
|
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
|
-
|
|
458
|
+
Code that has to wait for the write awaits `finished`:
|
|
200
459
|
|
|
201
460
|
```tsx
|
|
202
|
-
const
|
|
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
|
-
|
|
209
|
-
`
|
|
210
|
-
|
|
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
|
|
214
|
-
`update({ page: 0 })` on a `min(1)` field lands on the default
|
|
215
|
-
`?page=0` would, and the echo never shows a value the schema
|
|
216
|
-
value the URL cannot carry is refused there and then, before
|
|
217
|
-
written — the call throws rather than rejecting a handle nobody
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
navigation
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
535
|
+
Two operations depend on the router; everything else works under any router:
|
|
252
536
|
|
|
253
|
-
| operation
|
|
254
|
-
|
|
|
255
|
-
| `href` / `search` links, GET forms
|
|
256
|
-
| entry
|
|
257
|
-
| url `update()` on the client
|
|
258
|
-
| `parseUrl` on the server
|
|
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/
|
|
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
|
|
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
|
-
//
|
|
284
|
-
import type { routes } from '
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
322
|
-
|
|
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
|
|
334
|
-
writes under, and `inlineRead()` returns a JavaScript _expression_
|
|
335
|
-
inline `<script>` that evaluates, in the browser, to the
|
|
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
|
|
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
|
-
|
|
353
|
-
schema rejects falls back to that field's own default, field by
|
|
354
|
-
combination an object-level `refine` forbids falls back to the
|
|
355
|
-
whole. One broken value
|
|
356
|
-
time. An array field collects repeated params
|
|
357
|
-
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.
|
|
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
|
|
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.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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.
|