@lotics/app-sdk 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,289 @@
1
+ # Navigation & client state
2
+
3
+ How an app's screens and view-state live in the URL, and how small client-side
4
+ state persists across sessions. Covers **`AppRouter`** (multi-screen in-app
5
+ routing from the `@lotics/app-sdk/router` entry), **`useUrlState`** + the
6
+ **`urlParam`** codecs (a typed, declared slice of filters/search/sort/tab kept
7
+ in the address bar), and **`useRecents`** (a localStorage-backed recently-used
8
+ list). Read this when an app has more than one screen, when a filtered view
9
+ must survive refresh or be shareable as a link, or when a picker should
10
+ remember what the user chose last.
11
+
12
+ ## The two URL layers
13
+
14
+ The URL carries two independent kinds of app state, each owned by exactly one
15
+ mechanism:
16
+
17
+ | Layer | Owner | What it holds | History behavior |
18
+ | --- | --- | --- | --- |
19
+ | **Routing** (path) | `AppRouter` | Which screen is showing (`/`, `/item/:id`) | Navigations push entries — browser Back/Forward walk screens |
20
+ | **View-state** (query params) | `useUrlState` | Filters, search, sort, active tab | Writes replace in place — never a history entry |
21
+
22
+ They compose: a screen is a path; the screen's filters ride as query params.
23
+ Neither is server state — `useUrlState` values are *client* state that you feed
24
+ into a query's *server* params (see [named-query params](./queries.md) and the
25
+ [data-fetching hooks](./data_fetching.md) they drive). Nothing here is
26
+ persisted server-side; the address bar is the only store.
27
+
28
+ The embedded-vs-standalone distinction below is the runtime's: embedded means
29
+ the app runs in an iframe inside the Lotics host, standalone means it runs on
30
+ its own `<slug>.lotics.app` page (see [runtime](./runtime.md)). Both mechanisms
31
+ expose the same API in both modes with no per-mode code (the few behavioral
32
+ differences — embedded first-paint hydration, cross-screen persistence — are
33
+ flagged below); mode is detected automatically (from the `lotics_host` param
34
+ the host puts on the iframe src — `isEmbedded()` from the main entry exposes it
35
+ if you need it).
36
+
37
+ ## `AppRouter` — in-app routing
38
+
39
+ For a multi-screen app, write plain react-router and wrap the route config in
40
+ `AppRouter`:
41
+
42
+ ```tsx
43
+ import { AppRouter } from "@lotics/app-sdk/router";
44
+
45
+ export default function App() {
46
+ return (
47
+ <AppRouter
48
+ routes={[
49
+ { path: "/", element: <ItemList /> },
50
+ { path: "/item/:id", element: <ItemDetail /> },
51
+ ]}
52
+ />
53
+ );
54
+ }
55
+ ```
56
+
57
+ `routes` is a react-router `RouteObject[]` — nested `children`, `index`
58
+ routes, and splats all work. Inside the tree, use react-router normally:
59
+ `useNavigate`, `useParams`, `useLocation`, `<Link>`, `<Outlet>`.
60
+
61
+ - **Separate entry, optional peer.** `AppRouter` ships from
62
+ `@lotics/app-sdk/router` (signature: `dist/src/router.d.ts`) so apps that
63
+ don't route never pull react-router into their bundle. `react-router-dom`
64
+ (`^7`) is an **optional peer dependency** — an app that imports the router
65
+ entry must install it itself; nothing else in the SDK needs it.
66
+ - **Limitation: element routing only.** `AppRouter` mounts a plain browser
67
+ router, not a react-router *data* router — route `loader`/`action` fields
68
+ are ignored, and `useLoaderData` **throws** ("must be used within a data
69
+ router"). Fetch data inside screen components with the
70
+ [data-fetching hooks](./data_fetching.md).
71
+
72
+ ### The URL model
73
+
74
+ The app owns its **own** URL in both modes — the host URL only ever *mirrors*
75
+ the screen, it never drives the router:
76
+
77
+ - **Standalone** (`<slug>.lotics.app`): a normal browser router. Screens are
78
+ real path URLs, browser Back/Forward are native, and deep-links/refresh work
79
+ because the app host serves the entry HTML for any path the build didn't
80
+ emit (SPA fallback).
81
+
82
+ **Limitation:** the SPA fallback only fires for paths containing **no `.`
83
+ anywhere** — a route path with a dot in it (e.g. `/item/v1.2`) navigates
84
+ fine client-side but returns 404 on standalone refresh/deep-link. Keep dots
85
+ out of route paths (put such values in a query param instead).
86
+
87
+ - **Embedded** (inside the Lotics host): the router drives the **iframe's own
88
+ URL** (the iframe is same-origin to itself). The user sees the host's
89
+ address bar, never the iframe's, and the host never sees the iframe's URL —
90
+ so in-app navigation never causes a host navigation and never remounts or
91
+ reloads the app. The iframe's history still participates in the browser's
92
+ session history, so **Back/Forward walk app screens** (and then leave the
93
+ app).
94
+
95
+ ### Share and refresh survival (embedded)
96
+
97
+ To keep embedded screens shareable and refresh-survivable, `AppRouter` mirrors
98
+ the current screen (path + query + hash) into the **host** URL under the
99
+ `_loc` query param — a non-remounting `history.replaceState` on the host,
100
+ never a navigation. The mirror is write-only from the app's side; on (re)load
101
+ the host reads `_loc` back and bakes it into the iframe `src`, so the app
102
+ boots *directly* at the saved screen — no extra code in the app.
103
+
104
+ Observable details:
105
+
106
+ - The `lotics_host` handshake param is stripped from the mirrored href — it is
107
+ part of the iframe's boot URL, never part of a route.
108
+ - The host honours `_loc` only when it resolves same-origin to the app;
109
+ anything else (a foreign URL, malformed input) boots the app at its root.
110
+ - Standalone needs none of this — the app's own URL already *is* the screen.
111
+
112
+ **Warning — reserved query keys:** `_loc` (the screen mirror), `lotics_host`
113
+ (the host handshake), and `__mock` (the [mock harness](./runtime.md)) belong
114
+ to the framework. Never declare them in a `useUrlState` shape or write them
115
+ yourself; undeclared keys are already preserved automatically (see below).
116
+
117
+ ## `useUrlState` — typed view-state in the address bar
118
+
119
+ Save a declared slice of view-state into the address bar so a filtered view
120
+ survives refresh and is shareable/bookmarkable as a link. Exported from the
121
+ main entry (signature: `dist/src/use_url_state.d.ts`):
122
+
123
+ ```tsx
124
+ import { urlParam, useUrlState } from "@lotics/app-sdk";
125
+
126
+ const [filters, setFilters] = useUrlState({
127
+ q: urlParam.string.withDefault(""),
128
+ status: urlParam.enum(["open", "won", "lost"]), // optional → undefined when absent
129
+ tags: urlParam.arrayOf(urlParam.string).withDefault([]),
130
+ page: urlParam.number.withDefault(1),
131
+ });
132
+ // filters: { q: string; status: "open" | "won" | "lost" | undefined; tags: string[]; page: number }
133
+
134
+ setFilters({ status: "won" }); // merge into the address bar → ?status=won
135
+ setFilters({ page: 2 }); // merge; replaces in place — no history entry
136
+ ```
137
+
138
+ Semantics — each of these is load-bearing:
139
+
140
+ - **The URL is the only store.** `filters` is decoded fresh from the current
141
+ params each render — don't mirror it into `useState`; there is no second
142
+ copy to drift.
143
+ - **Writes replace, never push.** Every `setFilters` is a
144
+ `history.replaceState`-style write — view-state changes never add history
145
+ entries. The back button belongs to routing, not to filters.
146
+ - **Writes are merges over declared keys only.** A patch touches exactly the
147
+ keys it names; every other query param — a key a second `useUrlState` owns,
148
+ the framework's `lotics_host`/`__mock`/`_loc`, a future host param — is read
149
+ past and preserved on write. Multiple `useUrlState` calls with disjoint keys
150
+ coexist without a namespace rule.
151
+ - **Defaults are omitted from the URL.** A value equal to its
152
+ `.withDefault(...)` fallback encodes to nothing — `?page=1` and `?q=` never
153
+ appear, so a shared link carries only what the user actually changed.
154
+ Setting a key back to its default (or an optional key to `undefined`)
155
+ *clears* it from the URL.
156
+ - **Writes are optimistic.** The returned values reflect a write immediately.
157
+ If the underlying address-bar write fails, the failure is logged
158
+ (`console.error`) and the in-session state is kept — only cross-refresh
159
+ persistence is lost.
160
+ - **Back/Forward and hand-edited URLs flow back in.** External URL changes
161
+ re-decode into fresh values automatically in both modes.
162
+ - **Embedded first paint hydrates one tick late.** A standalone app reads its
163
+ own URL synchronously, so the first render already has the URL's values. An
164
+ embedded app cannot read the cross-origin host URL synchronously: the first
165
+ render sees the declared defaults, and the real values arrive right after
166
+ mount over the bridge. Gate an expensive initial fetch accordingly (e.g.
167
+ `enabled`, see [data fetching](./data_fetching.md)) or accept one
168
+ re-render. A user edit made before hydration lands is never clobbered by it.
169
+ - **Declare the shape statically.** Declare it inline, once, with a fixed set
170
+ of keys. The hook reads the codec map through a ref — a shape whose keys or
171
+ codecs change between renders is not re-decoded until the params next
172
+ change.
173
+ - **Debounce text input.** In an embedded app each `setFilters` is a
174
+ cross-frame write. For a search box, keep the live input in local
175
+ `useState` and commit to `setFilters` on a debounce.
176
+
177
+ ### `urlParam` codecs
178
+
179
+ Every base codec is *optional*: an absent key decodes to `undefined`, and
180
+ encoding `undefined` clears the key. Chain `.withDefault(fallback)` to make it
181
+ required — absent (or unparseable) decodes to `fallback`, and a value equal to
182
+ `fallback` is kept out of the URL. Equality for `withDefault` compares dates by
183
+ timestamp and arrays element-wise, so `withDefault([])` and
184
+ `withDefault(new Date(...))` behave correctly. Types are in
185
+ `dist/src/url_params.d.ts`.
186
+
187
+ | Builder | Decoded type | URL form | Decode rules |
188
+ | --- | --- | --- | --- |
189
+ | `urlParam.string` | `string` | `?q=text` | Raw text as-is; an empty `?q=` decodes to `""` |
190
+ | `urlParam.number` | `number` | `?page=2` | `Number(...)`; empty or non-finite input → absent |
191
+ | `urlParam.boolean` | `boolean` | `?done=true` | Only the literals `true`/`false`; anything else → absent |
192
+ | `urlParam.isoDate` | `Date` | `?from=2025-04-01` | Strictly `YYYY-MM-DD`; decodes to UTC midnight of that day |
193
+ | `urlParam.enum([...])` | union of the literals | `?status=open` | A value not in the list → absent |
194
+ | `urlParam.arrayOf(inner)` | `T[]` | `?tag=a&tag=b` (repeated key) | A single occurrence decodes to a 1-element array; elements the inner codec rejects are dropped |
195
+
196
+ Behavior notes:
197
+
198
+ - **Unparseable ≡ absent.** A malformed value (letters in a `number`, a bad
199
+ date, an unknown enum literal) decodes exactly like a missing key:
200
+ `undefined` for an optional codec, the fallback for a defaulted one. A
201
+ hand-mangled shared link degrades to defaults, never to a crash.
202
+ - **Repeated keys on a scalar codec** take the first occurrence.
203
+ - **`arrayOf`**: an empty array encodes to nothing — the key is omitted, so an
204
+ empty array and an absent key are indistinguishable (use
205
+ `.withDefault([])` and both decode to `[]`). The inner codec is any *base*
206
+ builder (`string`, `number`, `boolean`, `isoDate`, `enum([...])`).
207
+ **Limitation:** one level only — `arrayOf(arrayOf(...))` silently encodes to
208
+ nothing.
209
+ - **`isoDate` is UTC calendar-date semantics.** It encodes the UTC date of the
210
+ `Date` instant and decodes to UTC midnight. Dates produced by the codec
211
+ round-trip exactly; a `Date` you construct at *local* midnight in a non-UTC
212
+ timezone can encode as the neighbouring day. Construct date-only values in
213
+ UTC, or reuse decoded values.
214
+
215
+ ### How `useUrlState` relates to routing
216
+
217
+ `useUrlState` and `AppRouter` own different stores — keep each query key owned
218
+ by exactly one mechanism, and read it only through its owner:
219
+
220
+ - `useUrlState` keys live in the **host** address bar when embedded (written
221
+ over the bridge — the app cannot touch the cross-origin host URL itself) and
222
+ in the page URL when standalone. Read them via the hook, never via
223
+ react-router's `useSearchParams`.
224
+ - Query params you put in a route target (`<Link to="/items?tab=recent">`)
225
+ live in the **router's** location. Read them via react-router. Embedded,
226
+ they are part of the screen mirrored under `_loc`, so they are shareable and
227
+ refresh-survivable too.
228
+
229
+ **Warning — cross-screen persistence differs by mode.** Embedded,
230
+ `useUrlState` keys sit in the host URL, and in-app navigation only merges the
231
+ `_loc` mirror into it, leaving every other key alone — navigate away and back
232
+ and the filters are still in the URL. Standalone, a
233
+ react-router navigation rewrites the whole page URL (path *and* query), which
234
+ drops `useUrlState` keys from the address bar. So treat `useUrlState` state as
235
+ scoped to the screen the hook mounts on; state that must ride along a
236
+ navigation in both modes belongs in the route itself (a path param, or query
237
+ params on the `<Link>` target).
238
+
239
+ ## `useRecents` — recently-used items
240
+
241
+ A small, most-recent-first list persisted across sessions — the "recently
242
+ used" affordance a search box or picker shows when focused but empty (pair it
243
+ with a combobox's recent-options slot; see the picker pattern in
244
+ [data fetching](./data_fetching.md)). Exported from the main entry (signature:
245
+ `dist/src/use_recents.d.ts`):
246
+
247
+ ```tsx
248
+ const { recents, remember, forget, clear } = useRecents<Item>("item-picker", {
249
+ keyOf: (item) => item.id,
250
+ max: 8,
251
+ });
252
+ // on select:
253
+ remember(item);
254
+ ```
255
+
256
+ | Member | Behavior |
257
+ | --- | --- |
258
+ | `recents` | Remembered items, most-recent first, deduped, capped |
259
+ | `remember(item)` | Records `item` as most-recent — moves an existing match (by `keyOf`) to the front, caps at `max`, persists. Call on select. |
260
+ | `forget(item)` | Drops one item, matched by `keyOf` |
261
+ | `clear()` | Empties the list |
262
+
263
+ Options: `keyOf` (stable identity per item — used for dedupe and `forget`;
264
+ default `JSON.stringify`) and `max` (default **5**).
265
+
266
+ - **Storage**: `localStorage`, namespaced per `key` (two pickers on one page
267
+ keep separate lists), so it is per browser profile, per device, and per app
268
+ origin — never synced across devices or users. Passing a different `key` to
269
+ a mounted hook re-hydrates from the new namespace.
270
+ - **Failure-tolerant by design**: if storage is unavailable (private mode,
271
+ quota) or holds corrupt JSON, the hook keeps working in-memory for the
272
+ session — only cross-reload persistence is lost. It never throws.
273
+ - **Items round-trip through JSON.** Store plain serializable values; a `Date`
274
+ inside a remembered item comes back as a string after reload. For objects
275
+ you re-create per render, pass a `keyOf` that returns the item's id — the
276
+ default `JSON.stringify` identity treats any field change as a new item.
277
+ - **Limitation — no cross-tab sync**: another tab's writes are not observed
278
+ live; a tab sees them only when a hook next mounts (or its `key` changes).
279
+ Concurrent tabs last-write-win.
280
+
281
+ ## Shipped symbols
282
+
283
+ | Symbol | Entry | Signature |
284
+ | --- | --- | --- |
285
+ | `AppRouter` | `@lotics/app-sdk/router` | `dist/src/router.d.ts` |
286
+ | `useUrlState`, `UrlStateShape`, `UrlStateValues` | `@lotics/app-sdk` | `dist/src/use_url_state.d.ts` |
287
+ | `urlParam`, `UrlParamCodec`, `OptionalUrlParamCodec`, `UrlParams`, `UrlParamValue` | `@lotics/app-sdk` | `dist/src/url_params.d.ts` |
288
+ | `useRecents`, `RecentsApi`, `RecentsOptions` | `@lotics/app-sdk` | `dist/src/use_recents.d.ts` |
289
+ | `isEmbedded` | `@lotics/app-sdk` | `dist/src/rpc.d.ts` |